diff --git a/docs/gateway/policies/apikey-authentication.md b/docs/gateway/policies/apikey-authentication.md new file mode 100644 index 000000000..216e5a9a2 --- /dev/null +++ b/docs/gateway/policies/apikey-authentication.md @@ -0,0 +1,115 @@ +Introduce **APIKeyAuthentication Policy (v1.0.0)** featuring: +- API key validation from headers or query parameters +- Configurable key extraction with optional prefix stripping +- Flexible authentication source configuration (header/query) +- Pre-shared key validation against configured key lists +- Request context enrichment with authentication metadata + +## Configuration Schema + +```yaml +name: APIKeyAuthentication +version: v1.0.0 +description: | + Implements API Key Authentication to protect APIs with pre-shared API keys. + Validates API keys from request headers or query parameters against a configured + list of valid keys and sets authentication metadata in the request context. + +parameters: + type: object + additionalProperties: false + properties: + key: + type: string + required: true + description: | + The name of the header or query parameter that contains the API key. + For headers: case-insensitive matching is used (e.g., "X-API-Key", "Authorization") + For query parameters: exact name matching is used (e.g., "api_key", "token") + validation: + minLength: 1 + maxLength: 128 + + in: + type: string + required: true + description: | + Specifies where to look for the API key. + Must be either "header" or "query". + enum: + - "header" + - "query" + + value-prefix: + type: string + required: false + description: | + Optional prefix that should be stripped from the API key value before validation. + Case-insensitive matching and removal. Common use case is "Bearer " for Authorization headers. + If specified, the prefix will be removed from the extracted value. + validation: + minLength: 1 + maxLength: 64 + + required: + - key + - in + +initParameters: + type: object + properties: {} +``` + +## Configuration Notes + +The APIKeyAuthentication policy configuration only requires user provided parameters when attaching to an API or API resource. The policy does not define system-level initialization parameters (`initParameters` is empty), meaning all configuration is done by the API developer who attaches this policy to an API or API resource. + +Valid API keys are generated by the gateway, management portal, or developer portal. When a request is received, the API key sent in the request will be validated against the keys generated by these services. The policy handles the extraction and validation logic, while the actual key generation and management is handled by the platform's key management system. + +## Example API/Per-Route Configurations + +### Header-based API Key Authentication + +```yaml +# API key in custom header +name: APIKeyAuthentication +version: v1.0.0 +params: + key: X-API-Key + in: header +``` + +### Authorization Header with Bearer Prefix + +```yaml +# API key in Authorization header with Bearer prefix +name: APIKeyAuthentication +version: v1.0.0 +params: + key: Authorization + in: header + value-prefix: "Bearer " +``` + +### Query Parameter Authentication + +```yaml +# API key as query parameter +name: APIKeyAuthentication +version: v1.0.0 +params: + key: api_key + in: query +``` + +### Custom Header with Prefix + +```yaml +# API key in custom header with custom prefix +name: APIKeyAuthentication +version: v1.0.0 +params: + key: X-Custom-Auth + in: header + value-prefix: "ApiKey " +``` diff --git a/gateway/gateway-controller/Makefile b/gateway/gateway-controller/Makefile index 95068fb55..ba2c5b21e 100644 --- a/gateway/gateway-controller/Makefile +++ b/gateway/gateway-controller/Makefile @@ -39,7 +39,6 @@ help: ## Show this help message generate: ## Generate API server code from OpenAPI spec @echo "Generating API server code from OpenAPI spec..." @go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.5.1 --config=oapi-codegen.yaml api/openapi.yaml - test: ## Run unit and integration tests @echo "Running tests..." @go test -v ./... -cover diff --git a/gateway/gateway-controller/api/openapi.yaml b/gateway/gateway-controller/api/openapi.yaml index 13e8188b3..d5411fdec 100644 --- a/gateway/gateway-controller/api/openapi.yaml +++ b/gateway/gateway-controller/api/openapi.yaml @@ -176,7 +176,7 @@ paths: description: Filter by deployment status schema: type: string - enum: [pending, deployed, failed] + enum: [ pending, deployed, failed ] example: pending responses: "200": @@ -347,6 +347,108 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /apis/{id}/api-key: + post: + summary: Generate API key for an API + description: | + Generate a new API key for the specified API. The generated 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 + tags: + - API Management + parameters: + - name: id + in: path + required: true + description: | + Unique public identifier of the API to generate the key for + schema: + type: string + example: weather-api-v1.0 + requestBody: + required: true + content: + application/yaml: + schema: + $ref: "#/components/schemas/APIKeyGenerationRequest" + application/json: + schema: + $ref: "#/components/schemas/APIKeyGenerationRequest" + responses: + '201': + description: API key generated successfully + content: + application/json: + schema: + $ref: "#/components/schemas/APIKeyGenerationResponse" + "400": + description: Invalid configuration (validation failed) + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: API configuration not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /apis/{id}/api-key/{apiKey}: + delete: + summary: Revoke an API key + description: | + Revoke a previously generated API key for the specified API. Once revoked, + the key can no longer be used to authenticate requests. + operationId: revokeAPIKey + tags: + - API Management + parameters: + - name: id + in: path + required: true + description: | + Unique public identifier of the API to revoke the key for + schema: + type: string + example: weather-api-v1.0 + - name: apiKey + in: path + required: true + description: The API key to revoke + schema: + type: string + example: apip_4f3c2e1d5a6b7c8d9e0f1a2b3c4d5e6f + responses: + '204': + description: API key revoked successfully + "400": + description: Invalid configuration (validation failed) + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: API configuration not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /certificates: get: summary: List all custom certificates @@ -608,7 +710,7 @@ paths: description: Filter by deployment status schema: type: string - enum: [pending, deployed, failed] + enum: [ pending, deployed, failed ] example: pending responses: "200": @@ -1757,6 +1859,112 @@ components: format: date-time example: 2025-10-11T11:45:00Z + APIKeyGenerationRequest: + type: object + properties: + name: + type: string + description: Name of the API key + example: my-weather-api-key + operations: + type: array + description: List of API operations the key will have access to + items: + $ref: '#/components/schemas/Operation' + expires_in: + type: object + description: Expiration duration for the API key + properties: + unit: + type: string + description: Time unit for expiration + enum: + - seconds + - minutes + - hours + - days + - weeks + - months + example: days + duration: + type: integer + description: Duration value for expiration + example: 30 + required: + - unit + - duration + expires_at: + type: string + format: date-time + description: Expiration timestamp + example: "2026-12-08T10:30:00Z" + APIKeyGenerationResponse: + type: object + properties: + status: + type: string + example: success + message: + type: string + example: API key generated successfully + api_key: + $ref: '#/components/schemas/APIKey' + required: + - status + - message + APIKey: + type: object + description: Details of an API key + properties: + name: + type: string + description: Name of the API key + example: my-weather-api-key + api_key: + type: string + description: Generated API key with apip_ prefix + example: "apip_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + apiId: + type: string + description: Unique public identifier of the API that the key is associated with + example: weather-api-v1.0 + operations: + type: string + description: List of API operations the key will have access to + example: "[GET /{country_code}/{city}, POST /data]" + status: + type: string + description: Status of the API key + enum: + - active + - revoked + - expired + example: active + created_at: + type: string + format: date-time + description: Timestamp when the API key was generated + example: "2025-12-08T10:30:00Z" + created_by: + type: string + description: Identifier of the user who generated the API key + example: api_consumer + expires_at: + type: string + format: date-time + nullable: true + description: Expiration timestamp (null if no expiration) + example: "2025-12-08T10:30:00Z" + required: + - name + - api_key + - apiId + - operations + - status + - created_at + - created_by + - expires_at + APIListItem: type: object properties: @@ -2559,7 +2767,7 @@ components: example: Certificate uploaded and SDS updated successfully status: type: string - enum: [success, error] + enum: [ success, error ] example: success CertificateListResponse: diff --git a/gateway/gateway-controller/default-policies/APIKeyAuthentication-v1.0.0.yaml b/gateway/gateway-controller/default-policies/APIKeyAuthentication-v1.0.0.yaml new file mode 100644 index 000000000..1482c80ae --- /dev/null +++ b/gateway/gateway-controller/default-policies/APIKeyAuthentication-v1.0.0.yaml @@ -0,0 +1,47 @@ +name: APIKeyAuthentication +version: v1.0.0 +description: | + Implements API Key Authentication to protect APIs with pre-shared API keys. + Validates API keys from request headers or query parameters against a configured + list of valid keys and sets authentication metadata in the request context. + +parameters: + type: object + additionalProperties: false + properties: + key: + type: string + description: | + The name of the header or query parameter that contains the API key. + For headers: case-insensitive matching is used (e.g., "X-API-Key", "Authorization") + For query parameters: exact name matching is used (e.g., "api_key", "token") + validation: + minLength: 1 + maxLength: 128 + + in: + type: string + description: | + Specifies where to look for the API key. + Must be either "header" or "query". + enum: + - "header" + - "query" + + value-prefix: + type: string + description: | + Optional prefix that should be stripped from the API key value before validation. + Case-insensitive matching and removal. Common use case is "Bearer " for Authorization headers. + If specified, the prefix will be removed from the extracted value. + validation: + minLength: 1 + maxLength: 64 + + required: + - key + - in + +systemParameters: + type: object + properties: {} diff --git a/gateway/gateway-controller/pkg/api/generated/generated.go b/gateway/gateway-controller/pkg/api/generated/generated.go index 79f2fd9e9..ef4d42f7a 100644 --- a/gateway/gateway-controller/pkg/api/generated/generated.go +++ b/gateway/gateway-controller/pkg/api/generated/generated.go @@ -41,6 +41,23 @@ const ( APIDetailResponseApiMetadataStatusPending APIDetailResponseApiMetadataStatus = "pending" ) +// Defines values for APIKeyStatus. +const ( + Active APIKeyStatus = "active" + Expired APIKeyStatus = "expired" + Revoked APIKeyStatus = "revoked" +) + +// Defines values for APIKeyGenerationRequestExpiresInUnit. +const ( + Days APIKeyGenerationRequestExpiresInUnit = "days" + Hours APIKeyGenerationRequestExpiresInUnit = "hours" + Minutes APIKeyGenerationRequestExpiresInUnit = "minutes" + Months APIKeyGenerationRequestExpiresInUnit = "months" + Seconds APIKeyGenerationRequestExpiresInUnit = "seconds" + Weeks APIKeyGenerationRequestExpiresInUnit = "weeks" +) + // Defines values for APIListItemStatus. const ( APIListItemStatusDeployed APIListItemStatus = "deployed" @@ -294,6 +311,68 @@ type APIDetailResponse struct { // APIDetailResponseApiMetadataStatus defines model for APIDetailResponse.Api.Metadata.Status. type APIDetailResponseApiMetadataStatus string +// APIKey Details of an API key +type APIKey struct { + // ApiId Unique public identifier of the API that the key is associated with + ApiId string `json:"apiId" yaml:"apiId"` + + // ApiKey Generated API key with apip_ prefix + ApiKey string `json:"api_key" yaml:"api_key"` + + // CreatedAt Timestamp when the API key was generated + CreatedAt time.Time `json:"created_at" yaml:"created_at"` + + // CreatedBy Identifier of the user who generated the API key + CreatedBy string `json:"created_by" yaml:"created_by"` + + // ExpiresAt Expiration timestamp (null if no expiration) + ExpiresAt *time.Time `json:"expires_at" yaml:"expires_at"` + + // Name Name of the API key + Name string `json:"name" yaml:"name"` + + // Operations List of API operations the key will have access to + Operations string `json:"operations" yaml:"operations"` + + // Status Status of the API key + Status APIKeyStatus `json:"status" yaml:"status"` +} + +// APIKeyStatus Status of the API key +type APIKeyStatus string + +// APIKeyGenerationRequest defines model for APIKeyGenerationRequest. +type APIKeyGenerationRequest struct { + // ExpiresAt Expiration timestamp + ExpiresAt *time.Time `json:"expires_at,omitempty" yaml:"expires_at,omitempty"` + + // ExpiresIn Expiration duration for the API key + ExpiresIn *struct { + // Duration Duration value for expiration + Duration int `json:"duration" yaml:"duration"` + + // Unit Time unit for expiration + Unit APIKeyGenerationRequestExpiresInUnit `json:"unit" yaml:"unit"` + } `json:"expires_in,omitempty" yaml:"expires_in,omitempty"` + + // Name Name of the API key + Name *string `json:"name,omitempty" yaml:"name,omitempty"` + + // Operations List of API operations the key will have access to + Operations *[]Operation `json:"operations,omitempty" yaml:"operations,omitempty"` +} + +// APIKeyGenerationRequestExpiresInUnit Time unit for expiration +type APIKeyGenerationRequestExpiresInUnit string + +// APIKeyGenerationResponse defines model for APIKeyGenerationResponse. +type APIKeyGenerationResponse struct { + // ApiKey Details of an API key + ApiKey *APIKey `json:"api_key,omitempty" yaml:"api_key,omitempty"` + Message string `json:"message" yaml:"message"` + Status string `json:"status" yaml:"status"` +} + // APIListItem defines model for APIListItem. type APIListItem struct { Context *string `json:"context,omitempty" yaml:"context,omitempty"` @@ -1083,6 +1162,9 @@ 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 + // UploadCertificateJSONRequestBody defines body for UploadCertificate for application/json ContentType. type UploadCertificateJSONRequestBody = CertificateUploadRequest @@ -1417,6 +1499,12 @@ type ServerInterface interface { // Update an existing API configuration // (PUT /apis/{id}) UpdateAPI(c *gin.Context, id string) + // Generate API key for an API + // (POST /apis/{id}/api-key) + GenerateAPIKey(c *gin.Context, id string) + // Revoke an API key + // (DELETE /apis/{id}/api-key/{apiKey}) + RevokeAPIKey(c *gin.Context, id string, apiKey string) // List all custom certificates // (GET /certificates) ListCertificates(c *gin.Context) @@ -1629,6 +1717,63 @@ func (siw *ServerInterfaceWrapper) UpdateAPI(c *gin.Context) { siw.Handler.UpdateAPI(c, id) } +// GenerateAPIKey operation middleware +func (siw *ServerInterfaceWrapper) GenerateAPIKey(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 + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GenerateAPIKey(c, id) +} + +// RevokeAPIKey operation middleware +func (siw *ServerInterfaceWrapper) RevokeAPIKey(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 "apiKey" ------------- + var apiKey string + + err = runtime.BindStyledParameterWithOptions("simple", "apiKey", c.Param("apiKey"), &apiKey, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter apiKey: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.RevokeAPIKey(c, id, apiKey) +} + // ListCertificates operation middleware func (siw *ServerInterfaceWrapper) ListCertificates(c *gin.Context) { @@ -2152,6 +2297,8 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.DELETE(options.BaseURL+"/apis/:id", wrapper.DeleteAPI) router.GET(options.BaseURL+"/apis/:id", wrapper.GetAPIById) router.PUT(options.BaseURL+"/apis/:id", wrapper.UpdateAPI) + router.POST(options.BaseURL+"/apis/:id/api-key", wrapper.GenerateAPIKey) + router.DELETE(options.BaseURL+"/apis/:id/api-key/:apiKey", wrapper.RevokeAPIKey) router.GET(options.BaseURL+"/certificates", wrapper.ListCertificates) router.POST(options.BaseURL+"/certificates", wrapper.UploadCertificate) router.POST(options.BaseURL+"/certificates/reload", wrapper.ReloadCertificates) @@ -2179,151 +2326,167 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", + "7wAAAP//EqZYsAT/AAA=", } // 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 e80faf4b1..0adbe76fc 100644 --- a/gateway/gateway-controller/pkg/api/handlers/handlers.go +++ b/gateway/gateway-controller/pkg/api/handlers/handlers.go @@ -58,6 +58,7 @@ type APIServer struct { deploymentService *utils.APIDeploymentService mcpDeploymentService *utils.MCPDeploymentService llmDeploymentService *utils.LLMDeploymentService + apiKeyService *utils.APIKeyService controlPlaneClient controlplane.ControlPlaneClient routerConfig *config.RouterConfig httpClient *http.Client @@ -89,6 +90,7 @@ func NewAPIServer( deploymentService: deploymentService, mcpDeploymentService: utils.NewMCPDeploymentService(store, db, snapshotManager), llmDeploymentService: utils.NewLLMDeploymentService(store, db, snapshotManager, templateDefinitions, deploymentService), + apiKeyService: utils.NewAPIKeyService(store, db), controlPlaneClient: controlPlaneClient, routerConfig: routerConfig, httpClient: &http.Client{Timeout: 10 * time.Second}, @@ -770,6 +772,21 @@ func (s *APIServer) DeleteAPI(c *gin.Context, id string) { }) return } + + // Delete associated API keys from database + err := s.db.RemoveAPIKeysAPI(handle) + if err != nil { + log.Warn("Failed to remove API keys from database", + zap.String("handle", handle), + zap.Error(err)) + } + } + + // Remove API keys from ConfigStore + if err := s.store.RemoveAPIKeysByAPI(handle); err != nil { + log.Warn("Failed to remove API keys from ConfigStore", + zap.String("handle", handle), + zap.Error(err)) } if cfg.Configuration.Kind == api.Asyncwebsub { @@ -2169,3 +2186,142 @@ func (s *APIServer) GetConfigDump(c *gin.Context) { zap.Int("policies", len(policies)), zap.Int("certificates", len(certificates))) } + +// GenerateAPIKey implements ServerInterface.GenerateAPIKey +// (POST /apis/{id}/api-key) +func (s *APIServer) GenerateAPIKey(c *gin.Context, id string) { + // Get correlation-aware logger from context + log := middleware.GetLogger(c, s.logger) + handle := id + correlationID := middleware.GetCorrelationID(c) + + // TODO - Do user validation and get user info + user := "api_consumer" // Placeholder for user identification + + log.Debug("Starting API key generation", + zap.String("handle", handle), + zap.String("user", user), + zap.String("correlation_id", correlationID)) + + // Parse and validate request body + var request api.APIKeyGenerationRequest + if err := c.ShouldBindJSON(&request); err != nil { + log.Warn("Invalid request body for API key generation", + zap.Error(err), + zap.String("handle", handle), + zap.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.APIKeyGenerationParams{ + Handle: handle, + Request: request, + User: user, + CorrelationID: correlationID, + Logger: log, + } + + result, err := s.apiKeyService.GenerateAPIKey(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 generation completed", + zap.String("handle", handle), + zap.String("key name", result.Response.ApiKey.Name), + zap.String("user", user), + zap.String("correlation_id", correlationID)) + + // Return the response using the generated schema + c.JSON(http.StatusCreated, result.Response) +} + +// RevokeAPIKey implements ServerInterface.RevokeAPIKey +// (DELETE /apis/{id}/api-key/{apiKey}) +func (s *APIServer) RevokeAPIKey(c *gin.Context, id string, apiKey string) { + // Get correlation-aware logger from context + log := middleware.GetLogger(c, s.logger) + handle := id + correlationID := middleware.GetCorrelationID(c) + + // TODO - Do user validation and get user info + user := "api_consumer" // Placeholder for user identification + + log.Debug("Starting API key revocation", + zap.String("handle", handle), + zap.String("user", user), + zap.String("correlation_id", correlationID)) + + // Parse and validate + if strings.TrimSpace(id) == "" { + log.Warn("API handle is required for revocation", + zap.String("correlation_id", correlationID)) + c.JSON(http.StatusBadRequest, api.ErrorResponse{ + Status: "error", + Message: "API handle is required for revocation", + }) + return + } + if strings.TrimSpace(apiKey) == "" { + log.Warn("API key is required for revocation", + zap.String("handle", handle), + zap.String("correlation_id", correlationID)) + c.JSON(http.StatusBadRequest, api.ErrorResponse{ + Status: "error", + Message: "API key is required for revocation", + }) + return + } + + // Prepare parameters + params := utils.APIKeyRevocationParams{ + Handle: handle, + APIKey: apiKey, + User: user, + CorrelationID: correlationID, + Logger: log, + } + + err := s.apiKeyService.RevokeAPIKey(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 revoked successfully", + zap.String("handle", handle), + zap.String("key", apiKey), + zap.String("user", user), + zap.String("correlation_id", correlationID)) + + // Return the response using the generated schema + c.JSON(http.StatusNoContent, nil) +} diff --git a/gateway/gateway-controller/pkg/models/api_key.go b/gateway/gateway-controller/pkg/models/api_key.go new file mode 100644 index 000000000..7725af622 --- /dev/null +++ b/gateway/gateway-controller/pkg/models/api_key.go @@ -0,0 +1,64 @@ +/* + * 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 models + +import ( + "time" +) + +// APIKeyStatus represents the status of an API key +type APIKeyStatus string + +const ( + APIKeyStatusActive APIKeyStatus = "active" + APIKeyStatusRevoked APIKeyStatus = "revoked" + APIKeyStatusExpired APIKeyStatus = "expired" +) + +// 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"` + Handle string `json:"handle" db:"handle"` + 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"` +} + +// IsValid checks if the API key is valid (active and not expired) +func (ak *APIKey) IsValid() bool { + if ak.Status != APIKeyStatusActive { + return false + } + + if ak.ExpiresAt != nil && time.Now().After(*ak.ExpiresAt) { + return false + } + + return true +} + +// IsExpired checks if the API key has expired +func (ak *APIKey) IsExpired() bool { + return ak.ExpiresAt != nil && time.Now().After(*ak.ExpiresAt) +} diff --git a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql index a53a0d14b..da33e9477 100644 --- a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql +++ b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql @@ -103,5 +103,48 @@ CREATE TABLE IF NOT EXISTS llm_provider_templates ( -- Index for fast name lookups CREATE INDEX IF NOT EXISTS idx_template_handle ON llm_provider_templates(handle); --- Set schema version to 4 -PRAGMA user_version = 4; +-- Table for API keys +CREATE TABLE IF NOT EXISTS api_keys ( + -- Primary identifier (UUID) + id TEXT PRIMARY KEY, + + -- Human-readable name for the API key + name TEXT NOT NULL, + + -- The generated API key + api_key TEXT NOT NULL UNIQUE, + + -- API reference + handle TEXT NOT NULL, + + -- Comma-separated list of operations the key will have access to + operations TEXT NOT NULL DEFAULT '[*]', + + -- Key status + status TEXT NOT NULL CHECK(status IN ('active', 'revoked', 'expired')) DEFAULT 'active', + + -- Timestamps + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- User who generated the API key + created_by TEXT NOT NULL DEFAULT 'system', + + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NULL, -- NULL means no expiration + + -- Foreign key relationship to deployments + FOREIGN KEY (handle) REFERENCES deployments(handle) ON DELETE CASCADE, + + -- Composite unique constraint (handle + api key name must be unique) + UNIQUE (handle, name) +); + +-- Indexes for API key lookups +CREATE INDEX IF NOT EXISTS idx_api_key ON api_keys(api_key); +CREATE INDEX IF NOT EXISTS idx_api_key_api ON api_keys(handle); +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); + +-- Set schema version to 5 +PRAGMA user_version = 5; diff --git a/gateway/gateway-controller/pkg/storage/interface.go b/gateway/gateway-controller/pkg/storage/interface.go index 7bc605e69..375bff7a4 100644 --- a/gateway/gateway-controller/pkg/storage/interface.go +++ b/gateway/gateway-controller/pkg/storage/interface.go @@ -145,6 +145,51 @@ type Storage interface { // May be expensive for large datasets; consider pagination in future versions. GetAllLLMProviderTemplates() ([]*models.StoredLLMProviderTemplate, error) + // SaveAPIKey persists a new API key. + // + // Returns an error if an API key with the same key value already exists. + // Implementations should ensure this operation is atomic (all-or-nothing). + SaveAPIKey(apiKey *models.APIKey) error + + // GetAPIKeyByKey retrieves an API key by its key value. + // + // Returns an error if the API key is not found. + // This is used for API key validation during authentication. + GetAPIKeyByKey(key string) (*models.APIKey, error) + + // GetAPIKeysByAPI retrieves all API keys for a specific API. + // + // Returns an empty slice if no API keys exist for the API. + // Used for listing API keys associated with an API. + GetAPIKeysByAPI(handle string) ([]*models.APIKey, error) + + // GetAPIKeysByAPIAndName retrieves an API key by its name within a specific API. + // + // Returns an error if the API key is not found. + // Used for retrieving specific API keys by name. + GetAPIKeysByAPIAndName(handle, name string) (*models.APIKey, error) + + // UpdateAPIKey updates an existing API key (e.g., to revoke or expire it). + // + // Returns an error if the API key does not exist. + // Implementations should ensure this operation is atomic and thread-safe. + UpdateAPIKey(apiKey *models.APIKey) error + + // DeleteAPIKey removes an API key by its key value. + // + // Returns an error if the API key does not exist. + DeleteAPIKey(key string) error + + // RemoveAPIKeysAPI removes all API keys for a specific API. + // + // Returns an error if API key removal fails. + RemoveAPIKeysAPI(handle string) error + + // RemoveAPIKeyAPIAndName removes an API key by its API handle and name. + // + // Returns an error if the API key does not exist. + RemoveAPIKeyAPIAndName(handle, name string) error + // SaveCertificate persists a new certificate. // // Returns an error if a certificate with the same name already exists. diff --git a/gateway/gateway-controller/pkg/storage/memory.go b/gateway/gateway-controller/pkg/storage/memory.go index 720ef0507..38ed87d81 100644 --- a/gateway/gateway-controller/pkg/storage/memory.go +++ b/gateway/gateway-controller/pkg/storage/memory.go @@ -32,12 +32,17 @@ type ConfigStore struct { mu sync.RWMutex // Protects concurrent access configs map[string]*models.StoredConfig // Key: config ID nameVersion map[string]string // Key: "name:version" → Value: config ID + handle map[string]string // Key: "handle" → Value: config ID snapVersion int64 // Current xDS snapshot version TopicManager *TopicManager // LLM Provider Templates templates map[string]*models.StoredLLMProviderTemplate // Key: template ID templateIdByHandle map[string]string + + // API Keys storage + apiKeys map[string]*models.APIKey // Key: API key value → Value: APIKey + apiKeysByAPI map[string][]*models.APIKey // Key: "handle" → Value: slice of APIKeys } // NewConfigStore creates a new in-memory config store @@ -45,10 +50,13 @@ func NewConfigStore() *ConfigStore { return &ConfigStore{ configs: make(map[string]*models.StoredConfig), nameVersion: make(map[string]string), + handle: make(map[string]string), snapVersion: 0, TopicManager: NewTopicManager(), templates: make(map[string]*models.StoredLLMProviderTemplate), templateIdByHandle: make(map[string]string), + apiKeys: make(map[string]*models.APIKey), + apiKeysByAPI: make(map[string][]*models.APIKey), } } @@ -58,12 +66,18 @@ func (cs *ConfigStore) Add(cfg *models.StoredConfig) error { defer cs.mu.Unlock() key := cfg.GetCompositeKey() + handle := cfg.GetHandle() + if existingID, exists := cs.handle[key]; exists { + return fmt.Errorf("%w: configuration with handle '%s' already exists (ID: %s)", + ErrConflict, handle, existingID) + } if existingID, exists := cs.nameVersion[key]; exists { return fmt.Errorf("%w: configuration with displayName '%s' and version '%s' already exists (ID: %s)", ErrConflict, cfg.GetDisplayName(), cfg.GetVersion(), existingID) } cs.configs[cfg.ID] = cfg + cs.handle[handle] = cfg.ID cs.nameVersion[key] = cfg.ID if cfg.Configuration.Kind == api.Asyncwebsub { @@ -85,6 +99,20 @@ func (cs *ConfigStore) Update(cfg *models.StoredConfig) error { return fmt.Errorf("configuration with ID '%s' not found", cfg.ID) } + // If handle changed, update the handle index + oldHandle := existing.GetHandle() + newHandle := cfg.GetHandle() + + if oldHandle != newHandle { + // Check if new handle already exists + if existingID, exists := cs.handle[newHandle]; exists && existingID != cfg.ID { + return fmt.Errorf("%w: configuration with handle '%s' already exists (ID: %s)", + ErrConflict, newHandle, existingID) + } + delete(cs.handle, oldHandle) + cs.handle[newHandle] = cfg.ID + } + // If name/version changed, update the nameVersion index oldKey := existing.GetCompositeKey() newKey := cfg.GetCompositeKey() @@ -149,10 +177,12 @@ func (cs *ConfigStore) Delete(id string) error { } key := cfg.GetCompositeKey() + handle := cfg.GetHandle() if cfg.Configuration.Kind == api.Asyncwebsub { cs.TopicManager.RemoveAllForConfig(cfg.ID) } + delete(cs.handle, handle) delete(cs.nameVersion, key) delete(cs.configs, id) return nil @@ -188,6 +218,28 @@ func (cs *ConfigStore) GetByNameVersion(name, version string) (*models.StoredCon return cfg, nil } +// GetByHandle retrieves a configuration by handle +func (cs *ConfigStore) GetByHandle(handle string) (*models.StoredConfig, error) { + cs.mu.RLock() + defer cs.mu.RUnlock() + + key := fmt.Sprintf("%s", handle) + configID, exists := cs.handle[key] + if !exists { + return nil, fmt.Errorf("configuration with handle '%s' not found", handle) + } + + cfg, exists := cs.configs[configID] + if !exists { + return nil, fmt.Errorf("configuration with handle '%s' not found", handle) + } + + if cfg.GetHandle() != handle { + return nil, fmt.Errorf("configuration with handle '%s' not found", handle) + } + return cfg, nil +} + // GetAll returns all configurations func (cs *ConfigStore) GetAll() []*models.StoredConfig { cs.mu.RLock() @@ -397,3 +449,213 @@ func (cs *ConfigStore) GetAllTemplates() []*models.StoredLLMProviderTemplate { return templates } + +// StoreAPIKey stores an API key in the in-memory cache +func (cs *ConfigStore) StoreAPIKey(apiKey *models.APIKey) error { + if apiKey == nil { + return fmt.Errorf("API key cannot be nil") + } + if strings.TrimSpace(apiKey.Name) == "" { + return fmt.Errorf("API key name cannot be empty") + } + if strings.TrimSpace(apiKey.APIKey) == "" { + return fmt.Errorf("API key value cannot be empty") + } + if strings.TrimSpace(apiKey.Handle) == "" { + return fmt.Errorf("API handle cannot be empty") + } + + cs.mu.Lock() + defer cs.mu.Unlock() + + // Check if an API key with the same handle and name already exists + existingKeys, handleExists := cs.apiKeysByAPI[apiKey.Handle] + var existingKeyIndex = -1 + var oldAPIKeyValue string + + if handleExists { + for i, existingKey := range existingKeys { + if existingKey.Name == apiKey.Name { + existingKeyIndex = i + oldAPIKeyValue = existingKey.APIKey + break + } + } + } + + // Check if the new API key value already exists (but with different handle/name) + if _, keyExists := cs.apiKeys[apiKey.APIKey]; keyExists && oldAPIKeyValue != apiKey.APIKey { + return ErrConflict + } + + if existingKeyIndex >= 0 { + // Update existing API key + // Remove old API key value from apiKeys map if it's different + if oldAPIKeyValue != apiKey.APIKey { + delete(cs.apiKeys, oldAPIKeyValue) + } + + // Update the existing entry in apiKeysByAPI + cs.apiKeysByAPI[apiKey.Handle][existingKeyIndex] = apiKey + + // Store by new API key value + cs.apiKeys[apiKey.APIKey] = apiKey + } else { + // Insert new API key + // Check if API key value already exists + if _, exists := cs.apiKeys[apiKey.APIKey]; exists { + return ErrConflict + } + + // Store by API key value + cs.apiKeys[apiKey.APIKey] = apiKey + + // Store by API handle + cs.apiKeysByAPI[apiKey.Handle] = append(cs.apiKeysByAPI[apiKey.Handle], apiKey) + } + + return nil +} + +// GetAPIKeyByKey retrieves an API key by its key value +func (cs *ConfigStore) GetAPIKeyByKey(key string) (*models.APIKey, error) { + cs.mu.RLock() + defer cs.mu.RUnlock() + + apiKey, exists := cs.apiKeys[key] + if !exists { + return nil, ErrNotFound + } + + return apiKey, nil +} + +// GetAPIKeysByAPI retrieves all API keys for a specific API +func (cs *ConfigStore) GetAPIKeysByAPI(handle string) ([]*models.APIKey, error) { + cs.mu.RLock() + defer cs.mu.RUnlock() + + apiKeys, exists := cs.apiKeysByAPI[handle] + if !exists { + return []*models.APIKey{}, nil // Return empty slice instead of nil + } + + // Return a copy to prevent external modification + result := make([]*models.APIKey, len(apiKeys)) + copy(result, apiKeys) + return result, nil +} + +// GetAPIKeyByName retrieves an API key by its handle and name +func (cs *ConfigStore) GetAPIKeyByName(handle, name string) (*models.APIKey, error) { + cs.mu.RLock() + defer cs.mu.RUnlock() + + apiKeys, exists := cs.apiKeysByAPI[handle] + if !exists { + return nil, ErrNotFound + } + + // Search for the API key with the matching name + for _, apiKey := range apiKeys { + if apiKey.Name == name { + return apiKey, nil + } + } + + return nil, ErrNotFound +} + +// RemoveAPIKey removes an API key from the in-memory cache +func (cs *ConfigStore) RemoveAPIKey(apiKey string) error { + cs.mu.Lock() + defer cs.mu.Unlock() + + // Get the API key first to find its API association + key, exists := cs.apiKeys[apiKey] + if !exists { + return ErrNotFound + } + + // Remove from main map + delete(cs.apiKeys, apiKey) + + // Remove from API-specific map + if apiKeys, exists := cs.apiKeysByAPI[key.Handle]; exists { + for i, k := range apiKeys { + if k.APIKey == apiKey { + // Remove from slice + cs.apiKeysByAPI[key.Handle] = append(apiKeys[:i], apiKeys[i+1:]...) + break + } + } + // Clean up empty slices + if len(cs.apiKeysByAPI[key.Handle]) == 0 { + delete(cs.apiKeysByAPI, key.Handle) + } + } + + return nil +} + +// RemoveAPIKeysByAPI removes all API keys for a specific API +func (cs *ConfigStore) RemoveAPIKeysByAPI(handle string) error { + cs.mu.Lock() + defer cs.mu.Unlock() + + apiKeys, exists := cs.apiKeysByAPI[handle] + if !exists { + return nil // No keys to remove + } + + // Remove from main map + for _, key := range apiKeys { + delete(cs.apiKeys, key.APIKey) + } + + // Remove from API-specific map + delete(cs.apiKeysByAPI, handle) + + return nil +} + +// RemoveAPIKeyByName removes an API key from the in-memory cache by its handle and name +func (cs *ConfigStore) RemoveAPIKeyByName(handle, name string) error { + cs.mu.Lock() + defer cs.mu.Unlock() + + // Get API keys for the handle + apiKeys, exists := cs.apiKeysByAPI[handle] + if !exists { + return ErrNotFound + } + + // Find the API key with the matching name + var targetAPIKey *models.APIKey + var targetIndex = -1 + + for i, apiKey := range apiKeys { + if apiKey.Name == name { + targetAPIKey = apiKey + targetIndex = i + break + } + } + + if targetAPIKey == nil { + return ErrNotFound + } + + // Remove from main apiKeys map + delete(cs.apiKeys, targetAPIKey.APIKey) + + // Remove from apiKeysByAPI slice + cs.apiKeysByAPI[handle] = append(apiKeys[:targetIndex], apiKeys[targetIndex+1:]...) + + // Clean up empty slices + if len(cs.apiKeysByAPI[handle]) == 0 { + delete(cs.apiKeysByAPI, handle) + } + + return nil +} diff --git a/gateway/gateway-controller/pkg/storage/sqlite.go b/gateway/gateway-controller/pkg/storage/sqlite.go index 5d4166af3..3cb1b0a03 100644 --- a/gateway/gateway-controller/pkg/storage/sqlite.go +++ b/gateway/gateway-controller/pkg/storage/sqlite.go @@ -172,6 +172,46 @@ func (s *SQLiteStorage) initSchema() error { version = 4 } + if version == 4 { + // Add API keys table + if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + api_key TEXT NOT NULL UNIQUE, + handle TEXT NOT NULL, + operations TEXT NOT NULL DEFAULT '*', + status TEXT NOT NULL CHECK(status IN ('active', 'revoked', 'expired')) DEFAULT 'active', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by TEXT NOT NULL DEFAULT 'system', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NULL, + FOREIGN KEY (handle) REFERENCES deployments(handle) ON DELETE CASCADE, + UNIQUE (handle, name) + );`); 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) + } + if _, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_key_api ON api_keys(handle);`); err != nil { + return fmt.Errorf("failed to create api_keys handle index: %w", err) + } + if _, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_key_status ON api_keys(status);`); err != nil { + return fmt.Errorf("failed to create api_keys status index: %w", err) + } + if _, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_key_expiry ON api_keys(expires_at);`); err != nil { + return fmt.Errorf("failed to create api_keys expiry index: %w", err) + } + if _, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_created_by ON api_keys(created_by);`); err != nil { + return fmt.Errorf("failed to create api_keys created_by index: %w", err) + } + if _, err := s.db.Exec("PRAGMA user_version = 5"); err != nil { + return fmt.Errorf("failed to set schema version to 5: %w", err) + } + s.logger.Info("Schema migrated to version 5 (api_keys table)") + version = 5 + } + s.logger.Info("Database schema up to date", zap.Int("version", version)) } @@ -1007,6 +1047,347 @@ func (s *SQLiteStorage) DeleteCertificate(id string) error { return nil } +// API Key Storage Methods + +// SaveAPIKey persists a new API key to the database or updates existing one +// if an API key with the same handle 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 { + 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 + } + }() + + // First, check if an API key with the same handle and name exists + checkQuery := `SELECT id FROM api_keys WHERE handle = ? AND name = ?` + var existingID string + err = tx.QueryRow(checkQuery, apiKey.Handle, apiKey.Name).Scan(&existingID) + + if err != nil && !errors.Is(err, sql.ErrNoRows) { + tx.Rollback() + return fmt.Errorf("failed to check existing API key: %w", err) + } + + if errors.Is(err, sql.ErrNoRows) { + // No existing record, insert new API key + insertQuery := ` + INSERT INTO api_keys ( + id, name, api_key, handle, operations, status, + created_at, created_by, updated_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + + _, err := tx.Exec(insertQuery, + apiKey.ID, + apiKey.Name, + apiKey.APIKey, + apiKey.Handle, + apiKey.Operations, + apiKey.Status, + apiKey.CreatedAt, + apiKey.CreatedBy, + apiKey.UpdatedAt, + apiKey.ExpiresAt, + ) + + 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 insert API key: %w", err) + } + + s.logger.Info("API key inserted successfully", + zap.String("id", apiKey.ID), + zap.String("name", apiKey.Name), + zap.String("handle", apiKey.Handle), + zap.String("created_by", apiKey.CreatedBy)) + } else { + // Existing record found, update it with new API key data + updateQuery := ` + UPDATE api_keys + SET api_key = ?, operations = ?, status = ?, created_by = ?, updated_at = ?, expires_at = ? + WHERE handle = ? AND name = ? + ` + + _, err := tx.Exec(updateQuery, + apiKey.APIKey, + apiKey.Operations, + apiKey.Status, + apiKey.CreatedBy, + apiKey.UpdatedAt, + apiKey.ExpiresAt, + apiKey.Handle, + 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", + zap.String("existing_id", existingID), + zap.String("name", apiKey.Name), + zap.String("handle", apiKey.Handle), + zap.String("created_by", apiKey.CreatedBy)) + } + + // Commit the transaction + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} + +// GetAPIKeyByKey retrieves an API key by its key value +func (s *SQLiteStorage) GetAPIKeyByKey(key string) (*models.APIKey, error) { + query := ` + SELECT id, name, api_key, handle, operations, status, + created_at, created_by, updated_at, expires_at + FROM api_keys + WHERE api_key = ? + ` + + var apiKey models.APIKey + var expiresAt sql.NullTime + + err := s.db.QueryRow(query, key).Scan( + &apiKey.ID, + &apiKey.Name, + &apiKey.APIKey, + &apiKey.Handle, + &apiKey.Operations, + &apiKey.Status, + &apiKey.CreatedAt, + &apiKey.CreatedBy, + &apiKey.UpdatedAt, + &expiresAt, + ) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("%w: key not found", ErrNotFound) + } + return nil, fmt.Errorf("failed to query API key: %w", err) + } + + // Handle nullable expires_at field + if expiresAt.Valid { + apiKey.ExpiresAt = &expiresAt.Time + } + + return &apiKey, nil +} + +// GetAPIKeysByAPI retrieves all API keys for a specific API +func (s *SQLiteStorage) GetAPIKeysByAPI(handle string) ([]*models.APIKey, error) { + query := ` + SELECT id, name, api_key, handle, operations, status, + created_at, created_by, updated_at, expires_at + FROM api_keys + WHERE handle = ? + ORDER BY created_at DESC + ` + + rows, err := s.db.Query(query, handle) + if err != nil { + return nil, fmt.Errorf("failed to query API keys: %w", err) + } + defer rows.Close() + + var apiKeys []*models.APIKey + + for rows.Next() { + var apiKey models.APIKey + var expiresAt sql.NullTime + + err := rows.Scan( + &apiKey.ID, + &apiKey.Name, + &apiKey.APIKey, + &apiKey.Handle, + &apiKey.Operations, + &apiKey.Status, + &apiKey.CreatedAt, + &apiKey.CreatedBy, + &apiKey.UpdatedAt, + &expiresAt, + ) + + if err != nil { + return nil, fmt.Errorf("failed to scan API key row: %w", err) + } + + // Handle nullable expires_at field + if expiresAt.Valid { + apiKey.ExpiresAt = &expiresAt.Time + } + + apiKeys = append(apiKeys, &apiKey) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating API key rows: %w", err) + } + + return apiKeys, nil +} + +// GetAPIKeysByAPIAndName retrieves an API key by its handle and name +func (s *SQLiteStorage) GetAPIKeysByAPIAndName(handle, name string) (*models.APIKey, error) { + query := ` + SELECT id, name, api_key, handle, operations, status, + created_at, created_by, updated_at, expires_at + FROM api_keys + WHERE handle = ? AND name = ? + LIMIT 1 + ` + + var apiKey models.APIKey + var expiresAt sql.NullTime + + err := s.db.QueryRow(query, handle, name).Scan( + &apiKey.ID, + &apiKey.Name, + &apiKey.APIKey, + &apiKey.Handle, + &apiKey.Operations, + &apiKey.Status, + &apiKey.CreatedAt, + &apiKey.CreatedBy, + &apiKey.UpdatedAt, + &expiresAt, + ) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("failed to query API key by name: %w", err) + } + + // Handle nullable expires_at field + if expiresAt.Valid { + apiKey.ExpiresAt = &expiresAt.Time + } + + 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, + apiKey.Status, + apiKey.UpdatedAt, + apiKey.ExpiresAt, + apiKey.APIKey, + ) + + if err != nil { + 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) + } + + if rows == 0 { + return fmt.Errorf("%w: API key not found", ErrNotFound) + } + + s.logger.Info("API key updated successfully", + zap.String("id", apiKey.ID), + zap.String("status", string(apiKey.Status))) + + return nil +} + +// DeleteAPIKey removes an API key by its key value +func (s *SQLiteStorage) DeleteAPIKey(key string) error { + query := `DELETE FROM api_keys WHERE api_key = ?` + + result, err := s.db.Exec(query, key) + if err != nil { + return fmt.Errorf("failed to delete API key: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + + if rows == 0 { + return fmt.Errorf("%w: API key not found", ErrNotFound) + } + + s.logger.Info("API key deleted successfully", zap.String("key_prefix", key[:min(8, len(key))]+"***")) + + return nil +} + +// RemoveAPIKeysAPI removes an API keys by handle +func (s *SQLiteStorage) RemoveAPIKeysAPI(handle string) error { + query := `DELETE FROM api_keys WHERE handle = ?` + + _, err := s.db.Exec(query, handle) + if err != nil { + return fmt.Errorf("failed to remove API keys for API: %w", err) + } + + s.logger.Info("API keys removed successfully", + zap.String("handle", handle)) + + return nil +} + +// RemoveAPIKeyAPIAndName removes an API key by its handle and name +func (s *SQLiteStorage) RemoveAPIKeyAPIAndName(handle, name string) error { + query := `DELETE FROM api_keys WHERE handle = ? AND name = ?` + + result, err := s.db.Exec(query, handle, name) + if err != nil { + return fmt.Errorf("failed to remove API key: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + + if rows == 0 { + return fmt.Errorf("%w: API key not found", ErrNotFound) + } + + s.logger.Info("API key removed successfully", + zap.String("handle", handle), + zap.String("name", name)) + + return nil +} + // Close closes the database connection func (s *SQLiteStorage) Close() error { s.logger.Info("Closing SQLite storage") @@ -1121,3 +1502,10 @@ func isCertificateUniqueConstraintError(err error) bool { return err != nil && (err.Error() == "UNIQUE constraint failed: certificates.name" || err.Error() == "UNIQUE constraint failed: certificates.id") } + +// Helper function to check for API key unique constraint errors +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") +} diff --git a/gateway/gateway-controller/pkg/utils/api_key.go b/gateway/gateway-controller/pkg/utils/api_key.go new file mode 100644 index 000000000..fac7ea494 --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/api_key.go @@ -0,0 +1,465 @@ +/* + * 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 ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/generated" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" + "go.uber.org/zap" +) + +// APIKeyGenerationParams contains parameters for API key generation operations +type APIKeyGenerationParams struct { + Handle string // API handle/ID + Request api.APIKeyGenerationRequest // Request body with API key generation details + User string // User who initiated the request + CorrelationID string // Correlation ID for tracking + Logger *zap.Logger // Logger instance +} + +// APIKeyGenerationResult contains the result of API key generation +type APIKeyGenerationResult struct { + Response api.APIKeyGenerationResponse // Response following the generated schema + IsRetry bool // Whether this was a retry due to collision +} + +// APIKeyRevocationParams contains parameters for API key revocation operations +type APIKeyRevocationParams struct { + Handle string // API handle/ID + APIKey string // APi key to be revoked + User string // User who initiated the request + CorrelationID string // Correlation ID for tracking + Logger *zap.Logger // Logger instance +} + +// APIKeyService provides utilities for API configuration deployment +type APIKeyService struct { + store *storage.ConfigStore + db storage.Storage +} + +// NewAPIKeyService creates a new API key generation service +func NewAPIKeyService(store *storage.ConfigStore, db storage.Storage) *APIKeyService { + return &APIKeyService{ + store: store, + db: db, + } +} + +const APIKeyPrefix = "apip_" + +// GenerateAPIKey handles the complete API key generation process +func (s *APIKeyService) GenerateAPIKey(params APIKeyGenerationParams) (*APIKeyGenerationResult, error) { + logger := params.Logger + + // Validate that API exists + config, err := s.store.GetByHandle(params.Handle) + if err != nil { + logger.Warn("API configuration not found for API Key generation", + zap.String("handle", params.Handle), + zap.String("correlation_id", params.CorrelationID)) + return nil, fmt.Errorf("API configuration handle '%s' not found", params.Handle) + } + + // Generate the API key from request + apiKey, err := s.generateAPIKeyFromRequest(params.Handle, ¶ms.Request, params.User, config) + if err != nil { + logger.Error("Failed to generate API key", + zap.Error(err), + zap.String("handle", params.Handle), + zap.String("correlation_id", params.CorrelationID)) + return nil, fmt.Errorf("failed to generate API key: %w", err) + } + + result := &APIKeyGenerationResult{ + IsRetry: false, + } + + // Save API key to database (only if persistent mode) + 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", + zap.String("handle", params.Handle), + zap.String("correlation_id", params.CorrelationID)) + + // Generate a new key + apiKey, err = s.generateAPIKeyFromRequest(params.Handle, ¶ms.Request, params.User, config) + if err != nil { + logger.Error("Failed to regenerate API key after collision", + zap.Error(err), + zap.String("correlation_id", params.CorrelationID)) + return nil, fmt.Errorf("failed to regenerate 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", + zap.Error(err), + zap.String("correlation_id", params.CorrelationID)) + 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", + zap.Error(err), + zap.String("handle", params.Handle), + zap.String("correlation_id", params.CorrelationID)) + return nil, fmt.Errorf("failed to save API key to database: %w", err) + } + } + } + + // Store the generated API key in the ConfigStore + if err := s.store.StoreAPIKey(apiKey); err != nil { + logger.Error("Failed to store API key in ConfigStore", + zap.Error(err), + zap.String("handle", params.Handle), + zap.String("correlation_id", params.CorrelationID)) + + // Rollback database save to maintain consistency + if s.db != nil { + if delErr := s.db.RemoveAPIKeyAPIAndName(apiKey.Handle, apiKey.Name); delErr != nil { + logger.Error("Failed to rollback API key from database", + zap.Error(delErr), + zap.String("correlation_id", params.CorrelationID)) + } + } + return nil, fmt.Errorf("failed to store API key in ConfigStore: %w", err) + } + + apiConfig, err := config.Configuration.Spec.AsAPIConfigData() + if err != nil { + logger.Error("Failed to parse API configuration data", + zap.Error(err), + zap.String("handle", params.Handle), + zap.String("correlation_id", params.CorrelationID)) + return nil, fmt.Errorf("failed to parse API configuration data: %w", err) + } + + apiName := apiConfig.DisplayName + apiVersion := apiConfig.Version + logger.Info("Storing API key in policy engine", + zap.String("handle", params.Handle), + zap.String("name", apiKey.Name), + zap.String("api_name", apiName), + zap.String("api_version", apiVersion), + zap.String("user", params.User), + zap.String("correlation_id", params.CorrelationID)) + + // TODO - Send the API key to the policy engine + // err := StoreAPIKey(apiName, apiVersion string, apiKey *APIKey, params.CorrelationID string) + + // Build response following the generated schema + result.Response = s.buildAPIKeyResponse(apiKey) + + logger.Info("API key generated successfully", + zap.String("handle", params.Handle), + zap.String("name", apiKey.Name), + zap.String("user", params.User), + zap.Bool("is_retry", result.IsRetry), + zap.String("correlation_id", params.CorrelationID)) + + return result, nil +} + +// RevokeAPIKey handles the API key revocation process +func (s *APIKeyService) RevokeAPIKey(params APIKeyRevocationParams) error { + logger := params.Logger + + // Validate that API exists + config, err := s.store.GetByHandle(params.Handle) + if err != nil { + logger.Warn("API configuration not found for API key revocation", + zap.String("handle", params.Handle), + zap.String("correlation_id", params.CorrelationID)) + return fmt.Errorf("API configuration handle '%s' not found", params.Handle) + } + + // Get the API key by its value + apiKey, err := s.store.GetAPIKeyByKey(params.APIKey) + if err != nil { + logger.Debug("API key not found for revocation", + zap.String("handle", params.Handle), + zap.String("correlation_id", params.CorrelationID)) + //return nil + } + + // For security reasons, perform all validations but don't return errors + // This prevents information leakage about API key details + if apiKey != nil { + // Check if the API key belongs to the specified API + if apiKey.Handle != params.Handle { + logger.Debug("API key does not belong to the specified API", + zap.String("expected_handle", params.Handle), + zap.String("actual_handle", apiKey.Handle), + zap.String("correlation_id", params.CorrelationID)) + return fmt.Errorf("API key revocation failed for API: '%s'", params.Handle) + } + + // Check if the user revoking the key is the same as the one who created it + if apiKey.CreatedBy != params.User { + logger.Debug("User attempting to revoke API key is not the creator", + zap.String("handle", params.Handle), + zap.String("creator", apiKey.CreatedBy), + zap.String("requesting_user", params.User), + zap.String("correlation_id", params.CorrelationID)) + return 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", + zap.String("handle", params.Handle), + zap.String("correlation_id", params.CorrelationID)) + return nil + } + + // At this point, all validations passed, proceed with actual revocation + // Set status to revoked and update timestamp + apiKey.Status = models.APIKeyStatusRevoked + apiKey.UpdatedAt = time.Now() + + // Update the API key status in the database (if persistent mode) + if s.db != nil { + if err := s.db.UpdateAPIKey(apiKey); err != nil { + logger.Error("Failed to update API key status in database", + zap.Error(err), + zap.String("handle", params.Handle), + zap.String("correlation_id", params.CorrelationID)) + return fmt.Errorf("failed to revoke API key: %w", err) + } + } + + // Remove the API key from memory store + if err := s.store.RemoveAPIKey(params.APIKey); err != nil { + logger.Error("Failed to remove API key from memory store", + zap.Error(err), + zap.String("handle", params.Handle), + zap.String("correlation_id", params.CorrelationID)) + + // 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", + zap.Error(rollbackErr), + zap.String("correlation_id", params.CorrelationID)) + } + } + return fmt.Errorf("failed to revoke API key: %w", err) + } + } + + // Remove the API key from database (complete removal) + // Note: This is cleanup only - the revocation is already complete + if s.db != nil { + if err := s.db.DeleteAPIKey(params.APIKey); err != nil { + logger.Warn("Failed to remove API key from database, but revocation was successful", + zap.Error(err), + zap.String("handle", params.Handle), + zap.String("correlation_id", params.CorrelationID)) + // Don't return error - revocation was already successful + // The key is marked as revoked in DB and removed from memory + } + } + + // remove the api key from the policy engine + apiConfig, err := config.Configuration.Spec.AsAPIConfigData() + if err != nil { + logger.Error("Failed to parse API configuration data", + zap.Error(err), + zap.String("handle", params.Handle), + zap.String("correlation_id", params.CorrelationID)) + return fmt.Errorf("failed to revoke API key: %w", err) + } + + apiName := apiConfig.DisplayName + apiVersion := apiConfig.Version + logger.Info("Removing API key from policy engine", + zap.String("handle", params.Handle), + zap.String("api key", s.maskAPIKey(params.APIKey)), + zap.String("api_name", apiName), + zap.String("api_version", apiVersion), + zap.String("user", params.User), + zap.String("correlation_id", params.CorrelationID)) + + // TODO - Send the API key revocation to the policy engine + // err := RevokeAPIKey(apiName, apiVersion, params.APIKey, params.CorrelationID string) + // if err != nil { + // logger.Error("Failed to remove api key from policy engine", + // zap.Error(err), + // zap.String("correlation_id", params.CorrelationID)) + // return fmt.Errorf("failed to revoke API key: %w", err) + // } + + logger.Info("API key revoked successfully", + zap.String("handle", params.Handle), + zap.String("api key", s.maskAPIKey(params.APIKey)), + zap.String("user", params.User), + zap.String("correlation_id", params.CorrelationID)) + + return nil +} + +// generateAPIKeyFromRequest creates a new API key based on the APIKeyGenerationRequest +func (s *APIKeyService) generateAPIKeyFromRequest(handle string, request *api.APIKeyGenerationRequest, user string, + config *models.StoredConfig) (*models.APIKey, error) { + // Prevent unused parameter build error - config may be used in future enhancements + _ = config + + // Generate UUID for the record ID + id := uuid.New().String() + + // Generate 32 random bytes for the API key + randomBytes := make([]byte, 32) + if _, err := rand.Read(randomBytes); err != nil { + return nil, fmt.Errorf("failed to generate random bytes: %w", err) + } + + // Encode to hex and prefix + apiKeyValue := APIKeyPrefix + hex.EncodeToString(randomBytes) + + // 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) + } + + // Process operations + operations := "[*]" // Default to all operations + if request.Operations != nil && len(*request.Operations) > 0 { + operations = s.generateOperationsString(*request.Operations) + } + + now := time.Now() + + // Calculate expiration time + var expiresAt *time.Time + if request.ExpiresAt != nil { + expiresAt = request.ExpiresAt + } else if request.ExpiresIn != nil { + duration := time.Duration(request.ExpiresIn.Duration) + switch request.ExpiresIn.Unit { + case api.Seconds: + duration *= time.Second + case api.Minutes: + duration *= time.Minute + case api.Hours: + duration *= time.Hour + case api.Days: + duration *= 24 * time.Hour + case api.Weeks: + duration *= 7 * 24 * time.Hour + case api.Months: + duration *= 30 * 24 * time.Hour // Approximate month as 30 days + default: + return nil, fmt.Errorf("unsupported expiration unit: %s", request.ExpiresIn.Unit) + } + expiry := now.Add(duration) + expiresAt = &expiry + } + + if user == "" { + user = "system" + } + + return &models.APIKey{ + ID: id, + Name: name, + APIKey: apiKeyValue, + Handle: handle, + Operations: operations, + Status: models.APIKeyStatusActive, + CreatedAt: now, + CreatedBy: user, + UpdatedAt: now, + ExpiresAt: expiresAt, + }, nil +} + +// generateOperationsString creates a string array from operations in format "METHOD path" +// Example: ["GET /{country_code}/{city}", "POST /data"] +// Ignores the policies field from operations +func (s *APIKeyService) generateOperationsString(operations []api.Operation) string { + if len(operations) == 0 { + return "[*]" // Default to all operations if none specified + } + + var operationStrings []string + for _, op := range operations { + // Format: "METHOD path" (ignoring policies) + operationStr := fmt.Sprintf("%s %s", op.Method, op.Path) + operationStrings = append(operationStrings, operationStr) + } + + // Create JSON array string with comma-separated operations + operationsJSON, err := json.Marshal(operationStrings) + if err != nil { + // Fallback to default if marshaling fails + return "[*]" + } + + return string(operationsJSON) +} + +// buildAPIKeyResponse builds the response following the generated schema +func (s *APIKeyService) buildAPIKeyResponse(key *models.APIKey) api.APIKeyGenerationResponse { + if key == nil { + return api.APIKeyGenerationResponse{ + Status: "error", + Message: "API key is nil", + } + } + + return api.APIKeyGenerationResponse{ + Status: "success", + Message: "API key generated successfully", + ApiKey: &api.APIKey{ + Name: key.Name, + ApiKey: key.APIKey, + ApiId: key.Handle, + Operations: key.Operations, + Status: api.APIKeyStatus(key.Status), + CreatedAt: key.CreatedAt, + CreatedBy: key.CreatedBy, + ExpiresAt: key.ExpiresAt, + }, + } +} + +// maskAPIKey masks an API key for secure logging, showing first 8 and last 4 characters +func (s *APIKeyService) maskAPIKey(apiKey string) string { + if len(apiKey) <= 12 { + return "****" + } + return apiKey[:8] + "****" + apiKey[len(apiKey)-4:] +} diff --git a/gateway/policies/api-key-auth/v1.0.0/apikey.go b/gateway/policies/api-key-auth/v1.0.0/apikey.go new file mode 100644 index 000000000..4f514f76f --- /dev/null +++ b/gateway/policies/api-key-auth/v1.0.0/apikey.go @@ -0,0 +1,323 @@ +/* + * Copyright (c) 2025, 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 apikey + +import ( + "encoding/json" + "fmt" + policy "github.com/wso2/api-platform/sdk/gateway/policy/v1alpha" + "log/slog" + "net/http" + "net/url" + "strings" +) + +const ( + // Metadata keys for context storage + MetadataKeyAuthSuccess = "auth.success" + MetadataKeyAuthMethod = "auth.method" +) + +// APIKeyPolicy implements API Key Authentication +type APIKeyPolicy struct { +} + +var ins = &APIKeyPolicy{} + +func GetPolicy( + metadata policy.PolicyMetadata, + params map[string]interface{}, +) (policy.Policy, error) { + return ins, nil +} + +// Mode returns the processing mode for this policy +func (p *APIKeyPolicy) Mode() policy.ProcessingMode { + return policy.ProcessingMode{ + RequestHeaderMode: policy.HeaderModeProcess, // Process request headers for auth + RequestBodyMode: policy.BodyModeSkip, // Don't need request body + ResponseHeaderMode: policy.HeaderModeSkip, // Don't process response headers + ResponseBodyMode: policy.BodyModeSkip, // Don't need response body + } +} + +// OnRequest performs API Key Authentication +func (p *APIKeyPolicy) OnRequest(ctx *policy.RequestContext, params map[string]interface{}) policy.RequestAction { + slog.Debug("API Key Auth Policy: OnRequest started", + "path", ctx.Path, + "method", ctx.Method, + "apiName", ctx.APIName, + "apiVersion", ctx.APIVersion, + ) + + // Get configuration parameters + keyName, ok := params["key"].(string) + if !ok || keyName == "" { + slog.Debug("API Key Auth Policy: Missing or invalid 'key' configuration", + "keyName", keyName, + "ok", ok, + ) + return p.handleAuthFailure(ctx, 401, "json", "Valid API key required", + "missing or invalid 'key' configuration") + } + + location, ok := params["in"].(string) + if !ok || location == "" { + slog.Debug("API Key Auth Policy: Missing or invalid 'in' configuration", + "location", location, + "ok", ok, + ) + return p.handleAuthFailure(ctx, 401, "json", "Valid API key required", + "missing or invalid 'in' configuration") + } + + var valuePrefix string + if valuePrefixRaw, ok := params["value-prefix"]; ok { + if vp, ok := valuePrefixRaw.(string); ok { + valuePrefix = vp + } + } + + slog.Debug("API Key Auth Policy: Configuration loaded", + "keyName", keyName, + "location", location, + "valuePrefix", valuePrefix, + ) + + // Extract API key based on location + var providedKey string + + if location == "header" { + // Check header (case-insensitive) + if headerValues := ctx.Headers.Get(http.CanonicalHeaderKey(keyName)); len(headerValues) > 0 { + providedKey = headerValues[0] + slog.Debug("API Key Auth Policy: Found API key in header", + "headerName", keyName, + "keyLength", len(providedKey), + ) + } + } else if location == "query" { + // Extract query parameters from the full path + providedKey = extractQueryParam(ctx.Path, keyName) + if providedKey != "" { + slog.Debug("API Key Auth Policy: Found API key in query parameter", + "paramName", keyName, + "keyLength", len(providedKey), + ) + } + } + + // If no API key provided + if providedKey == "" { + slog.Debug("API Key Auth Policy: No API key found", + "location", location, + "keyName", keyName, + ) + return p.handleAuthFailure(ctx, 401, "json", "Valid API key required", + "missing API key") + } + + // Strip prefix if configured + if valuePrefix != "" { + originalLength := len(providedKey) + providedKey = stripPrefix(providedKey, valuePrefix) + slog.Debug("API Key Auth Policy: Processed value prefix", + "prefix", valuePrefix, + "originalLength", originalLength, + "processedLength", len(providedKey), + ) + + // If after stripping prefix, the key is empty, treat as missing + if providedKey == "" { + slog.Debug("API Key Auth Policy: API key became empty after prefix removal") + return p.handleAuthFailure(ctx, 401, "json", "Valid API key required", + "missing API key") + } + } + + apiName := ctx.APIName + apiVersion := ctx.APIVersion + apiOperation := ctx.OperationPath + operationMethod := ctx.Method + + if apiName == "" || apiVersion == "" || apiOperation == "" || operationMethod == "" { + slog.Debug("API Key Auth Policy: Missing API details for validation", + "apiName", apiName, + "apiVersion", apiVersion, + "apiOperation", apiOperation, + "operationMethod", operationMethod, + ) + return p.handleAuthFailure(ctx, 401, "json", "Valid API key required", + "missing API details for validation") + } + + slog.Debug("API Key Auth Policy: Starting validation", + "apiName", apiName, + "apiVersion", apiVersion, + "apiOperation", apiOperation, + "operationMethod", operationMethod, + "keyLength", len(providedKey), + ) + + // API key was provided - validate it using external validation + isValid, err := p.validateAPIKey(apiName, apiVersion, apiOperation, operationMethod, providedKey) + if err != nil { + slog.Debug("API Key Auth Policy: Validation error", + "error", err, + ) + return p.handleAuthFailure(ctx, 401, "json", "Valid API key required", + "error validating API key") + } + if !isValid { + slog.Debug("API Key Auth Policy: Invalid API key") + return p.handleAuthFailure(ctx, 401, "json", "Valid API key required", + "invalid API key") + } + + // Authentication successful + slog.Debug("API Key Auth Policy: Authentication successful") + return p.handleAuthSuccess(ctx) +} + +// handleAuthSuccess handles successful authentication +func (p *APIKeyPolicy) handleAuthSuccess(ctx *policy.RequestContext) policy.RequestAction { + slog.Debug("API Key Auth Policy: handleAuthSuccess called", + "apiName", ctx.APIName, + "apiVersion", ctx.APIVersion, + "method", ctx.Method, + "path", ctx.Path, + ) + + // Set metadata indicating successful authentication + ctx.Metadata[MetadataKeyAuthSuccess] = true + ctx.Metadata[MetadataKeyAuthMethod] = "api-key" + + slog.Debug("API Key Auth Policy: Authentication metadata set", + "authSuccess", true, + "authMethod", "api-key", + ) + + // Continue to upstream with no modifications + return policy.UpstreamRequestModifications{} +} + +// OnResponse is not used by this policy (authentication is request-only) +func (p *APIKeyPolicy) OnResponse(_ctx *policy.ResponseContext, _params map[string]interface{}) policy.ResponseAction { + return nil // No response processing needed +} + +// handleAuthFailure handles authentication failure +func (p *APIKeyPolicy) handleAuthFailure(ctx *policy.RequestContext, statusCode int, errorFormat, errorMessage, + reason string) policy.RequestAction { + slog.Debug("API Key Auth Policy: handleAuthFailure called", + "statusCode", statusCode, + "errorFormat", errorFormat, + "errorMessage", errorMessage, + "reason", reason, + "apiName", ctx.APIName, + "apiVersion", ctx.APIVersion, + "method", ctx.Method, + "path", ctx.Path, + ) + + // Set metadata indicating failed authentication + ctx.Metadata[MetadataKeyAuthSuccess] = false + ctx.Metadata[MetadataKeyAuthMethod] = "api-key" + + headers := map[string]string{ + "content-type": "application/json", + } + + var body string + switch errorFormat { + case "plain": + body = errorMessage + headers["content-type"] = "text/plain" + default: // json + errResponse := map[string]interface{}{ + "error": "Unauthorized", + "message": errorMessage, + } + bodyBytes, _ := json.Marshal(errResponse) + body = string(bodyBytes) + } + + slog.Debug("API Key Auth Policy: Returning immediate response", + "statusCode", statusCode, + "contentType", headers["content-type"], + "bodyLength", len(body), + "reason", reason, + ) + + return policy.ImmediateResponse{ + StatusCode: statusCode, + Headers: headers, + Body: []byte(body), + } +} + +// validateAPIKey validates the provided API key against external store/service +func (p *APIKeyPolicy) validateAPIKey(apiName, apiVersion, apiOperation, operationMethod, apiKey string) (bool, error) { + apiKeyStore := policy.GetAPIkeyStoreInstance() + isValid, err := apiKeyStore.ValidateAPIKey(apiName, apiVersion, apiOperation, operationMethod, apiKey) + if err != nil { + return false, fmt.Errorf("failed to validate API key via the policy engine") + } + return isValid, nil +} + +// extractQueryParam extracts the first value of the given query parameter from the request path +func extractQueryParam(path, param string) string { + // Parse the URL-encoded path + decodedPath, err := url.PathUnescape(path) + if err != nil { + return "" + } + + // Split the path into components + parts := strings.Split(decodedPath, "?") + if len(parts) != 2 { + return "" + } + + // Parse the query string + queryString := parts[1] + values, err := url.ParseQuery(queryString) + if err != nil { + return "" + } + + // Get the first value of the specified parameter + if value, ok := values[param]; ok && len(value) > 0 { + return value[0] + } + + return "" +} + +// stripPrefix removes the specified prefix from the value (case-insensitive) +// Returns the value with prefix removed, or empty string if prefix doesn't match +func stripPrefix(value, prefix string) string { + // Do exact case-insensitive prefix matching + if len(value) >= len(prefix) && strings.EqualFold(value[:len(prefix)], prefix) { + return value[len(prefix):] + } + + // No matching prefix found, return empty string + return "" +} diff --git a/gateway/policies/api-key-auth/v1.0.0/go.mod b/gateway/policies/api-key-auth/v1.0.0/go.mod new file mode 100644 index 000000000..574f25d06 --- /dev/null +++ b/gateway/policies/api-key-auth/v1.0.0/go.mod @@ -0,0 +1,7 @@ +module github.com/policy-engine/policies/api-key-auth + +go 1.23.0 + +require github.com/wso2/api-platform/sdk v1.0.0 + +replace github.com/wso2/api-platform/sdk => ../../../../sdk diff --git a/gateway/policies/api-key-auth/v1.0.0/policy-definition.yaml b/gateway/policies/api-key-auth/v1.0.0/policy-definition.yaml new file mode 100644 index 000000000..1482c80ae --- /dev/null +++ b/gateway/policies/api-key-auth/v1.0.0/policy-definition.yaml @@ -0,0 +1,47 @@ +name: APIKeyAuthentication +version: v1.0.0 +description: | + Implements API Key Authentication to protect APIs with pre-shared API keys. + Validates API keys from request headers or query parameters against a configured + list of valid keys and sets authentication metadata in the request context. + +parameters: + type: object + additionalProperties: false + properties: + key: + type: string + description: | + The name of the header or query parameter that contains the API key. + For headers: case-insensitive matching is used (e.g., "X-API-Key", "Authorization") + For query parameters: exact name matching is used (e.g., "api_key", "token") + validation: + minLength: 1 + maxLength: 128 + + in: + type: string + description: | + Specifies where to look for the API key. + Must be either "header" or "query". + enum: + - "header" + - "query" + + value-prefix: + type: string + description: | + Optional prefix that should be stripped from the API key value before validation. + Case-insensitive matching and removal. Common use case is "Bearer " for Authorization headers. + If specified, the prefix will be removed from the extracted value. + validation: + minLength: 1 + maxLength: 64 + + required: + - key + - in + +systemParameters: + type: object + properties: {} diff --git a/gateway/policies/policy-manifest.yaml b/gateway/policies/policy-manifest.yaml index 88f036111..82c526382 100644 --- a/gateway/policies/policy-manifest.yaml +++ b/gateway/policies/policy-manifest.yaml @@ -54,3 +54,8 @@ policies: - name: JSONSchemaGuardrail version: v0.1.0 filePath: ./json-schema-guardrail/v0.1.0 + + # API Key Authentication Policy + - name: APIKeyAuthentication + version: v1.0.0 + filePath: ./api-key-auth/v1.0.0 diff --git a/sdk/gateway/policy/v1alpha/api_key.go b/sdk/gateway/policy/v1alpha/api_key.go new file mode 100644 index 000000000..f3b138df5 --- /dev/null +++ b/sdk/gateway/policy/v1alpha/api_key.go @@ -0,0 +1,282 @@ +package policyv1alpha + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" +) + +type APIKey struct { + // ID of the API Key + ID string `json:"id" yaml:"id"` + // Name of the API key + Name string `json:"name" yaml:"name"` + // ApiKey API key with apip_ prefix + APIKey string `json:"api_key" yaml:"api_key"` + // Handle Unique public identifier of the API that the key is associated with + Handle string `json:"handle" yaml:"handle"` + // 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"` + // 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"` +} + +// APIKeyStatus Status of the API key +type APIKeyStatus string + +// Defines values for APIKeyStatus. +const ( + Active APIKeyStatus = "active" + Expired APIKeyStatus = "expired" + Revoked APIKeyStatus = "revoked" +) + +// 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 + apiKeys map[string]*APIKey // Key: API key value → Value: APIKey + apiKeysByAPI map[string][]*APIKey // Key: "name:version" → Value: slice of APIKeys +} + +// NewAPIkeyStore creates a new in-memory API key store +func NewAPIkeyStore() *APIkeyStore { + return &APIkeyStore{ + apiKeys: make(map[string]*APIKey), + apiKeysByAPI: make(map[string][]*APIKey), + } +} + +// 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(name, version string, apiKey *APIKey) error { + if apiKey == nil { + return fmt.Errorf("API key cannot be nil") + } + + aks.mu.Lock() + defer aks.mu.Unlock() + + handleKey := compositeKey(name, version) + + // Check if an API key with the same handleKey and name already exists + existingKeys, handleExists := aks.apiKeysByAPI[handleKey] + var existingKeyIndex = -1 + var oldAPIKeyValue string + + if handleExists { + for i, existingKey := range existingKeys { + if existingKey.Name == apiKey.Name { + existingKeyIndex = i + oldAPIKeyValue = existingKey.APIKey + break + } + } + } + + // Check if the new API key value already exists (but with different handleKey/name) + if _, keyExists := aks.apiKeys[apiKey.APIKey]; keyExists && oldAPIKeyValue != apiKey.APIKey { + return ErrConflict + } + + if existingKeyIndex >= 0 { + // Update existing API key + // Remove old API key value from apiKeys map if it's different + if oldAPIKeyValue != apiKey.APIKey { + delete(aks.apiKeys, oldAPIKeyValue) + } + + // Update the existing entry in apiKeysByAPI + aks.apiKeysByAPI[handleKey][existingKeyIndex] = apiKey + + // Store by new API key value + aks.apiKeys[apiKey.APIKey] = apiKey + } else { + // Insert new API key + // Check if API key value already exists + if _, exists := aks.apiKeys[apiKey.APIKey]; exists { + return ErrConflict + } + + // Store by API key value + aks.apiKeys[apiKey.APIKey] = apiKey + + // Store by API handleKey + aks.apiKeysByAPI[handleKey] = append(aks.apiKeysByAPI[handleKey], apiKey) + } + + return nil +} + +// ValidateAPIKey validates the provided API key against the internal APIkey store +func (aks *APIkeyStore) ValidateAPIKey(apiName, apiVersion, apiOperation, operationMethod, apiKey string) (bool, error) { + aks.mu.Lock() + defer aks.mu.Unlock() + + handleKey := compositeKey(apiName, apiVersion) + + // Get API keys for the handleKey + apiKeys, exists := aks.apiKeysByAPI[handleKey] + if !exists { + return false, ErrNotFound + } + + // Find the API key with the matching key value + var targetAPIKey *APIKey + + for _, ak := range apiKeys { + if ak.APIKey == apiKey { + targetAPIKey = ak + break + } + } + + if targetAPIKey == nil { + return false, ErrNotFound + } + + // 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 API key value +func (aks *APIkeyStore) RevokeAPIKey(apiName, apiVersion, apiKeyValue string) error { + aks.mu.Lock() + defer aks.mu.Unlock() + + handleKey := compositeKey(apiName, apiVersion) + + // Get API keys for the handleKey + apiKeys, exists := aks.apiKeysByAPI[handleKey] + if !exists { + // If the API doesn't exist in our store, we treat revocation as successful + // since the key is effectively "not active" anyway + return nil + } + + // Find the API key with the matching key value + var targetAPIKey *APIKey + var targetIndex = -1 + + for i, apiKey := range apiKeys { + if apiKey.APIKey == apiKeyValue { + targetAPIKey = apiKey + targetIndex = i + break + } + } + + // If the API key doesn't exist, treat revocation as successful (idempotent operation) + if targetAPIKey == nil { + return nil + } + + // Set status to revoked + targetAPIKey.Status = Revoked + + // Remove from main apiKeys map + delete(aks.apiKeys, apiKeyValue) + + // Remove from apiKeysByAPI slice + aks.apiKeysByAPI[handleKey] = append(apiKeys[:targetIndex], apiKeys[targetIndex+1:]...) + + // Clean up empty slices + if len(aks.apiKeysByAPI[handleKey]) == 0 { + delete(aks.apiKeysByAPI, handleKey) + } + + return nil +} + +// RemoveAPIKeysByAPI removes all API keys for a specific API +func (aks *APIkeyStore) RemoveAPIKeysByAPI(name, version string) error { + aks.mu.Lock() + defer aks.mu.Unlock() + + handleKey := compositeKey(name, version) + + apiKeys, exists := aks.apiKeysByAPI[handleKey] + if !exists { + return nil // No keys to remove + } + + // Remove from main map + for _, key := range apiKeys { + delete(aks.apiKeys, key.APIKey) + } + + // Remove from API-specific map + delete(aks.apiKeysByAPI, handleKey) + + return nil +} + +// compositeKey creates a composite key from name and version +func compositeKey(name, version string) string { + return fmt.Sprintf("%s:%s", name, version) +}