From 02a2e7a5dbb176ea2bfbc455424acb1810f56e66 Mon Sep 17 00:00:00 2001 From: Nimsara Fernando Date: Thu, 22 Jan 2026 07:25:43 +0530 Subject: [PATCH 1/6] Implement secret management for gateway controller --- gateway/gateway-controller/Dockerfile | 3 + .../gateway-controller/Dockerfile.coverage | 3 + .../aesgcm-keys/default-aesgcm256-v1.bin | 1 + gateway/gateway-controller/api/openapi.yaml | 425 ++++++++++++- .../gateway-controller/cmd/controller/main.go | 103 +++- .../modify-headers-v0.1.0.yaml | 4 + .../pkg/api/generated/generated.go | 561 ++++++++++++------ .../pkg/api/handlers/handlers.go | 272 ++++++++- .../pkg/api/handlers/policy_ordering_test.go | 6 + .../gateway-controller/pkg/config/config.go | 31 + .../pkg/config/mcp_validator.go | 2 +- .../gateway-controller/pkg/config/parser.go | 2 +- .../pkg/config/secret_validator.go | 154 +++++ .../pkg/encryption/aesgcm/keymgmt.go | 149 +++++ .../pkg/encryption/aesgcm/provider.go | 187 ++++++ .../pkg/encryption/errors.go | 77 +++ .../pkg/encryption/manager.go | 214 +++++++ .../gateway-controller/pkg/models/secrets.go | 33 ++ .../pkg/resolver/policy_resolver.go | 382 ++++++++++++ .../gateway-controller/pkg/secrets/service.go | 365 ++++++++++++ .../pkg/storage/gateway-controller-db.sql | 14 + .../pkg/storage/interface.go | 32 + .../gateway-controller/pkg/storage/sqlite.go | 198 +++++++ gateway/it/features/secrets.feature | 536 +++++++++++++++++ gateway/it/steps_secrets.go | 77 +++ gateway/it/suite_test.go | 3 + 26 files changed, 3635 insertions(+), 199 deletions(-) create mode 100644 gateway/gateway-controller/aesgcm-keys/default-aesgcm256-v1.bin create mode 100644 gateway/gateway-controller/pkg/config/secret_validator.go create mode 100644 gateway/gateway-controller/pkg/encryption/aesgcm/keymgmt.go create mode 100644 gateway/gateway-controller/pkg/encryption/aesgcm/provider.go create mode 100644 gateway/gateway-controller/pkg/encryption/errors.go create mode 100644 gateway/gateway-controller/pkg/encryption/manager.go create mode 100644 gateway/gateway-controller/pkg/models/secrets.go create mode 100644 gateway/gateway-controller/pkg/resolver/policy_resolver.go create mode 100644 gateway/gateway-controller/pkg/secrets/service.go create mode 100644 gateway/it/features/secrets.feature create mode 100644 gateway/it/steps_secrets.go diff --git a/gateway/gateway-controller/Dockerfile b/gateway/gateway-controller/Dockerfile index 510bea692..f31f84698 100644 --- a/gateway/gateway-controller/Dockerfile +++ b/gateway/gateway-controller/Dockerfile @@ -77,6 +77,9 @@ COPY default-policies /app/default-policies # Copy default llm provider templates COPY default-llm-provider-templates /app/default-llm-provider-templates +# Copy default aesgcm keys +COPY aesgcm-keys /app/aesgcm-keys + # Create data directory for SQLite database RUN mkdir -p /app/data diff --git a/gateway/gateway-controller/Dockerfile.coverage b/gateway/gateway-controller/Dockerfile.coverage index 96907dda0..5e58686d0 100644 --- a/gateway/gateway-controller/Dockerfile.coverage +++ b/gateway/gateway-controller/Dockerfile.coverage @@ -76,6 +76,9 @@ COPY default-policies /app/default-policies # Copy default llm provider templates COPY default-llm-provider-templates /app/default-llm-provider-templates +# Copy default aesgcm keys +COPY aesgcm-keys /app/aesgcm-keys + # Create data directory for SQLite database RUN mkdir -p /app/data diff --git a/gateway/gateway-controller/aesgcm-keys/default-aesgcm256-v1.bin b/gateway/gateway-controller/aesgcm-keys/default-aesgcm256-v1.bin new file mode 100644 index 000000000..9db0a36df --- /dev/null +++ b/gateway/gateway-controller/aesgcm-keys/default-aesgcm256-v1.bin @@ -0,0 +1 @@ +5MZ֐pq//z{iX" \ No newline at end of file diff --git a/gateway/gateway-controller/api/openapi.yaml b/gateway/gateway-controller/api/openapi.yaml index 58bd679b3..7c70866b9 100644 --- a/gateway/gateway-controller/api/openapi.yaml +++ b/gateway/gateway-controller/api/openapi.yaml @@ -998,7 +998,7 @@ paths: - Token identifiers must use valid JSONPath expressions operationId: createLLMProviderTemplate tags: - - LLM Provider Management + - LLM Provider Template Management requestBody: required: true content: @@ -1040,7 +1040,7 @@ paths: Retrieve a list of all LLM provider templates. operationId: listLLMProviderTemplates tags: - - LLM Provider Management + - LLM Provider Template Management parameters: - name: displayName in: query @@ -1080,7 +1080,7 @@ paths: description: Retrieve the complete configuration for a specific LLM provider template operationId: getLLMProviderTemplateById tags: - - LLM Provider Management + - LLM Provider Template Management parameters: - name: id in: path @@ -1118,7 +1118,7 @@ paths: Update an existing LLM provider template configuration. operationId: updateLLMProviderTemplate tags: - - LLM Provider Management + - LLM Provider Template Management parameters: - name: id in: path @@ -1168,7 +1168,7 @@ paths: Remove an LLM provider template from the Gateway Controller. operationId: deleteLLMProviderTemplate tags: - - LLM Provider Management + - LLM Provider Template Management parameters: - name: id in: path @@ -1461,7 +1461,7 @@ paths: with an LLM proxy deployed in the gateway, including authentication, and policies. operationId: createLLMProxy tags: - - LLM Provider Management + - LLM Proxy Management requestBody: required: true description: LLM proxy configuration in YAML or JSON format @@ -1503,7 +1503,7 @@ paths: description: Retrieve a list of all LLM proxies operationId: listLLMProxies tags: - - LLM Provider Management + - LLM Proxy Management parameters: - name: displayName in: query @@ -1572,7 +1572,7 @@ paths: description: Retrieve the complete configuration for a specific LLM proxy operationId: getLLMProxyById tags: - - LLM Provider Management + - LLM Proxy Management parameters: - name: id in: path @@ -1609,7 +1609,7 @@ paths: description: Update the configuration of an existing LLM proxy operationId: updateLLMProxy tags: - - LLM Provider Management + - LLM Proxy Management parameters: - name: id in: path @@ -1659,7 +1659,7 @@ paths: description: Remove an LLM proxy from the Gateway Controller operationId: deleteLLMProxy tags: - - LLM Provider Management + - LLM Proxy Management parameters: - name: id in: path @@ -1698,6 +1698,247 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' + /secrets: + get: + tags: + - secrets + summary: List all secrets + description: | + Retrieve a list of all stored secrets. Returns secret metadata without + the actual secret values for security purposes. + operationId: listSecrets + responses: + '200': + description: List of secrets retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SecretListResponse' + '401': + description: Unauthorized - authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + post: + tags: + - secrets + summary: Create a new secret + description: | + Stores a new secret encrypted at rest. The secret ID must be unique. + The value is encrypted using the primary encryption provider before persistence. + operationId: createSecret + requestBody: + required: true + content: + application/yaml: + schema: + $ref: '#/components/schemas/SecretConfiguration' + application/json: + schema: + $ref: '#/components/schemas/SecretConfiguration' + responses: + '201': + description: Secret created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SecretResponse' + examples: + created: + summary: Successful creation + value: + id: "database-password" + value: "sup3rs3cr3t!" + created_at: "2026-01-05T10:30:00Z" + updated_at: "2026-01-05T10:30:00Z" + '400': + description: Bad request - missing or invalid fields + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Conflict - secret with this ID already exists + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error - encryption failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /secrets/{id}: + parameters: + - name: id + in: path + required: true + description: Unique secret identifier + schema: + type: string + examples: + simple: + summary: Simple ID + value: "database-password" + complex: + summary: Namespaced ID + value: "prod.db.main.password" + + get: + tags: + - secrets + summary: Retrieve a secret + description: | + Retrieves and decrypts a secret. The secret value is decrypted using the + encryption provider chain before being returned. If all providers fail to + decrypt the secret, a 500 error is returned with a generic message. + operationId: getSecret + responses: + '200': + description: Secret retrieved and decrypted successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SecretResponse' + examples: + success: + summary: Successful retrieval + value: + id: "database-password" + value: "sup3rs3cr3t!" + created_at: "2026-01-05T10:30:00Z" + updated_at: "2026-01-05T10:30:00Z" + '401': + description: Unauthorized - authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Secret not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error - decryption failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + put: + tags: + - secrets + summary: Update a secret + description: | + Updates an existing secret with a new value. The new value is encrypted + using the current primary encryption provider, enabling automatic migration + to newer keys during updates. Old secrets remain readable via the provider chain. + operationId: updateSecret + requestBody: + required: true + content: + application/yaml: + schema: + $ref: '#/components/schemas/SecretConfiguration' + application/json: + schema: + $ref: '#/components/schemas/SecretConfiguration' + responses: + '200': + description: Secret updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SecretResponse' + examples: + updated: + summary: Successful update + value: + id: "database-password" + value: "n3w_p@ssw0rd!" + created_at: "2026-01-05T10:30:00Z" + updated_at: "2026-01-05T11:45:00Z" + '400': + description: Bad request - missing or invalid value + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Secret not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error - encryption failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + delete: + tags: + - secrets + summary: Delete a secret + description: | + Permanently deletes a secret from the database. This is a hard delete with + no recovery mechanism. The operation is idempotent - deleting a non-existent + secret returns 404. + operationId: deleteSecret + responses: + '200': + description: Secret deleted successfully (no content) + '401': + description: Unauthorized - authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Secret not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + not-found: + summary: Secret does not exist + value: + error: "not_found" + message: "Secret not found" + correlation_id: "req-bcd-890" + '500': + description: Internal server error - database failure + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /config_dump: get: summary: Dump current configuration state @@ -3343,6 +3584,70 @@ components: items: $ref: "#/components/schemas/LLMPolicy" + SecretConfiguration: + type: object + required: + - apiVersion + - kind + - metadata + - spec + properties: + apiVersion: + type: string + description: Secret specification version + example: gateway.api-platform.wso2.com/v1alpha1 + enum: + - gateway.api-platform.wso2.com/v1alpha1 + + kind: + type: string + description: Secret resource kind + example: Secret + enum: + - Secret + + metadata: + $ref: "#/components/schemas/Metadata" + + spec: + $ref: "#/components/schemas/SecretConfigData" + + SecretConfigData: + type: object + required: + - displayName + - type + - value + properties: + displayName: + type: string + description: Human-readable secret name (must be URL-friendly - only letters, numbers, spaces, hyphens, underscores, and dots allowed) + minLength: 1 + maxLength: 253 + pattern: '^[a-zA-Z0-9\-_\. ]+$' + example: WSO2 OpenAI Key + + description: + type: string + description: Description of the secret + maxLength: 1024 + example: WSO2 OpenAI provider API Key + + type: + type: string + description: Secret category + enum: + - default + example: default + + value: + type: string + description: Secret value (stored securely and never returned in API responses) + minLength: 1 + maxLength: 8192 + format: password + example: sk_xxx + CertificateUploadRequest: type: object required: @@ -3436,6 +3741,98 @@ components: type: string example: success + SecretCreateRequest: + type: object + required: + - id + - value + properties: + id: + type: string + description: | + Unique identifier for the secret. Must be unique across all secrets. + Recommended format: alphanumeric with hyphens/underscores. + minLength: 1 + maxLength: 255 + example: "database-password" + value: + type: string + description: | + The secret value to encrypt and store. Will be encrypted at rest + using the primary encryption provider. Maximum size: 10KB. + minLength: 1 + maxLength: 10240 + example: "sup3rs3cr3t!" + example: + id: "api-key-production" + value: "sk-abcd1234efgh5678" + + SecretUpdateRequest: + type: object + required: + - value + properties: + value: + type: string + description: | + New secret value to encrypt and store. The secret will be re-encrypted + using the current primary encryption provider, enabling automatic key + rotation. Maximum size: 10KB. + minLength: 1 + maxLength: 10240 + example: "n3w_s3cr3t_v@lu3!" + example: + value: "updated-secret-value" + + SecretResponse: + type: object + required: + - id + - value + - created_at + - updated_at + properties: + id: + type: string + description: Unique secret identifier + example: "database-password" + value: + type: string + description: Decrypted secret value (plaintext) + example: "sup3rs3cr3t!" + created_at: + type: string + format: date-time + description: Timestamp when the secret was created (UTC) + example: "2026-01-05T10:30:00Z" + updated_at: + type: string + format: date-time + description: Timestamp when the secret was last updated (UTC) + example: "2026-01-05T11:45:00Z" + example: + id: "prod-db-password" + value: "my-secret-password" + created_at: "2026-01-05T10:00:00Z" + updated_at: "2026-01-05T10:00:00Z" + + SecretListResponse: + type: object + properties: + secrets: + type: array + description: List of secrets (without values for security) + items: + type: string + example: "secret_12345" + totalCount: + type: integer + description: Total number of secrets + example: 5 + status: + type: string + example: success + ConfigDumpAPIItem: type: object description: API item in config dump response @@ -3530,4 +3927,10 @@ tags: - name: Policy Management description: Registration of reusable policy definitions - name: Certificate Management - description: Manage custom TLS certificates for HTTPS upstream verification \ No newline at end of file + description: Manage custom TLS certificates for HTTPS upstream verification + - name: LLM Provider Template Management + description: CRUD operations for LLM Provider Template configurations + - name: LLM Provider Management + description: CRUD operations for LLM Provider configurations + - name: LLM Proxy Management + description: CRUD operations for LLM Proxy configurations diff --git a/gateway/gateway-controller/cmd/controller/main.go b/gateway/gateway-controller/cmd/controller/main.go index 18acdc16b..b0685cfda 100644 --- a/gateway/gateway-controller/cmd/controller/main.go +++ b/gateway/gateway-controller/cmd/controller/main.go @@ -13,7 +13,11 @@ import ( "time" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/apikeyxds" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/encryption" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/encryption/aesgcm" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/lazyresourcexds" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/resolver" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/secrets" "github.com/gin-gonic/gin" "github.com/wso2/api-platform/common/authenticators" @@ -122,6 +126,10 @@ func main() { lazyResourceSnapshotManager := lazyresourcexds.NewLazyResourceSnapshotManager(lazyResourceStore, log) lazyResourceXDSManager := lazyresourcexds.NewLazyResourceStateManager(lazyResourceStore, lazyResourceSnapshotManager, log) + // Initialize encryption providers for secret management + var encryptionProviderManager *encryption.ProviderManager + var secretsService *secrets.SecretService + // Load configurations from database on startup (if persistent mode) if cfg.IsPersistentMode() && db != nil { log.Info("Loading configurations from database") @@ -142,6 +150,43 @@ func main() { os.Exit(1) } log.Info("Loaded API keys", slog.Int("count", apiKeyXDSManager.GetAPIKeyCount())) + + if len(cfg.GatewayController.Encryption.Providers) > 0 { + log.Info("Initializing encryption providers", slog.Int("provider_count", len(cfg.GatewayController.Encryption.Providers))) + + // Initialize encryption providers + var providers []encryption.EncryptionProvider + for _, providerConfig := range cfg.GatewayController.Encryption.Providers { + switch providerConfig.Type { + case "aesgcm": + // Convert config keys to AES-GCM key configs + var keyConfigs []aesgcm.KeyConfig + for _, keyConf := range providerConfig.Keys { + keyConfigs = append(keyConfigs, aesgcm.KeyConfig{ + Version: keyConf.Version, + FilePath: keyConf.FilePath, + }) + } + + provider, err := aesgcm.NewAESGCMProvider(keyConfigs, log) + if err != nil { + log.Error("Failed to initialize AES-GCM provider", slog.Any("error", err)) + } + providers = append(providers, provider) + + default: + log.Error("Unsupported encryption provider type", slog.String("type", providerConfig.Type)) + } + } + + // Create provider manager + encryptionProviderManager, err = encryption.NewProviderManager(providers, db, log) + if err != nil { + log.Error("Failed to initialize provider manager", slog.Any("error", err)) + } + // Create secrets service + secretsService = secrets.NewSecretsService(db, encryptionProviderManager, log) + } } // Initialize xDS snapshot manager with router config @@ -198,6 +243,20 @@ func main() { cancel() } + // Load policy definitions from files (must be done before creating validator) + policyLoader := utils.NewPolicyLoader(log) + policyDir := cfg.GatewayController.Policies.DefinitionsPath + log.Info("Loading policy definitions from directory", slog.String("directory", policyDir)) + policyDefinitions, err := policyLoader.LoadPoliciesFromDirectory(policyDir) + if err != nil { + log.Error("Failed to load policy definitions", slog.Any("error", err)) + os.Exit(1) + } + log.Info("Policy definitions loaded", slog.Int("count", len(policyDefinitions))) + + // Initialize policy resolver + policyResolver := resolver.NewPolicyResolver(policyDefinitions, secretsService) + // Initialize policy store and start policy xDS server if enabled var policyXDSServer *policyxds.Server var policyManager *policyxds.PolicyManager @@ -220,7 +279,8 @@ func main() { for _, apiConfig := range loadedAPIs { // Derive policy configuration from API if apiConfig.Configuration.Kind == api.RestApi { - storedPolicy := derivePolicyFromAPIConfig(apiConfig, cfg) + storedPolicy := derivePolicyFromAPIConfig( + apiConfig, cfg, policyResolver, log) if storedPolicy != nil { if err := policyStore.Set(storedPolicy); err != nil { log.Warn("Failed to load policy from API", @@ -264,17 +324,6 @@ func main() { log.Info("Policy xDS server is disabled") } - // Load policy definitions from files (must be done before creating validator) - policyLoader := utils.NewPolicyLoader(log) - policyDir := cfg.GatewayController.Policies.DefinitionsPath - log.Info("Loading policy definitions from directory", slog.String("directory", policyDir)) - policyDefinitions, err := policyLoader.LoadPoliciesFromDirectory(policyDir) - if err != nil { - log.Error("Failed to load policy definitions", slog.Any("error", err)) - os.Exit(1) - } - log.Info("Policy definitions loaded", slog.Int("count", len(policyDefinitions))) - // Load llm provider templates from files templateLoader := utils.NewLLMTemplateLoader(log) templateDir := cfg.GatewayController.LLM.TemplateDefinitionsPath @@ -326,7 +375,7 @@ func main() { // Initialize API server with the configured validator and API key manager apiServer := handlers.NewAPIServer(configStore, db, snapshotManager, policyManager, lazyResourceXDSManager, log, cpClient, - policyDefinitions, templateDefinitions, validator, apiKeyXDSManager, cfg) + policyDefinitions, templateDefinitions, validator, secretsService, policyResolver, apiKeyXDSManager, cfg) // Ensure initial lazy resource snapshot includes default templates loaded from files. // At this point, the API server initialization has already persisted/published OOB templates. @@ -468,6 +517,12 @@ func generateAuthConfig(config *config.Config) commonmodels.AuthConfig { "POST /apis/:id/api-keys/:apiKeyName/regenerate": {"admin", "consumer"}, "DELETE /apis/:id/api-keys/:apiKeyName": {"admin", "consumer"}, + "POST /secrets": {"admin"}, + "GET /secrets": {"admin"}, + "GET /secrets/:id": {"admin"}, + "PUT /secrets/:id": {"admin"}, + "DELETE /secrets/:id": {"admin"}, + "GET /config_dump": {"admin"}, } basicAuth := commonmodels.BasicAuth{Enabled: false} @@ -501,9 +556,27 @@ func generateAuthConfig(config *config.Config) commonmodels.AuthConfig { // derivePolicyFromAPIConfig derives a policy configuration from an API configuration // This is a simplified version of the buildStoredPolicyFromAPI function from handlers -func derivePolicyFromAPIConfig(cfg *models.StoredConfig, fullConfig *config.Config) *models.StoredPolicyConfig { - apiCfg := &cfg.Configuration +func derivePolicyFromAPIConfig(cfg *models.StoredConfig, fullConfig *config.Config, policyResolver *resolver.PolicyResolver, + log *slog.Logger) *models.StoredPolicyConfig { + var apiCfg *api.APIConfiguration routerConfig := &fullConfig.GatewayController.Router + resolvedCfg, validationErrors := policyResolver.ResolvePolicies(cfg) + if len(validationErrors) > 0 { + // Aggregate errors into a single error message + errMsgs := make([]string, 0, len(validationErrors)) + for _, ve := range validationErrors { + errMsgs = append(errMsgs, ve.Message) + } + errMsg := strings.Join(errMsgs, "; ") + + log.Error("Policy resolution failed", + slog.String("config_id", cfg.ID), + slog.String("errors", errMsg), + ) + apiCfg = &cfg.Configuration // Fallback to original config + } else { + apiCfg = &resolvedCfg.Configuration + } apiData, err := apiCfg.Spec.AsAPIConfigData() if err != nil { return nil diff --git a/gateway/gateway-controller/default-policies/modify-headers-v0.1.0.yaml b/gateway/gateway-controller/default-policies/modify-headers-v0.1.0.yaml index 39eae1e16..696b293ab 100644 --- a/gateway/gateway-controller/default-policies/modify-headers-v0.1.0.yaml +++ b/gateway/gateway-controller/default-policies/modify-headers-v0.1.0.yaml @@ -35,6 +35,8 @@ parameters: required: - action - name + resolve: + - value responseHeaders: type: array description: Array of header modifications to apply during response phase. At @@ -62,6 +64,8 @@ parameters: required: - action - name + resolve: + - value systemParameters: type: object diff --git a/gateway/gateway-controller/pkg/api/generated/generated.go b/gateway/gateway-controller/pkg/api/generated/generated.go index 7455bf320..066b2352e 100644 --- a/gateway/gateway-controller/pkg/api/generated/generated.go +++ b/gateway/gateway-controller/pkg/api/generated/generated.go @@ -208,7 +208,7 @@ const ( // Defines values for MCPProxyConfigurationApiVersion. const ( - GatewayApiPlatformWso2Comv1alpha1 MCPProxyConfigurationApiVersion = "gateway.api-platform.wso2.com/v1alpha1" + MCPProxyConfigurationApiVersionGatewayApiPlatformWso2Comv1alpha1 MCPProxyConfigurationApiVersion = "gateway.api-platform.wso2.com/v1alpha1" ) // Defines values for MCPProxyConfigurationKind. @@ -243,6 +243,21 @@ const ( PUT RouteExceptionMethods = "PUT" ) +// Defines values for SecretConfigDataType. +const ( + Default SecretConfigDataType = "default" +) + +// Defines values for SecretConfigurationApiVersion. +const ( + SecretConfigurationApiVersionGatewayApiPlatformWso2Comv1alpha1 SecretConfigurationApiVersion = "gateway.api-platform.wso2.com/v1alpha1" +) + +// Defines values for SecretConfigurationKind. +const ( + Secret SecretConfigurationKind = "Secret" +) + // Defines values for UpstreamHostRewrite. const ( Auto UpstreamHostRewrite = "auto" @@ -1203,6 +1218,66 @@ type RouteException struct { // RouteExceptionMethods defines model for RouteException.Methods. type RouteExceptionMethods string +// SecretConfigData defines model for SecretConfigData. +type SecretConfigData struct { + // Description Description of the secret + Description *string `json:"description,omitempty" yaml:"description,omitempty"` + + // DisplayName Human-readable secret name (must be URL-friendly - only letters, numbers, spaces, hyphens, underscores, and dots allowed) + DisplayName string `json:"displayName" yaml:"displayName"` + + // Type Secret category + Type SecretConfigDataType `json:"type" yaml:"type"` + + // Value Secret value (stored securely and never returned in API responses) + Value string `json:"value" yaml:"value"` +} + +// SecretConfigDataType Secret category +type SecretConfigDataType string + +// SecretConfiguration defines model for SecretConfiguration. +type SecretConfiguration struct { + // ApiVersion Secret specification version + ApiVersion SecretConfigurationApiVersion `json:"apiVersion" yaml:"apiVersion"` + + // Kind Secret resource kind + Kind SecretConfigurationKind `json:"kind" yaml:"kind"` + Metadata Metadata `json:"metadata" yaml:"metadata"` + Spec SecretConfigData `json:"spec" yaml:"spec"` +} + +// SecretConfigurationApiVersion Secret specification version +type SecretConfigurationApiVersion string + +// SecretConfigurationKind Secret resource kind +type SecretConfigurationKind string + +// SecretListResponse defines model for SecretListResponse. +type SecretListResponse struct { + // Secrets List of secrets (without values for security) + Secrets *[]string `json:"secrets,omitempty" yaml:"secrets,omitempty"` + Status *string `json:"status,omitempty" yaml:"status,omitempty"` + + // TotalCount Total number of secrets + TotalCount *int `json:"totalCount,omitempty" yaml:"totalCount,omitempty"` +} + +// SecretResponse defines model for SecretResponse. +type SecretResponse struct { + // CreatedAt Timestamp when the secret was created (UTC) + CreatedAt time.Time `json:"created_at" yaml:"created_at"` + + // Id Unique secret identifier + Id string `json:"id" yaml:"id"` + + // UpdatedAt Timestamp when the secret was last updated (UTC) + UpdatedAt time.Time `json:"updated_at" yaml:"updated_at"` + + // Value Decrypted secret value (plaintext) + Value string `json:"value" yaml:"value"` +} + // Upstream Upstream backend configuration (single target or reference) type Upstream struct { // HostRewrite Controls how the Host header is handled when routing to the upstream. `auto` delegates host rewriting to Envoy, which rewrites the Host header using the upstream cluster host. `manual` disables automatic rewriting and expects explicit configuration. @@ -1396,6 +1471,12 @@ type CreateMCPProxyJSONRequestBody = MCPProxyConfiguration // UpdateMCPProxyJSONRequestBody defines body for UpdateMCPProxy for application/json ContentType. type UpdateMCPProxyJSONRequestBody = MCPProxyConfiguration +// CreateSecretJSONRequestBody defines body for CreateSecret for application/json ContentType. +type CreateSecretJSONRequestBody = SecretConfiguration + +// UpdateSecretJSONRequestBody defines body for UpdateSecret for application/json ContentType. +type UpdateSecretJSONRequestBody = SecretConfiguration + // AsAPIConfigData returns the union data inside the APIConfiguration_Spec as a APIConfigData func (t APIConfiguration_Spec) AsAPIConfigData() (APIConfigData, error) { var body APIConfigData @@ -1968,6 +2049,21 @@ type ServerInterface interface { // List all registered policy definitions // (GET /policies) ListPolicies(c *gin.Context) + // List all secrets + // (GET /secrets) + ListSecrets(c *gin.Context) + // Create a new secret + // (POST /secrets) + CreateSecret(c *gin.Context) + // Delete a secret + // (DELETE /secrets/{id}) + DeleteSecret(c *gin.Context, id string) + // Retrieve a secret + // (GET /secrets/{id}) + GetSecret(c *gin.Context, id string) + // Update a secret + // (PUT /secrets/{id}) + UpdateSecret(c *gin.Context, id string) } // ServerInterfaceWrapper converts contexts to parameters. @@ -2862,6 +2958,104 @@ func (siw *ServerInterfaceWrapper) ListPolicies(c *gin.Context) { siw.Handler.ListPolicies(c) } +// ListSecrets operation middleware +func (siw *ServerInterfaceWrapper) ListSecrets(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.ListSecrets(c) +} + +// CreateSecret operation middleware +func (siw *ServerInterfaceWrapper) CreateSecret(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.CreateSecret(c) +} + +// DeleteSecret operation middleware +func (siw *ServerInterfaceWrapper) DeleteSecret(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.DeleteSecret(c, id) +} + +// GetSecret operation middleware +func (siw *ServerInterfaceWrapper) GetSecret(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.GetSecret(c, id) +} + +// UpdateSecret operation middleware +func (siw *ServerInterfaceWrapper) UpdateSecret(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.UpdateSecret(c, id) +} + // GinServerOptions provides options for the Gin server. type GinServerOptions struct { BaseURL string @@ -2925,180 +3119,207 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.GET(options.BaseURL+"/mcp-proxies/:id", wrapper.GetMCPProxyById) router.PUT(options.BaseURL+"/mcp-proxies/:id", wrapper.UpdateMCPProxy) router.GET(options.BaseURL+"/policies", wrapper.ListPolicies) + router.GET(options.BaseURL+"/secrets", wrapper.ListSecrets) + router.POST(options.BaseURL+"/secrets", wrapper.CreateSecret) + router.DELETE(options.BaseURL+"/secrets/:id", wrapper.DeleteSecret) + router.GET(options.BaseURL+"/secrets/:id", wrapper.GetSecret) + router.PUT(options.BaseURL+"/secrets/:id", wrapper.UpdateSecret) } // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "AZTlAfUOSPuqqKVw8/XmvwMAAP//VAoIyOQsAQA=", + "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", + "k11OAQA=", } // 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..3dc6ea7cb 100644 --- a/gateway/gateway-controller/pkg/api/handlers/handlers.go +++ b/gateway/gateway-controller/pkg/api/handlers/handlers.go @@ -25,6 +25,8 @@ import ( "github.com/wso2/api-platform/common/constants" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/apikeyxds" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/resolver" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/secrets" "io" "net/http" @@ -67,6 +69,8 @@ type APIServer struct { deploymentService *utils.APIDeploymentService mcpDeploymentService *utils.MCPDeploymentService llmDeploymentService *utils.LLMDeploymentService + secretService *secrets.SecretService + policyResolver *resolver.PolicyResolver apiKeyService *utils.APIKeyService apiKeyXDSManager *apikeyxds.APIKeyStateManager controlPlaneClient controlplane.ControlPlaneClient @@ -87,6 +91,8 @@ func NewAPIServer( policyDefinitions map[string]api.PolicyDefinition, templateDefinitions map[string]*api.LLMProviderTemplate, validator config.Validator, + secretService *secrets.SecretService, + policyResolver *resolver.PolicyResolver, apiKeyXDSManager *apikeyxds.APIKeyStateManager, systemConfig *config.Config, ) *APIServer { @@ -99,6 +105,8 @@ func NewAPIServer( policyDefinitions: policyDefinitions, parser: config.NewParser(), validator: validator, + secretService: secretService, + policyResolver: policyResolver, logger: logger, deploymentService: deploymentService, mcpDeploymentService: utils.NewMCPDeploymentService(store, db, snapshotManager), @@ -1653,7 +1661,24 @@ func (s *APIServer) ListPolicies(c *gin.Context) { // used by the xDS translator for consistency. func (s *APIServer) buildStoredPolicyFromAPI(cfg *models.StoredConfig) *models.StoredPolicyConfig { // TODO: (renuka) duplicate buildStoredPolicyFromAPI funcs. Refactor this. - apiCfg := &cfg.Configuration + var apiCfg *api.APIConfiguration + resolvedCfg, validationErrors := s.policyResolver.ResolvePolicies(cfg) + if len(validationErrors) > 0 { + // Aggregate errors into a single error message + errMsgs := make([]string, 0, len(validationErrors)) + for _, ve := range validationErrors { + errMsgs = append(errMsgs, ve.Message) + } + errMsg := strings.Join(errMsgs, "; ") + + s.logger.Error("Policy resolution failed", + slog.String("config_id", cfg.ID), + slog.String("errors", errMsg), + ) + apiCfg = &cfg.Configuration // Fallback to original config + } else { + apiCfg = &resolvedCfg.Configuration + } // Collect API-level policies apiPolicies := make(map[string]policyenginev1.PolicyInstance) // name -> policy @@ -2230,6 +2255,251 @@ func (s *APIServer) waitForDeploymentAndNotify(configID string, correlationID st } } +// CreateSecret handles POST /secrets +func (s *APIServer) CreateSecret(c *gin.Context) { + log := middleware.GetLogger(c, s.logger) + + // Read request body + body, err := io.ReadAll(c.Request.Body) + if err != nil { + log.Error("Failed to read request body", slog.Any("error", err)) + c.JSON(http.StatusBadRequest, api.ErrorResponse{ + Status: "error", + Message: "Failed to read request body", + }) + return + } + + // Get correlation ID from context + correlationID := middleware.GetCorrelationID(c) + + // Delegate to service which parses/validates/encrypt and persists + secret, err := s.secretService.CreateSecret(secrets.SecretParams{ + Data: body, + ContentType: c.GetHeader("Content-Type"), + Logger: log, + }) + if err != nil { + log.Error("Failed to encrypt Secret", slog.Any("error", err)) + if strings.Contains(err.Error(), "already exists") { + c.JSON(http.StatusConflict, api.ErrorResponse{Status: "error", Message: err.Error()}) + } else { + c.JSON(http.StatusBadRequest, api.ErrorResponse{Status: "error", Message: err.Error()}) + } + return + } + + log.Info("Secret created successfully", + slog.String("secret_handle", secret.Handle), + slog.String("correlation_id", correlationID)) + + // Return created secret + c.JSON(http.StatusCreated, gin.H{ + "id": secret.Handle, + "value": secret.Value, + "created_at": secret.CreatedAt, + "updated_at": secret.UpdatedAt, + }) +} + +// ListSecrets implements ServerInterface.ListSecrets +// (GET /secrets) +func (s *APIServer) ListSecrets(c *gin.Context) { + log := s.logger + correlationID := middleware.GetCorrelationID(c) + + log.Debug("Retrieving secretsList", slog.String("correlation_id", correlationID)) + + ids, err := s.secretService.GetSecrets(correlationID) + if err != nil { + log.Error("Failed to retrieve secretsList", + slog.String("correlation_id", correlationID), + slog.Any("error", err), + ) + c.JSON(http.StatusInternalServerError, api.ErrorResponse{ + Status: "error", + Message: "Failed to retrieve secretsList", + }) + return + } + + secretsList := make([]string, 0, len(ids)) + for _, id := range ids { + secretsList = append(secretsList, id) + } + + c.JSON(http.StatusOK, api.SecretListResponse{ + Status: stringPtr("success"), + TotalCount: intPtr(len(secretsList)), + Secrets: &secretsList, + }) +} + +// GetSecret handles GET /secrets/{id} +func (s *APIServer) GetSecret(c *gin.Context, id string) { + log := s.logger + correlationID := middleware.GetCorrelationID(c) + + log.Debug("Retrieving secret", + slog.String("secret_handle", id), + slog.String("correlation_id", correlationID)) + + // Validate secret ID format + if id == "" { + c.JSON(http.StatusBadRequest, api.ErrorResponse{ + Status: "error", + Message: "Missing required field: id", + }) + return + } + if len(id) > 255 { + c.JSON(http.StatusBadRequest, api.ErrorResponse{ + Status: "error", + Message: "Secret ID too long (max 255 characters)", + }) + return + } + + // Retrieve secret + secret, err := s.secretService.Get(id, correlationID) + if err != nil { + // Check for not found error + if storage.IsNotFoundError(err) { + c.JSON(http.StatusNotFound, api.ErrorResponse{ + Status: "error", + Message: err.Error(), + }) + return + } + + // Generic error for decryption failures (security-first) + log.Error("Failed to retrieve secret", + slog.String("secret_handle", id), + slog.String("correlation_id", correlationID), + slog.Any("error", err)) + c.JSON(http.StatusInternalServerError, api.ErrorResponse{ + Status: "error", + Message: "Failed to decrypt secret", + }) + return + } + + log.Debug("Secret retrieved successfully", + slog.String("secret_handle", secret.Handle), + slog.String("correlation_id", correlationID)) + + // Return secret + c.JSON(http.StatusOK, gin.H{ + "id": secret.Handle, + "value": secret.Value, + "created_at": secret.CreatedAt, + "updated_at": secret.UpdatedAt, + }) +} + +// UpdateSecret handles PUT /secrets/{id} +func (s *APIServer) UpdateSecret(c *gin.Context, id string) { + log := middleware.GetLogger(c, s.logger) + + // Read request body + body, err := io.ReadAll(c.Request.Body) + if err != nil { + log.Error("Failed to read request body", slog.Any("error", err)) + c.JSON(http.StatusBadRequest, api.ErrorResponse{ + Status: "error", + Message: "Failed to read request body", + }) + return + } + + // Get correlation ID from context + correlationID := middleware.GetCorrelationID(c) + + // Delegate to service which parses/validates/encrypt and persists + secret, err := s.secretService.UpdateSecret(secrets.SecretParams{ + Data: body, + ContentType: c.GetHeader("Content-Type"), + Logger: log, + }) + if err != nil { + log.Error("Failed to encrypt Secret", slog.Any("error", err)) + if strings.Contains(err.Error(), "does not exist") { + c.JSON(http.StatusNotFound, api.ErrorResponse{Status: "error", Message: err.Error()}) + } else { + c.JSON(http.StatusBadRequest, api.ErrorResponse{Status: "error", Message: err.Error()}) + } + return + } + + log.Info("Secret updated successfully", + slog.String("secret_handle", secret.Handle), + slog.String("correlation_id", correlationID)) + + // Return created secret + c.JSON(http.StatusOK, gin.H{ + "id": secret.Handle, + "value": secret.Value, + "created_at": secret.CreatedAt, + "updated_at": secret.UpdatedAt, + }) +} + +// DeleteSecret handles DELETE /secrets/{id} +func (s *APIServer) DeleteSecret(c *gin.Context, id string) { + log := s.logger + correlationID := middleware.GetCorrelationID(c) + + log.Debug("Deleting secret", + slog.String("secret_id", id), + slog.String("correlation_id", correlationID)) + + // Validate secret ID format + if id == "" { + c.JSON(http.StatusBadRequest, api.ErrorResponse{ + Status: "error", + Message: "Missing required field: id", + }) + return + } + if len(id) > 255 { + c.JSON(http.StatusBadRequest, api.ErrorResponse{ + Status: "error", + Message: "Secret ID too long (max 255 characters)", + }) + return + } + + // Delete secret + if err := s.secretService.Delete(id, correlationID); err != nil { + // Check for not found error + if storage.IsNotFoundError(err) { + c.JSON(http.StatusNotFound, api.ErrorResponse{ + Status: "error", + Message: err.Error(), + }) + return + } + + // Generic error for storage failures + log.Error("Failed to delete secret", + slog.String("secret_id", id), + slog.String("correlation_id", correlationID), + slog.Any("error", err)) + c.JSON(http.StatusInternalServerError, api.ErrorResponse{ + Status: "error", + Message: "Failed to delete secret", + }) + return + } + + log.Info("Secret deleted successfully", + slog.String("secret_id", id), + slog.String("correlation_id", correlationID)) + + // Return 200 OK on successful deletion + c.Status(http.StatusOK) +} + // GetConfigDump implements the GET /config_dump endpoint func (s *APIServer) GetConfigDump(c *gin.Context) { log := middleware.GetLogger(c, s.logger) diff --git a/gateway/gateway-controller/pkg/api/handlers/policy_ordering_test.go b/gateway/gateway-controller/pkg/api/handlers/policy_ordering_test.go index 1009978a6..eae80c5a3 100644 --- a/gateway/gateway-controller/pkg/api/handlers/policy_ordering_test.go +++ b/gateway/gateway-controller/pkg/api/handlers/policy_ordering_test.go @@ -26,6 +26,8 @@ import ( api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/generated" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/resolver" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/secrets" ) // newTestAPIServer creates a minimal APIServer instance for testing @@ -34,11 +36,15 @@ func newTestAPIServer() *APIServer { Main: config.VHostEntry{Default: "localhost"}, Sandbox: config.VHostEntry{Default: "sandbox-*"}, } + // Initialize policy resolver + policyResolver := resolver.NewPolicyResolver(make(map[string]api.PolicyDefinition), + &secrets.SecretService{}) return &APIServer{ routerConfig: &config.RouterConfig{ GatewayHost: "localhost", VHosts: *vhosts, }, + policyResolver: policyResolver, } } diff --git a/gateway/gateway-controller/pkg/config/config.go b/gateway/gateway-controller/pkg/config/config.go index 4c9ab3bf8..48bb8fe92 100644 --- a/gateway/gateway-controller/pkg/config/config.go +++ b/gateway/gateway-controller/pkg/config/config.go @@ -62,6 +62,7 @@ type GatewayController struct { Auth AuthConfig `koanf:"auth"` APIKey APIKeyConfig `koanf:"api_key"` Metrics MetricsConfig `koanf:"metrics"` + Encryption EncryptionConfig `koanf:"encryption"` } // MetricsConfig holds Prometheus metrics server configuration @@ -325,6 +326,23 @@ type APIKeyConfig struct { Algorithm string `koanf:"algorithm"` // Hashing algorithm to use } +// EncryptionConfig holds encryption provider configuration +type EncryptionConfig struct { + Providers []ProviderConfig `koanf:"providers"` +} + +// ProviderConfig defines configuration for a single encryption provider +type ProviderConfig struct { + Type string `koanf:"type"` // "aesgcm" + Keys []EncryptionKeyConfig `koanf:"keys"` +} + +// EncryptionKeyConfig defines a single encryption key +type EncryptionKeyConfig struct { + Version string `koanf:"version"` // Key identifier (e.g., "key-v1") + FilePath string `koanf:"file"` // Path to raw binary key file +} + // LoadConfig loads configuration from file, environment variables, and defaults // Priority: Environment variables > Config file > Defaults func LoadConfig(configPath string) (*Config, error) { @@ -512,6 +530,19 @@ func defaultConfig() *Config { RoleMapping: map[string][]string{}, }, }, + Encryption: EncryptionConfig{ + Providers: []ProviderConfig{ + { + Type: "aesgcm", + Keys: []EncryptionKeyConfig{ + { + Version: "aesgcm256-v1", + FilePath: "./aesgcm-keys/default-aesgcm256-v1.bin", + }, + }, + }, + }, + }, Logging: LoggingConfig{ Level: "info", Format: "json", diff --git a/gateway/gateway-controller/pkg/config/mcp_validator.go b/gateway/gateway-controller/pkg/config/mcp_validator.go index c6690082d..f3fec34ce 100644 --- a/gateway/gateway-controller/pkg/config/mcp_validator.go +++ b/gateway/gateway-controller/pkg/config/mcp_validator.go @@ -71,7 +71,7 @@ func (v *MCPValidator) validateMCPConfiguration(config *api.MCPProxyConfiguratio var errors []ValidationError // Validate version - if config.ApiVersion != api.GatewayApiPlatformWso2Comv1alpha1 { + if config.ApiVersion != api.MCPProxyConfigurationApiVersionGatewayApiPlatformWso2Comv1alpha1 { errors = append(errors, ValidationError{ Field: "version", Message: "Unsupported configuration version (must be 'gateway.api-platform.wso2.com/v1alpha1')", diff --git a/gateway/gateway-controller/pkg/config/parser.go b/gateway/gateway-controller/pkg/config/parser.go index f33dc44b8..e9d5c1ae8 100644 --- a/gateway/gateway-controller/pkg/config/parser.go +++ b/gateway/gateway-controller/pkg/config/parser.go @@ -58,7 +58,7 @@ func (p *Parser) ParseAPIConfigYAML(data []byte, configParsed interface{}) error return nil default: if err := yaml.Unmarshal(data, target); err != nil { - return fmt.Errorf("failed to unmarshal YAML into MCPProxyConfiguration: %w", err) + return fmt.Errorf("failed to unmarshal YAML into %T: %w", configParsed, err) } return nil } diff --git a/gateway/gateway-controller/pkg/config/secret_validator.go b/gateway/gateway-controller/pkg/config/secret_validator.go new file mode 100644 index 000000000..9df3c55f3 --- /dev/null +++ b/gateway/gateway-controller/pkg/config/secret_validator.go @@ -0,0 +1,154 @@ +/* + * 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 config + +import ( + "fmt" + "regexp" + "slices" + "strings" + + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/generated" +) + +// SecretValidator validates Secret configurations using rule-based validation +type SecretValidator struct { + // urlFriendlyNameRegex matches URL-safe characters for secret display names + urlFriendlyNameRegex *regexp.Regexp + + // supportedKinds defines supported Secret kinds + supportedKinds []string + + // supportedTypes defines supported secret types + supportedTypes []string +} + +// NewSecretValidator creates a new Secret configuration validator +func NewSecretValidator() *SecretValidator { + return &SecretValidator{ + urlFriendlyNameRegex: regexp.MustCompile(`^[a-zA-Z0-9\-_. ]+$`), + supportedKinds: []string{"Secret"}, + supportedTypes: []string{"default"}, + } +} + +// Validate performs comprehensive validation on a configuration +func (v *SecretValidator) Validate(config any) []ValidationError { + switch cfg := config.(type) { + case *api.SecretConfiguration: + return v.validateSecretConfiguration(cfg) + case api.SecretConfiguration: + return v.validateSecretConfiguration(&cfg) + default: + return []ValidationError{ + { + Field: "config", + Message: "Unsupported configuration type for SecretValidator (expected SecretConfiguration)", + }, + } + } +} + +// validateSecretConfiguration validates the Secret configuration root object +func (v *SecretValidator) validateSecretConfiguration(config *api.SecretConfiguration) []ValidationError { + var errors []ValidationError + + // Validate apiVersion + if config.ApiVersion != api.SecretConfigurationApiVersionGatewayApiPlatformWso2Comv1alpha1 { + errors = append(errors, ValidationError{ + Field: "version", + Message: "Unsupported configuration version (must be 'gateway.api-platform.wso2.com/v1alpha1')", + }) + } + + // Validate kind + if config.Kind != "Secret" { + errors = append(errors, ValidationError{ + Field: "kind", + Message: "Unsupported configuration kind (only 'Secret' is supported)", + }) + } + + // Validate metadata + errors = append(errors, ValidateMetadata(&config.Metadata)...) + + // Validate spec section + errors = append(errors, v.validateSpec(&config.Spec)...) + + return errors +} + +// validateSpec validates the spec section of the Secret configuration +func (v *SecretValidator) validateSpec(spec *api.SecretConfigData) []ValidationError { + var errors []ValidationError + + // Validate displayName + if spec.DisplayName == "" { + errors = append(errors, ValidationError{ + Field: "spec.displayName", + Message: "Secret displayName is required", + }) + } else if len(spec.DisplayName) > 253 { + errors = append(errors, ValidationError{ + Field: "spec.displayName", + Message: "Secret displayName must be 1-253 characters", + }) + } else if !v.urlFriendlyNameRegex.MatchString(spec.DisplayName) { + errors = append(errors, ValidationError{ + Field: "spec.displayName", + Message: "Secret displayName must be URL-friendly (only letters, numbers, spaces, hyphens, underscores, and dots allowed)", + }) + } + + // Validate description + if spec.Description != nil && len(*spec.Description) > 1024 { + errors = append(errors, ValidationError{ + Field: "spec.description", + Message: "Secret description must be at most 1024 characters", + }) + } + + // Validate type + if spec.Type == "" { + errors = append(errors, ValidationError{ + Field: "spec.type", + Message: "Secret type is required", + }) + } else if !slices.Contains(v.supportedTypes, string(spec.Type)) { + errors = append(errors, ValidationError{ + Field: "spec.type", + Message: fmt.Sprintf("Unsupported secret type (supported types: %s)", strings.Join(v.supportedTypes, ", ")), + }) + } + + // Validate value + if spec.Value == "" { + errors = append(errors, ValidationError{ + Field: "spec.value", + Message: "Secret value is required", + }) + } else if len(spec.Value) > 8192 { + errors = append(errors, ValidationError{ + Field: "spec.value", + Message: "Secret value must be at most 8192 characters", + }) + } + + return errors +} diff --git a/gateway/gateway-controller/pkg/encryption/aesgcm/keymgmt.go b/gateway/gateway-controller/pkg/encryption/aesgcm/keymgmt.go new file mode 100644 index 000000000..db678bc5f --- /dev/null +++ b/gateway/gateway-controller/pkg/encryption/aesgcm/keymgmt.go @@ -0,0 +1,149 @@ +/* + * 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 aesgcm + +import ( + "fmt" + "log/slog" + "os" + + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/encryption" +) + +const ( + // AESKeySize is the required key size for AES-256 (32 bytes) + AESKeySize = 32 +) + +// Key represents a single encryption key with its version +type Key struct { + Version string + Data []byte +} + +// KeyManager manages loading and accessing encryption keys +type KeyManager struct { + keys map[string]*Key // version -> key mapping + primaryKey *Key // primary key for encryption (first key in config) + primaryVersion string + logger *slog.Logger +} + +// NewKeyManager creates a new key manager and loads keys from files +func NewKeyManager(keyConfigs []KeyConfig, logger *slog.Logger) (*KeyManager, error) { + if len(keyConfigs) == 0 { + return nil, fmt.Errorf("at least one encryption key is required") + } + + km := &KeyManager{ + keys: make(map[string]*Key), + logger: logger, + } + + // Load all keys + for i, config := range keyConfigs { + key, err := km.loadKey(config) + if err != nil { + return nil, fmt.Errorf("failed to load key %s: %w", config.Version, err) + } + + km.keys[config.Version] = key + + // First key is the primary key for encryption + if i == 0 { + km.primaryKey = key + km.primaryVersion = config.Version + } + + logger.Debug("Loaded encryption key", + slog.String("version", config.Version), + slog.Bool("is_primary", i == 0), + ) + } + + logger.Info("Key manager initialized", + slog.Int("total_keys", len(km.keys)), + slog.String("primary_version", km.primaryVersion), + ) + + return km, nil +} + +// loadKey reads a key from a file and validates its size +func (km *KeyManager) loadKey(config KeyConfig) (*Key, error) { + // Check file permissions for security + info, err := os.Stat(config.FilePath) + if err != nil { + return nil, &encryption.ErrKeyNotFound{KeyPath: config.FilePath} + } + + // Warn if key file is world-readable (security risk) + perm := info.Mode().Perm() + if perm&0004 != 0 { + km.logger.Warn("Encryption key file is world-readable - consider restricting permissions", + slog.String("key_version", config.Version), + slog.String("file_path", config.FilePath), + slog.String("permissions", perm.String()), + ) + } + + // Read key data + data, err := os.ReadFile(config.FilePath) + if err != nil { + return nil, fmt.Errorf("failed to read key file: %w", err) + } + + // Validate key size (must be exactly 32 bytes for AES-256) + if len(data) != AESKeySize { + return nil, &encryption.ErrInvalidKeySize{ + Expected: AESKeySize, + Actual: len(data), + } + } + + return &Key{ + Version: config.Version, + Data: data, + }, nil +} + +// GetPrimaryKey returns the primary encryption key +func (km *KeyManager) GetPrimaryKey() *Key { + return km.primaryKey +} + +// GetKey returns a specific key by version +func (km *KeyManager) GetKey(version string) (*Key, error) { + key, exists := km.keys[version] + if !exists { + return nil, fmt.Errorf("key version not found: %s", version) + } + return key, nil +} + +// GetPrimaryVersion returns the primary key version +func (km *KeyManager) GetPrimaryVersion() string { + return km.primaryVersion +} + +// KeyConfig holds configuration for a single key +type KeyConfig struct { + Version string + FilePath string +} diff --git a/gateway/gateway-controller/pkg/encryption/aesgcm/provider.go b/gateway/gateway-controller/pkg/encryption/aesgcm/provider.go new file mode 100644 index 000000000..89f7944e0 --- /dev/null +++ b/gateway/gateway-controller/pkg/encryption/aesgcm/provider.go @@ -0,0 +1,187 @@ +/* + * 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 aesgcm + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "fmt" + "log/slog" + + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/encryption" +) + +const ( + // NonceSize is the size of the nonce for AES-GCM (12 bytes is standard) + NonceSize = 12 +) + +// AESGCMProvider implements encryption using AES-GCM +type AESGCMProvider struct { + name string + keyManager *KeyManager + logger *slog.Logger +} + +// NewAESGCMProvider creates a new AES-GCM encryption provider +func NewAESGCMProvider(keyConfigs []KeyConfig, logger *slog.Logger) (*AESGCMProvider, error) { + keyManager, err := NewKeyManager(keyConfigs, logger) + if err != nil { + return nil, fmt.Errorf("failed to initialize key manager: %w", err) + } + + provider := &AESGCMProvider{ + name: "aesgcm", + keyManager: keyManager, + logger: logger, + } + + logger.Info("AES-GCM provider initialized", + slog.String("provider", provider.name), + slog.String("primary_key_version", keyManager.GetPrimaryVersion()), + ) + + return provider, nil +} + +// Name returns the provider identifier +func (p *AESGCMProvider) Name() string { + return p.name +} + +// Encrypt encrypts plaintext using AES-GCM with a random nonce +func (p *AESGCMProvider) Encrypt(plaintext []byte) (*encryption.EncryptedPayload, error) { + // Get primary key for encryption + key := p.keyManager.GetPrimaryKey() + + // Create AES cipher + block, err := aes.NewCipher(key.Data) + if err != nil { + return nil, fmt.Errorf("failed to create cipher: %w", err) + } + + // Create GCM mode + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("failed to create GCM: %w", err) + } + + // Generate random nonce (12 bytes) + nonce := make([]byte, NonceSize) + if _, err := rand.Read(nonce); err != nil { + return nil, fmt.Errorf("failed to generate nonce: %w", err) + } + + // Encrypt and authenticate + // GCM appends the auth tag to the ciphertext automatically + ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) + + p.logger.Debug("Encrypted data with AES-GCM", + slog.String("key_version", key.Version), + slog.Int("plaintext_size", len(plaintext)), + slog.Int("ciphertext_size", len(ciphertext)), + ) + + return &encryption.EncryptedPayload{ + Provider: p.name, + KeyVersion: key.Version, + Ciphertext: ciphertext, // nonce || encrypted data || auth tag + }, nil +} + +// Decrypt decrypts ciphertext using AES-GCM +func (p *AESGCMProvider) Decrypt(payload *encryption.EncryptedPayload) ([]byte, error) { + // Get the key used for encryption + key, err := p.keyManager.GetKey(payload.KeyVersion) + if err != nil { + return nil, fmt.Errorf("key not found for version %s: %w", payload.KeyVersion, err) + } + + // Validate ciphertext length (must be at least nonce size + tag size) + if len(payload.Ciphertext) < NonceSize { + return nil, fmt.Errorf("ciphertext too short: %d bytes", len(payload.Ciphertext)) + } + + // Create AES cipher + block, err := aes.NewCipher(key.Data) + if err != nil { + return nil, fmt.Errorf("failed to create cipher: %w", err) + } + + // Create GCM mode + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("failed to create GCM: %w", err) + } + + // Extract nonce from the beginning of ciphertext + nonce := payload.Ciphertext[:NonceSize] + ciphertext := payload.Ciphertext[NonceSize:] + + // Decrypt and verify authentication tag + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, fmt.Errorf("decryption failed (authentication error): %w", err) + } + + p.logger.Debug("Decrypted data with AES-GCM", + slog.String("key_version", key.Version), + slog.Int("ciphertext_size", len(payload.Ciphertext)), + slog.Int("plaintext_size", len(plaintext)), + ) + + return plaintext, nil +} + +// HealthCheck validates that the provider is properly initialized +func (p *AESGCMProvider) HealthCheck() error { + // Verify we have a primary key + primaryKey := p.keyManager.GetPrimaryKey() + if primaryKey == nil { + return fmt.Errorf("no primary key available") + } + + // Verify key size + if len(primaryKey.Data) != AESKeySize { + return &encryption.ErrInvalidKeySize{ + Expected: AESKeySize, + Actual: len(primaryKey.Data), + } + } + + // Test encryption/decryption round-trip + testData := []byte("health-check-test-data") + encrypted, err := p.Encrypt(testData) + if err != nil { + return fmt.Errorf("health check encryption failed: %w", err) + } + + decrypted, err := p.Decrypt(encrypted) + if err != nil { + return fmt.Errorf("health check decryption failed: %w", err) + } + + if string(decrypted) != string(testData) { + return fmt.Errorf("health check round-trip failed: data mismatch") + } + + p.logger.Debug("AES-GCM provider health check passed") + return nil +} diff --git a/gateway/gateway-controller/pkg/encryption/errors.go b/gateway/gateway-controller/pkg/encryption/errors.go new file mode 100644 index 000000000..a1d0c9d79 --- /dev/null +++ b/gateway/gateway-controller/pkg/encryption/errors.go @@ -0,0 +1,77 @@ +/* + * 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 encryption + +import "fmt" + +// ErrProviderNotFound indicates no provider can decrypt the payload +type ErrProviderNotFound struct { + ProviderName string +} + +func (e *ErrProviderNotFound) Error() string { + return fmt.Sprintf("no encryption provider found for: %s", e.ProviderName) +} + +// ErrEncryptionFailed indicates encryption operation failed +type ErrEncryptionFailed struct { + ProviderName string + Cause error +} + +func (e *ErrEncryptionFailed) Error() string { + return fmt.Sprintf("encryption failed for provider %s: %v", e.ProviderName, e.Cause) +} + +func (e *ErrEncryptionFailed) Unwrap() error { + return e.Cause +} + +// ErrDecryptionFailed indicates decryption operation failed +type ErrDecryptionFailed struct { + ProviderName string + Cause error +} + +func (e *ErrDecryptionFailed) Error() string { + return fmt.Sprintf("decryption failed for provider %s: %v", e.ProviderName, e.Cause) +} + +func (e *ErrDecryptionFailed) Unwrap() error { + return e.Cause +} + +// ErrInvalidKeySize indicates encryption key has wrong size +type ErrInvalidKeySize struct { + Expected int + Actual int +} + +func (e *ErrInvalidKeySize) Error() string { + return fmt.Sprintf("invalid key size: expected %d bytes, got %d bytes", e.Expected, e.Actual) +} + +// ErrKeyNotFound indicates encryption key file not found +type ErrKeyNotFound struct { + KeyPath string +} + +func (e *ErrKeyNotFound) Error() string { + return fmt.Sprintf("encryption key not found: %s", e.KeyPath) +} diff --git a/gateway/gateway-controller/pkg/encryption/manager.go b/gateway/gateway-controller/pkg/encryption/manager.go new file mode 100644 index 000000000..9b9508b35 --- /dev/null +++ b/gateway/gateway-controller/pkg/encryption/manager.go @@ -0,0 +1,214 @@ +/* + * 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 encryption + +import ( + "encoding/base64" + "fmt" + "log/slog" + "strings" + + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" +) + +// EncryptionProvider defines the interface for encryption implementations +type EncryptionProvider interface { + // Name returns the provider identifier (e.g., "aesgcm", "vault") + Name() string + + // Encrypt transforms plaintext into encrypted payload using the active key + Encrypt(plaintext []byte) (*EncryptedPayload, error) + + // Decrypt transforms encrypted payload back to plaintext + Decrypt(payload *EncryptedPayload) ([]byte, error) + + // HealthCheck validates provider initialization and key availability + HealthCheck() error +} + +// EncryptedPayload represents encrypted data with metadata +type EncryptedPayload struct { + Provider string // Provider type identifier (e.g., "aesgcm") + KeyVersion string // Key name/version (e.g., "key-v2") + Ciphertext []byte // Encrypted bytes (nonce || ciphertext || tag for AES-GCM) +} + +// ProviderManager orchestrates the encryption provider chain +type ProviderManager struct { + providers []EncryptionProvider + storage storage.Storage + logger *slog.Logger +} + +// NewProviderManager creates a new provider manager with the given providers +func NewProviderManager(providers []EncryptionProvider, storage storage.Storage, logger *slog.Logger) (*ProviderManager, error) { + if len(providers) == 0 { + return nil, fmt.Errorf("at least one encryption provider is required") + } + + // Validate all providers + for _, provider := range providers { + if err := provider.HealthCheck(); err != nil { + return nil, fmt.Errorf("provider %s failed health check: %w", provider.Name(), err) + } + } + + logger.Info("Initialized encryption provider chain", + slog.Int("provider_count", len(providers)), + slog.String("primary_provider", providers[0].Name()), + ) + + return &ProviderManager{ + providers: providers, + storage: storage, + logger: logger, + }, nil +} + +// Encrypt encrypts plaintext using the primary provider (first in chain) +func (m *ProviderManager) Encrypt(plaintext []byte) (*EncryptedPayload, error) { + primaryProvider := m.providers[0] + + m.logger.Debug("Encrypting with primary provider", + slog.String("provider", primaryProvider.Name()), + slog.Int("plaintext_size", len(plaintext)), + ) + + payload, err := primaryProvider.Encrypt(plaintext) + if err != nil { + m.logger.Error("Encryption failed", + slog.String("provider", primaryProvider.Name()), + slog.Any("error", err), + ) + return nil, &ErrEncryptionFailed{ + ProviderName: primaryProvider.Name(), + Cause: err, + } + } + + m.logger.Debug("Encryption successful", + slog.String("provider", payload.Provider), + slog.String("key_version", payload.KeyVersion), + ) + + return payload, nil +} + +// Decrypt decrypts the payload using the provider chain +// It tries to match the provider by name from the payload metadata +func (m *ProviderManager) Decrypt(payload *EncryptedPayload) ([]byte, error) { + m.logger.Debug("Decrypting payload", + slog.String("provider", payload.Provider), + slog.String("key_version", payload.KeyVersion), + ) + + // Find the provider that can decrypt this payload + // If the provider name matches, use it to decrypt + // If no match, return error indicating no provider found + // If decryption fails, return error indicating curruption or invalid data + for _, provider := range m.providers { + if provider.Name() == payload.Provider { + plaintext, err := provider.Decrypt(payload) + if err != nil { + m.logger.Error("Decryption failed", + slog.String("provider", provider.Name()), + slog.String("key_version", payload.KeyVersion), + slog.Any("error", err), + ) + return nil, &ErrDecryptionFailed{ + ProviderName: provider.Name(), + Cause: err, + } + } + + m.logger.Debug("Decryption successful", + slog.String("provider", provider.Name()), + slog.Int("plaintext_size", len(plaintext)), + ) + + return plaintext, nil + } + } + + // No provider found that can decrypt this payload + m.logger.Error("No provider found for decryption", + slog.String("requested_provider", payload.Provider), + slog.String("key_version", payload.KeyVersion), + ) + + return nil, &ErrProviderNotFound{ + ProviderName: payload.Provider, + } +} + +// HealthCheck validates all providers in the chain +func (m *ProviderManager) HealthCheck() error { + for _, provider := range m.providers { + if err := provider.HealthCheck(); err != nil { + return fmt.Errorf("provider %s health check failed: %w", provider.Name(), err) + } + } + return nil +} + +// GetPrimaryProvider returns the primary encryption provider (first in chain) +func (m *ProviderManager) GetPrimaryProvider() EncryptionProvider { + return m.providers[0] +} + +// GetProviders returns all configured providers +func (m *ProviderManager) GetProviders() []EncryptionProvider { + return m.providers +} + +// MarshalPayload converts EncryptedPayload to storage format +// Format: enc:provider:v1:key-version:base64-ciphertext +func MarshalPayload(payload *EncryptedPayload) string { + encoded := base64.StdEncoding.EncodeToString(payload.Ciphertext) + return fmt.Sprintf("enc:%s:v1:%s:%s", payload.Provider, payload.KeyVersion, encoded) +} + +// UnmarshalPayload converts storage format back to EncryptedPayload +// Expects format: enc:provider:v1:key-version:base64-ciphertext +func UnmarshalPayload(stored string) (*EncryptedPayload, error) { + parts := strings.SplitN(stored, ":", 5) + if len(parts) != 5 { + return nil, fmt.Errorf("invalid payload format: expected 5 parts, got %d", len(parts)) + } + + if parts[0] != "enc" { + return nil, fmt.Errorf("invalid payload prefix: expected 'enc', got '%s'", parts[0]) + } + + if parts[2] != "v1" { + return nil, fmt.Errorf("unsupported payload version: %s", parts[2]) + } + + ciphertext, err := base64.StdEncoding.DecodeString(parts[4]) + if err != nil { + return nil, fmt.Errorf("failed to decode ciphertext: %w", err) + } + + return &EncryptedPayload{ + Provider: parts[1], + KeyVersion: parts[3], + Ciphertext: ciphertext, + }, nil + +} diff --git a/gateway/gateway-controller/pkg/models/secrets.go b/gateway/gateway-controller/pkg/models/secrets.go new file mode 100644 index 000000000..f6d24130b --- /dev/null +++ b/gateway/gateway-controller/pkg/models/secrets.go @@ -0,0 +1,33 @@ +/* + * 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 models + +import "time" + +// Secret represents a secret in the storage layer +type Secret struct { + ID string // UUID + Handle string // User-provided unique identifier + Value string // Plaintext secret data (in-memory only, never persisted) + Provider string // Encryption provider identifier + KeyVersion string // Key version used for encryption + Ciphertext []byte // Encrypted secret with metadata (stored in database) + CreatedAt time.Time // Creation timestamp (UTC) + UpdatedAt time.Time // Last modification timestamp (UTC) +} diff --git a/gateway/gateway-controller/pkg/resolver/policy_resolver.go b/gateway/gateway-controller/pkg/resolver/policy_resolver.go new file mode 100644 index 000000000..99b48e0e4 --- /dev/null +++ b/gateway/gateway-controller/pkg/resolver/policy_resolver.go @@ -0,0 +1,382 @@ +/* + * 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 resolver + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/generated" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/secrets" +) + +// PolicyResolver resolves resolve policy params +type PolicyResolver struct { + policyDefinitions map[string]api.PolicyDefinition + secretsService *secrets.SecretService + resolveRules map[string][]ResolveRule // policyKey -> rules +} + +type ResolveRule struct { + Path string +} + +// NewPolicyResolver creates a new policy resolver +func NewPolicyResolver(policyDefinitions map[string]api.PolicyDefinition, + secretsService *secrets.SecretService) *PolicyResolver { + resolver := &PolicyResolver{ + policyDefinitions: policyDefinitions, + secretsService: secretsService, + resolveRules: make(map[string][]ResolveRule), + } + + resolver.buildResolveRules() + return resolver +} + +// buildResolveRules extracts param paths needs to be resolved +func (pr *PolicyResolver) buildResolveRules() { + for key, policyDef := range pr.policyDefinitions { + if policyDef.Parameters == nil { + continue + } + + schema := *policyDef.Parameters + var rules []ResolveRule + + pr.walkSchema(schema, "params", &rules) + + if len(rules) > 0 { + pr.resolveRules[key] = rules + } + } +} + +// walkSchema iterate through the policy definition and populates resolve rules +func (pr *PolicyResolver) walkSchema(schema map[string]interface{}, path string, rules *[]ResolveRule) { + // Check resolve at this level + if raw, ok := schema["resolve"]; ok { + if fields, ok := raw.([]interface{}); ok { + for _, f := range fields { + if fieldName, ok := f.(string); ok { + *rules = append(*rules, ResolveRule{ + Path: path + "." + fieldName, + }) + } + } + } + } + + // Walk properties + if props, ok := schema["properties"].(map[string]interface{}); ok { + for name, propSchema := range props { + if propMap, ok := propSchema.(map[string]interface{}); ok { + pr.walkSchema(propMap, path+"."+name, rules) + } + } + } + + // Walk array items + if items, ok := schema["items"].(map[string]interface{}); ok { + pr.walkSchema(items, path+".*", rules) + } +} + +// GetResolveRules returns resolve rules per policy +func (pr *PolicyResolver) GetResolveRules(policy api.Policy) []ResolveRule { + key := policy.Name + "|" + policy.Version + return pr.resolveRules[key] +} + +// ResolvePolicies resolve all resolve policy params in an API configuration +// Returns a new StoredConfig with resolved secrets without modifying the original +// If no policies need resolution, returns the original config +func (pr *PolicyResolver) ResolvePolicies(apiConfig *models.StoredConfig) ( + *models.StoredConfig, []config.ValidationError) { + var errors []config.ValidationError + + apiData, err := apiConfig.Configuration.Spec.AsAPIConfigData() + if err != nil { + errors = append(errors, config.ValidationError{ + Field: "spec", + Message: fmt.Sprintf("Failed to parse API data for policy validation: %v", err), + }) + return nil, errors + } + + // First pass: check if any policies need resolution + needsResolution := false + + // Check global policies + if apiData.Policies != nil { + for i := range *apiData.Policies { + policy := (*apiData.Policies)[i] + rules := pr.GetResolveRules(policy) + if len(rules) > 0 { + needsResolution = true + break + } + } + } + + // Check operation-level policies if we haven't found any yet + if !needsResolution && apiData.Operations != nil { + for i := range apiData.Operations { + operation := apiData.Operations[i] + if operation.Policies != nil { + for j := range *operation.Policies { + policy := (*operation.Policies)[j] + rules := pr.GetResolveRules(policy) + if len(rules) > 0 { + needsResolution = true + break + } + } + } + if needsResolution { + break + } + } + } + + // If no policies need resolution, return original config + if !needsResolution { + return apiConfig, nil + } + + // Deep copy only when needed + resolvedConfig, err := pr.deepCopyStoredConfig(apiConfig) + if err != nil { + errors = append(errors, config.ValidationError{ + Field: "config", + Message: fmt.Sprintf("Failed to create copy of config: %v", err), + }) + return nil, errors + } + + // Re-parse apiData from the copied config + apiData, err = resolvedConfig.Configuration.Spec.AsAPIConfigData() + if err != nil { + errors = append(errors, config.ValidationError{ + Field: "spec", + Message: fmt.Sprintf("Failed to parse copied API data: %v", err), + }) + return nil, errors + } + + // Process global policies + if apiData.Policies != nil { + for i := range *apiData.Policies { + policy := &(*apiData.Policies)[i] + if err := pr.resolvePolicyValues(policy); err != nil { + errors = append(errors, config.ValidationError{ + Field: fmt.Sprintf("spec.policies[%d]", i), + Message: fmt.Sprintf("Failed to resolve policy %s: %v", policy.Name, err), + }) + } + } + } + + // Process operation-level policies + if apiData.Operations != nil { + for i := range apiData.Operations { + operation := &apiData.Operations[i] + if operation.Policies != nil { + for j := range *operation.Policies { + policy := &(*operation.Policies)[j] + fieldPath := fmt.Sprintf("spec.operations[%d].policies[%d]", i, j) + if err := pr.resolvePolicyValues(policy); err != nil { + errors = append(errors, config.ValidationError{ + Field: fieldPath, + Message: fmt.Sprintf("Failed to resolve policy %s for %s %s: %v", + policy.Name, operation.Method, operation.Path, err), + }) + } + } + } + } + } + + err = resolvedConfig.Configuration.Spec.FromAPIConfigData(apiData) + if err != nil { + errors = append(errors, config.ValidationError{}) + } + + if len(errors) > 0 { + return nil, errors + } + + return resolvedConfig, nil +} + +// resolvePolicyValues checks if policy needs resolution and decrypts values +func (pr *PolicyResolver) resolvePolicyValues(policy *api.Policy) error { + // Get resolve rules for this policy + rules := pr.GetResolveRules(*policy) + + if len(rules) == 0 { + // No resolution needed for this policy + return nil + } + + if policy.Params == nil { + return fmt.Errorf("policy %s has resolve rules but no params", policy.Name) + } + + // Iterate through each resolve rule + for _, rule := range rules { + // Resolve (decrypt) values at this path + if err := pr.resolveValuesByPath(*policy.Params, rule.Path); err != nil { + return fmt.Errorf("failed to resolve values for path %s: %w", rule.Path, err) + } + } + + return nil +} + +// resolveValuesByPath finds and decrypts values at the given path +// Handles templated secrets like "Bearer $secret{wso2-openai-apikey}" and "$secret{auth-type} $secret{key}" +func (pr *PolicyResolver) resolveValuesByPath(params map[string]interface{}, path string) error { + // Split path into segments (e.g., ["params", "requestHeaders", "*", "value"]) + segments := strings.Split(path, ".") + + // Start from params, skip the first "params" segment + if len(segments) == 0 || segments[0] != "params" { + return fmt.Errorf("path must start with 'params'") + } + + // Navigate to the parent of the target field + targetField := segments[len(segments)-1] + parentPath := segments[1 : len(segments)-1] + + // Get all parent objects that contain the target field + parents, err := pr.navigateToParents(params, parentPath) + if err != nil { + return err + } + + // Decrypt the target field in each parent + for _, parent := range parents { + if obj, ok := parent.(map[string]interface{}); ok { + if templateValue, exists := obj[targetField]; exists { + // Check if value is a string (templates are strings) + if strValue, ok := templateValue.(string); ok { + // Resolve all $secret{} templates in the string + resolvedValue, err := pr.resolveSecretTemplates(strValue) + if err != nil { + return fmt.Errorf("failed to resolve secret templates: %w", err) + } + // Update the value in place + obj[targetField] = resolvedValue + } + } + } + } + + return nil +} + +// resolveSecretTemplates finds and replaces all $secret{key} templates with decrypted values +// Example: "Bearer $secret{wso2-openai-apikey}" -> "Bearer sk_xxx" +// Example: "$secret{auth-type} $secret{wso2-openai-apikey}" -> "Bearer sk_xxx" +func (pr *PolicyResolver) resolveSecretTemplates(templateStr string) (string, error) { + // Pattern to match $secret{key} + secretPattern := `\$secret\{([^}]+)\}` + re := regexp.MustCompile(secretPattern) + + var resolveErr error + resolved := re.ReplaceAllStringFunc(templateStr, func(match string) string { + // Extract the secret key from $secret{key} + matches := re.FindStringSubmatch(match) + if len(matches) < 2 { + resolveErr = fmt.Errorf("invalid secret template format: %s", match) + return match + } + + secretKey := matches[1] + + // Decrypt the secret key + decryptedSecret, err := pr.secretsService.Get(secretKey, "") + if err != nil { + resolveErr = fmt.Errorf("failed to decrypt secret '%s': %w", secretKey, err) + return match + } + + return decryptedSecret.Value + }) + + if resolveErr != nil { + return "", resolveErr + } + + return resolved, nil +} + +// navigateToParents navigates to all parent objects that contain the target field +func (pr *PolicyResolver) navigateToParents(params map[string]interface{}, path []string) ([]interface{}, error) { + current := []interface{}{params} + + for _, segment := range path { + var next []interface{} + + for _, item := range current { + if segment == "*" { + // Handle array wildcard + if arr, ok := item.([]interface{}); ok { + next = append(next, arr...) + } else { + return nil, fmt.Errorf("expected array at wildcard, got %T", item) + } + } else { + // Handle object property + if obj, ok := item.(map[string]interface{}); ok { + if val, exists := obj[segment]; exists { + next = append(next, val) + } + } else { + return nil, fmt.Errorf("expected object at segment %s, got %T", segment, item) + } + } + } + + current = next + } + + return current, nil +} + +// deepCopyStoredConfig creates a deep copy of StoredConfig +func (pr *PolicyResolver) deepCopyStoredConfig(original *models.StoredConfig) (*models.StoredConfig, error) { + // Use JSON marshaling/unmarshaling for deep copy + data, err := json.Marshal(original) + if err != nil { + return nil, fmt.Errorf("failed to marshal config: %w", err) + } + + var copied models.StoredConfig + if err := json.Unmarshal(data, &copied); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + + return &copied, nil +} diff --git a/gateway/gateway-controller/pkg/secrets/service.go b/gateway/gateway-controller/pkg/secrets/service.go new file mode 100644 index 000000000..c4cb539ea --- /dev/null +++ b/gateway/gateway-controller/pkg/secrets/service.go @@ -0,0 +1,365 @@ +/* + * 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 secrets + +import ( + "fmt" + "log/slog" + "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/config" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/encryption" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" +) + +const ( + // MaxSecretSize is the maximum allowed size for a secret value (10KB) + MaxSecretSize = 10 * 1024 +) + +// SecretParams carries input to encrypt/save a secret +type SecretParams struct { + Data []byte // Raw configuration data (YAML/JSON) + ContentType string // Content type for parsing + ID string // Optional ID; if empty, generated + CorrelationID string // Correlation ID for tracking + Logger *slog.Logger // Logger +} + +// SecretService handles business logic for secret operations +type SecretService struct { + storage storage.Storage + providerManager *encryption.ProviderManager + parser *config.Parser + validator *config.SecretValidator + logger *slog.Logger +} + +// NewSecretsService creates a new secret service +func NewSecretsService( + storage storage.Storage, + providerManager *encryption.ProviderManager, + logger *slog.Logger, +) *SecretService { + return &SecretService{ + storage: storage, + providerManager: providerManager, + parser: config.NewParser(), + validator: config.NewSecretValidator(), + logger: logger, + } +} + +// CreateSecret creates a new secret with encryption +func (s *SecretService) CreateSecret(params SecretParams) (*models.Secret, error) { + var secretConfig api.SecretConfiguration + // Parse configuration + err := s.parser.Parse(params.Data, params.ContentType, &secretConfig) + handle := secretConfig.Metadata.Name + if err != nil { + return nil, fmt.Errorf("failed to parse configuration: %w", err) + } + + // Validate configuration + validationErrors := s.validator.Validate(&secretConfig) + if len(validationErrors) > 0 { + errors := make([]string, 0, len(validationErrors)) + params.Logger.Warn("Configuration validation failed", + slog.String("secret_id", handle), + slog.String("name", secretConfig.Spec.DisplayName), + slog.Int("num_errors", len(validationErrors))) + + for i, e := range validationErrors { + params.Logger.Warn("Validation error", + slog.String("field", e.Field), + slog.String("message", e.Message)) + errors = append(errors, fmt.Sprintf("%d. %s: %s", i+1, e.Field, e.Message)) + } + + combinedMsg := strings.Join(errors, "; ") + + return nil, fmt.Errorf("configuration validation failed with %d error(s): %s", + len(validationErrors), combinedMsg) + } + + // Encrypt the secret value + payload, err := s.providerManager.Encrypt([]byte(secretConfig.Spec.Value)) + if err != nil { + s.logger.Error("Failed to encrypt secret", + slog.String("secret_handle", handle), + slog.String("correlation_id", params.CorrelationID), + slog.Any("error", err), + ) + return nil, fmt.Errorf("encryption failed: %w", err) + } + + // Serialize the encrypted payload for storage + ciphertext := encryption.MarshalPayload(payload) + + // Create secret model + secret := &models.Secret{ + ID: generateUUID(), + Handle: handle, + Value: "", // Don't store plaintext + Provider: payload.Provider, + KeyVersion: payload.KeyVersion, + Ciphertext: []byte(ciphertext), + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + + // Persist encrypted secret + if err := s.storage.SaveSecret(secret); err != nil { + s.logger.Error("Failed to save secret", + slog.String("secret_handle", handle), + slog.String("correlation_id", params.CorrelationID), + slog.Any("error", err), + ) + return nil, fmt.Errorf("storage failed: %w", err) + } + + s.logger.Info("Secret created successfully", + slog.String("secret_handle", handle), + slog.String("correlation_id", params.CorrelationID), + slog.String("provider", payload.Provider), + slog.String("key_version", payload.KeyVersion), + ) + + // Return secret with plaintext value for response + secret.Value = secretConfig.Spec.Value + return secret, nil +} + +// GetSecrets retrieves all secret handles/IDs +func (s *SecretService) GetSecrets(correlationID string) ([]string, error) { + s.logger.Info("Retrieving all secrets", + slog.String("correlation_id", correlationID), + ) + + // Retrieve all secret handles from storage + secrets, err := s.storage.GetSecrets() + if err != nil { + s.logger.Error("Failed to retrieve secrets", + slog.String("correlation_id", correlationID), + slog.Any("error", err), + ) + return nil, fmt.Errorf("failed to retrieve secrets: %w", err) + } + + s.logger.Info("Secrets retrieved successfully", + slog.String("correlation_id", correlationID), + slog.Int("count", len(secrets)), + ) + + return secrets, nil +} + +// Get retrieves and decrypts a secret +func (s *SecretService) Get(handle string, correlationID string) (*models.Secret, error) { + s.logger.Info("Retrieving secret", + slog.String("secret_handle", handle), + slog.String("correlation_id", correlationID), + ) + + // Retrieve encrypted secret from storage + secret, err := s.storage.GetSecret(handle) + if err != nil { + // Don't log details for not found errors (common case) + if ok := storage.IsNotFoundError(err); ok { + s.logger.Debug("Secret not found", + slog.String("secret_handle", handle), + slog.String("correlation_id", correlationID), + ) + } else { + s.logger.Error("Failed to retrieve secret", + slog.String("secret_handle", handle), + slog.String("correlation_id", correlationID), + slog.Any("error", err), + ) + } + return nil, err + } + + // Deserialize the encrypted payload + payload, err := encryption.UnmarshalPayload(string(secret.Ciphertext)) + if err != nil { + s.logger.Error("Failed to unmarshal encrypted payload", + slog.String("secret_handle", handle), + slog.String("correlation_id", correlationID), + slog.Any("error", err), + ) + return nil, fmt.Errorf("payload deserialization failed: %w", err) + } + + // Decrypt the secret value + plaintext, err := s.providerManager.Decrypt(payload) + if err != nil { + s.logger.Error("Failed to decrypt secret", + slog.String("secret_handle", handle), + slog.String("provider", payload.Provider), + slog.String("key_version", payload.KeyVersion), + slog.String("correlation_id", correlationID), + slog.Any("error", err), + ) + return nil, fmt.Errorf("decryption failed: %w", err) + } + + s.logger.Info("Secret retrieved successfully", + slog.String("secret_handle", handle), + slog.String("correlation_id", correlationID), + slog.String("provider", payload.Provider), + slog.String("key_version", payload.KeyVersion), + ) + + // Set plaintext value in secret + secret.Value = string(plaintext) + return secret, nil +} + +// UpdateSecret updates an existing secret with re-encryption using current primary key +func (s *SecretService) UpdateSecret(params SecretParams) (*models.Secret, error) { + var secretConfig api.SecretConfiguration + // Parse configuration + err := s.parser.Parse(params.Data, params.ContentType, &secretConfig) + handle := secretConfig.Metadata.Name + if err != nil { + return nil, fmt.Errorf("failed to parse configuration: %w", err) + } + + // Validate configuration + validationErrors := s.validator.Validate(&secretConfig) + if len(validationErrors) > 0 { + errors := make([]string, 0, len(validationErrors)) + params.Logger.Warn("Configuration validation failed", + slog.String("secret_id", handle), + slog.String("name", secretConfig.Spec.DisplayName), + slog.Int("num_errors", len(validationErrors))) + + for i, e := range validationErrors { + params.Logger.Warn("Validation error", + slog.String("field", e.Field), + slog.String("message", e.Message)) + errors = append(errors, fmt.Sprintf("%d. %s: %s", i+1, e.Field, e.Message)) + } + + combinedMsg := strings.Join(errors, "; ") + + return nil, fmt.Errorf("configuration validation failed with %d error(s): %s", + len(validationErrors), combinedMsg) + } + + // Check if secret exists + exists, err := s.storage.SecretExists(handle) + if err != nil { + return nil, fmt.Errorf("failed to check secret existence: %w", err) + } + if !exists { + return nil, fmt.Errorf("secret with id '%s' does not exists", handle) + } + + // Encrypt with current primary key (automatic key migration) + payload, err := s.providerManager.Encrypt([]byte(secretConfig.Spec.Value)) + if err != nil { + s.logger.Error("Failed to encrypt secret", + slog.String("secret_handle", handle), + slog.String("correlation_id", params.CorrelationID), + slog.Any("error", err), + ) + return nil, fmt.Errorf("encryption failed: %w", err) + } + + // Serialize the encrypted payload + ciphertext := encryption.MarshalPayload(payload) + + // Update secret model + secret := &models.Secret{ + Handle: handle, + Provider: payload.Provider, + KeyVersion: payload.KeyVersion, + Ciphertext: []byte(ciphertext), + } + + // Persist updated secret + if err := s.storage.UpdateSecret(secret); err != nil { + s.logger.Error("Failed to update secret", + slog.String("secret_handle", handle), + slog.String("correlation_id", params.CorrelationID), + slog.Any("error", err), + ) + return nil, fmt.Errorf("storage update failed: %w", err) + } + + // Retrieve updated secret with timestamps + updatedSecret, err := s.storage.GetSecret(handle) + if err != nil { + return nil, fmt.Errorf("failed to retrieve updated secret: %w", err) + } + + s.logger.Info("Secret updated successfully", + slog.String("secret_handle", handle), + slog.String("correlation_id", params.CorrelationID), + slog.String("provider", payload.Provider), + slog.String("key_version", payload.KeyVersion), + ) + + // Return secret with plaintext value + updatedSecret.Value = secretConfig.Spec.Value + return updatedSecret, nil +} + +// Delete permanently removes a secret +func (s *SecretService) Delete(id string, correlationID string) error { + s.logger.Info("Deleting secret", + slog.String("secret_handle", id), + slog.String("correlation_id", correlationID), + ) + + if err := s.storage.DeleteSecret(id); err != nil { + // Don't log details for not found errors + if ok := storage.IsNotFoundError(err); ok { + s.logger.Debug("Secret not found for deletion", + slog.String("secret_handle", id), + slog.String("correlation_id", correlationID), + ) + } else { + s.logger.Error("Failed to delete secret", + slog.String("secret_handle", id), + slog.String("correlation_id", correlationID), + slog.Any("error", err), + ) + } + return err + } + + s.logger.Info("Secret deleted successfully", + slog.String("secret_handle", id), + slog.String("correlation_id", correlationID), + ) + + return nil +} + +// generateUUID generates a new UUID string +func generateUUID() string { + return uuid.New().String() +} diff --git a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql index 920aeee76..a34dbae69 100644 --- a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql +++ b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql @@ -146,6 +146,20 @@ CREATE TABLE IF NOT EXISTS api_keys ( UNIQUE (apiId, name) ); +-- Table for encrypted secrets +CREATE TABLE IF NOT EXISTS secrets ( + id TEXT PRIMARY KEY NOT NULL, + handle TEXT NOT NULL UNIQUE, -- secret identifier (e.g., wso2-openai-api-key) + provider TEXT NOT NULL, + key_version TEXT NOT NULL, + ciphertext BLOB NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Index for secrets updates +CREATE INDEX IF NOT EXISTS idx_secrets_updated_at ON secrets(updated_at); + -- 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(apiId); diff --git a/gateway/gateway-controller/pkg/storage/interface.go b/gateway/gateway-controller/pkg/storage/interface.go index 4d7423d5e..c7d2e633f 100644 --- a/gateway/gateway-controller/pkg/storage/interface.go +++ b/gateway/gateway-controller/pkg/storage/interface.go @@ -233,6 +233,38 @@ type Storage interface { // Returns an error if the certificate does not exist. DeleteCertificate(id string) error + // SaveSecret persists a new encrypted secret. + // + // Returns an error if a secret with the same ID already exists. + // Implementations should ensure this operation is atomic. + SaveSecret(secret *models.Secret) error + + // GetSecrets retrieves all secrets. + // + // Returns an empty slice if no secrets exist. + GetSecrets() ([]string, error) + + // GetSecret retrieves a secret by ID. + // + // Returns error if the secret does not exist. + GetSecret(handle string) (*models.Secret, error) + + // UpdateSecret updates an existing secret. + // + // Returns error if the secret does not exist. + // Implementations should ensure this operation is atomic. + UpdateSecret(secret *models.Secret) error + + // DeleteSecret permanently removes a secret. + // + // Returns error if the secret does not exist. + DeleteSecret(id string) error + + // SecretExists checks if a secret with the given ID exists. + // + // Returns true if the secret exists, false otherwise. + SecretExists(id string) (bool, error) + // Close closes the storage connection and releases resources. // // Should be called during graceful shutdown. diff --git a/gateway/gateway-controller/pkg/storage/sqlite.go b/gateway/gateway-controller/pkg/storage/sqlite.go index b8ba82b5d..6dd395a87 100644 --- a/gateway/gateway-controller/pkg/storage/sqlite.go +++ b/gateway/gateway-controller/pkg/storage/sqlite.go @@ -1709,3 +1709,201 @@ func (s *SQLiteStorage) CountActiveAPIKeysByUserAndAPI(apiId, userID string) (in return count, nil } +// SaveSecret persists a new encrypted secret +func (s *SQLiteStorage) SaveSecret(secret *models.Secret) error { + // Check if secret already exists + exists, err := s.SecretExists(secret.Handle) + if err != nil { + return fmt.Errorf("failed to check secret existence: %w", err) + } + if exists { + return fmt.Errorf("%w: secret with id '%s' already exists", ErrConflict, secret.Handle) + } + + query := ` + INSERT INTO secrets (id, handle, provider, key_version, ciphertext, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ` + + now := time.Now().UTC() + _, err = s.db.Exec(query, + secret.ID, + secret.Handle, + secret.Provider, + secret.KeyVersion, + secret.Ciphertext, + now, + now, + ) + + if err != nil { + s.logger.Error("Failed to save secret", + slog.String("secret_handle", secret.Handle), + slog.Any("error", err), + ) + return fmt.Errorf("failed to save secret: %w", err) + } + + s.logger.Debug("Secret saved successfully", + slog.String("secret_handle", secret.Handle), + slog.String("provider", secret.Provider), + slog.String("key_version", secret.KeyVersion), + ) + + return nil +} + +// GetSecrets retrieves all secrets +func (s *SQLiteStorage) GetSecrets() ([]string, error) { + query := `SELECT handle FROM secrets` + + rows, err := s.db.Query(query) + if err != nil { + return nil, fmt.Errorf("failed to query secrets: %w", err) + } + + var ids []string + + for rows.Next() { + var handle string + if err := rows.Scan(&handle); err != nil { + return nil, fmt.Errorf("failed to scan handle: %w", err) + } + ids = append(ids, handle) + } + + s.logger.Debug("Secrets retrieved successfully", + slog.Int("count", len(ids)), + ) + + return ids, nil +} + +// GetSecret retrieves a secret by Handle +func (s *SQLiteStorage) GetSecret(handle string) (*models.Secret, error) { + query := ` + SELECT id, handle, provider, key_version, ciphertext, created_at, updated_at + FROM secrets + WHERE handle = ? + ` + + var secret models.Secret + err := s.db.QueryRow(query, handle).Scan( + &secret.ID, + &secret.Handle, + &secret.Provider, + &secret.KeyVersion, + &secret.Ciphertext, + &secret.CreatedAt, + &secret.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, fmt.Errorf("%w: id=%s", ErrNotFound, handle) + } + + if err != nil { + s.logger.Error("Failed to get secret", + slog.String("secret_handle", handle), + slog.Any("error", err), + ) + return nil, fmt.Errorf("failed to get secret: %w", err) + } + + s.logger.Debug("Secret retrieved successfully", + slog.String("secret_handle", secret.Handle), + slog.String("provider", secret.Provider), + ) + + return &secret, nil +} + +// UpdateSecret updates an existing secret +func (s *SQLiteStorage) UpdateSecret(secret *models.Secret) error { + query := ` + UPDATE secrets + SET handle = ?, provider = ?, key_version = ?, ciphertext = ?, updated_at = ? + WHERE handle = ? + ` + + now := time.Now().UTC() + result, err := s.db.Exec(query, + secret.Handle, + secret.Provider, + secret.KeyVersion, + secret.Ciphertext, + now, + secret.Handle, + ) + + if err != nil { + s.logger.Error("Failed to update secret", + slog.String("secret_handle", secret.Handle), + slog.Any("error", err), + ) + return fmt.Errorf("failed to update secret: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to check rows affected: %w", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("%w: id=%s", ErrNotFound, secret.Handle) + } + + s.logger.Debug("Secret updated successfully", + slog.String("secret_handle", secret.Handle), + slog.String("provider", secret.Provider), + slog.String("key_version", secret.KeyVersion), + ) + + return nil +} + +// DeleteSecret permanently removes a secret +func (s *SQLiteStorage) DeleteSecret(handle string) error { + query := `DELETE FROM secrets WHERE handle = ?` + + result, err := s.db.Exec(query, handle) + if err != nil { + s.logger.Error("Failed to delete secret", + slog.String("secret_handle", handle), + slog.Any("error", err), + ) + return fmt.Errorf("failed to delete secret: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to check rows affected: %w", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("%w: id=%s", ErrNotFound, handle) + } + + s.logger.Debug("Secret deleted successfully", + slog.String("secret_handle", handle), + ) + + return nil +} + +// SecretExists checks if a secret with the given ID exists +func (s *SQLiteStorage) SecretExists(handle string) (bool, error) { + query := `SELECT EXISTS(SELECT 1 FROM secrets WHERE handle = ?)` + + var exists bool + err := s.db.QueryRow(query, handle).Scan(&exists) + if err != nil { + s.logger.Error("Failed to check secret existence", + slog.String("secret_handle", handle), + slog.Any("error", err), + ) + return false, fmt.Errorf("failed to check secret existence: %w", err) + } + + return exists, nil +} diff --git a/gateway/it/features/secrets.feature b/gateway/it/features/secrets.feature new file mode 100644 index 000000000..2ffd2d0b7 --- /dev/null +++ b/gateway/it/features/secrets.feature @@ -0,0 +1,536 @@ +# -------------------------------------------------------------------- +# 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. +# -------------------------------------------------------------------- + +Feature: Secrets Management + As an API administrator + I want to manage secrets securely in the gateway + So that I can store and retrieve sensitive configuration data encrypted at rest + + Background: + Given the gateway services are running + + # ======================================== + # Scenario Group 1: Secret Lifecycle (Happy Path) + # ======================================== + + Scenario: Complete secret lifecycle - create, retrieve, update, and delete + Given I authenticate using basic auth as "admin" + When I create this secret: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: Secret + metadata: + name: wso2-openai-key + spec: + displayName: WSO2 OpenAI Key + description: WSO2 OpenAI provider API Key + type: default + value: sk_xxx + """ + Then the response status code should be 201 + And the response should be valid JSON + And the JSON response field "id" should be "wso2-openai-key" + And the JSON response field "value" should be "sk_xxx" + + Given I authenticate using basic auth as "admin" + When I retrieve the secret "wso2-openai-key" + Then the response status code should be 200 + And the response should be valid JSON + And the JSON response field "id" should be "wso2-openai-key" + And the JSON response field "value" should be "sk_xxx" + + Given I authenticate using basic auth as "admin" + When I update the secret "wso2-openai-key" with: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: Secret + metadata: + name: wso2-openai-key + spec: + displayName: WSO2 OpenAI Key + description: WSO2 OpenAI provider API Key + type: default + value: sk_yyy + """ + Then the response status code should be 200 + And the response should be valid JSON + And the JSON response field "id" should be "wso2-openai-key" + And the JSON response field "value" should be "sk_yyy" + + Given I authenticate using basic auth as "admin" + When I retrieve the secret "wso2-openai-key" + Then the response status code should be 200 + And the JSON response field "value" should be "sk_yyy" + + Given I authenticate using basic auth as "admin" + When I delete the secret "wso2-openai-key" + Then the response status code should be 200 + + Given I authenticate using basic auth as "admin" + When I retrieve the secret "wso2-openai-keye" + Then the response status code should be 404 + And the response should be valid JSON + And the JSON response field "status" should be "error" + +# ======================================== + # Scenario Group 2: Listing and Filtering + # ======================================== + + Scenario: List all secrets returns metadata without sensitive values + Given I authenticate using basic auth as "admin" + When I create this secret: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: Secret + metadata: + name: list-test-secret-1 + spec: + displayName: Test Secret 1 + description: First test secret for listing + type: default + value: super-secret-value-1 + """ + Then the response status code should be 201 + + Given I authenticate using basic auth as "admin" + When I create this secret: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: Secret + metadata: + name: list-test-secret-2 + spec: + displayName: Test Secret 2 + description: Second test secret for listing + type: default + value: super-secret-value-2 + """ + Then the response status code should be 201 + + Given I authenticate using basic auth as "admin" + When I create this secret: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: Secret + metadata: + name: list-test-secret-3 + spec: + displayName: Test Secret 3 + description: Third test secret for listing + type: default + value: super-secret-value-3 + """ + Then the response status code should be 201 + + Given I authenticate using basic auth as "admin" + When I list all secrets + Then the response status code should be 200 + And the response should be valid JSON + And the response body should contain "list-test-secret-1" + And the response body should contain "list-test-secret-2" + And the response body should contain "list-test-secret-3" + + Given I authenticate using basic auth as "admin" + When I delete the secret "list-test-secret-1" + Then the response status code should be 200 + Given I authenticate using basic auth as "admin" + When I delete the secret "list-test-secret-2" + Then the response status code should be 200 + Given I authenticate using basic auth as "admin" + When I delete the secret "list-test-secret-3" + Then the response status code should be 200 + + Scenario: List secrets includes metadata fields + Given I authenticate using basic auth as "admin" + When I create this secret: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: Secret + metadata: + name: metadata-test-secret + spec: + displayName: Metadata Test Secret + description: Secret for testing metadata fields + type: default + value: test-value-123 + """ + Then the response status code should be 201 + + Given I authenticate using basic auth as "admin" + When I list all secrets + Then the response status code should be 200 + And the response should be valid JSON + And the response body should contain "metadata-test-secret" + And the JSON response should have field "secrets" + + Given I authenticate using basic auth as "admin" + When I delete the secret "metadata-test-secret" + Then the response status code should be 200 + + Scenario: Empty secrets list returns valid response + Given I authenticate using basic auth as "admin" + When I list all secrets + Then the response status code should be 200 + And the response should be valid JSON + And the JSON response field "status" should be "success" + + Scenario: List secrets after creating and deleting shows correct state + Given I authenticate using basic auth as "admin" + When I create this secret: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: Secret + metadata: + name: temporary-secret + spec: + displayName: Temporary Secret + description: Will be deleted + type: default + value: temporary-value + """ + Then the response status code should be 201 + + Given I authenticate using basic auth as "admin" + When I list all secrets + Then the response status code should be 200 + And the response body should contain "temporary-secret" + + Given I authenticate using basic auth as "admin" + When I delete the secret "temporary-secret" + Then the response status code should be 200 + + Given I authenticate using basic auth as "admin" + When I list all secrets + Then the response status code should be 200 + And the response body should not contain "temporary-secret" + + # ======================================== + # Scenario Group 3: Validation & Error Handling + # ======================================== + + Scenario: Create secret with missing metadata name field + Given I authenticate using basic auth as "admin" + When I create this secret: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: Secret + metadata: + labels: + test: "true" + spec: + displayName: Missing Name Secret + description: Secret without name in metadata + type: default + value: some-value + """ + Then the response status code should be 400 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "name" + + Scenario: Create secret with missing value field + Given I authenticate using basic auth as "admin" + When I create this secret: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: Secret + metadata: + name: missing-value-secret + spec: + displayName: Missing Value Secret + description: Secret without value field + type: default + """ + Then the response status code should be 400 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "value" + + Scenario: Create secret with empty value field + Given I authenticate using basic auth as "admin" + When I create this secret: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: Secret + metadata: + name: empty-value-secret + spec: + displayName: Empty Value Secret + description: Secret with empty value + type: default + value: "" + """ + Then the response status code should be 400 + And the response should be valid JSON + And the JSON response field "status" should be "error" + + Scenario: Create secret with oversized value exceeding 10KB limit + Given I authenticate using basic auth as "admin" + When I create a secret with oversized value + Then the response status code should be 400 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "Secret value must be at most 8192 characters" + + Scenario: Update secret with missing value field + Given I authenticate using basic auth as "admin" + When I create this secret: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: Secret + metadata: + name: update-validation-test-secret + spec: + displayName: Update Validation Test + description: Testing update validation + type: default + value: original-value + """ + Then the response status code should be 201 + + Given I authenticate using basic auth as "admin" + When I update the secret "update-validation-test-secret" with: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: Secret + metadata: + name: update-validation-test-secret + spec: + displayName: Update Validation Test + description: Attempting to update without value + type: default + """ + Then the response status code should be 400 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "value" + + Given I authenticate using basic auth as "admin" + When I retrieve the secret "update-validation-test-secret" + Then the response status code should be 200 + And the JSON response field "value" should be "original-value" + + Given I authenticate using basic auth as "admin" + When I delete the secret "update-validation-test-secret" + Then the response status code should be 200 + + Scenario: Update secret with empty value field + Given I authenticate using basic auth as "admin" + When I create this secret: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: Secret + metadata: + name: empty-update-test-secret + spec: + displayName: Empty Update Test + description: Testing empty value on update + type: default + value: original-value + """ + Then the response status code should be 201 + + Given I authenticate using basic auth as "admin" + When I update the secret "empty-update-test-secret" with: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: Secret + metadata: + name: empty-update-test-secret + spec: + displayName: Empty Update Test + description: Attempting to update with empty value + type: default + value: "" + """ + Then the response status code should be 400 + And the response should be valid JSON + And the JSON response field "status" should be "error" + + Given I authenticate using basic auth as "admin" + When I retrieve the secret "empty-update-test-secret" + Then the response status code should be 200 + And the JSON response field "value" should be "original-value" + + Given I authenticate using basic auth as "admin" + When I delete the secret "empty-update-test-secret" + Then the response status code should be 200 + + Scenario: Access secrets endpoints without authentication returns unauthorized + When I clear all headers + And I list all secrets + Then the response status code should be 401 + And the response should be valid JSON + And the JSON response field "error" should be "no valid authentication credentials provided" + + When I clear all headers + And I create this secret: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: Secret + metadata: + name: unauthorized-test-secret + spec: + displayName: Unauthorized Test + description: Testing without authentication + type: default + value: test-value + """ + Then the response status code should be 401 + And the response should be valid JSON + And the JSON response field "error" should be "no valid authentication credentials provided" + + When I clear all headers + And I retrieve the secret "some-secret-id" + Then the response status code should be 401 + And the response should be valid JSON + And the JSON response field "error" should be "no valid authentication credentials provided" + + When I clear all headers + And I update the secret "some-secret-id" with: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: Secret + metadata: + name: some-secret-id + spec: + displayName: Unauthorized Update + description: Testing update without authentication + type: default + value: new-value + """ + Then the response status code should be 401 + And the response should be valid JSON + And the JSON response field "error" should be "no valid authentication credentials provided" + + When I clear all headers + And I delete the secret "some-secret-id" + Then the response status code should be 401 + And the response should be valid JSON + And the JSON response field "error" should be "no valid authentication credentials provided" + + # ======================================== + # Scenario Group 4: Conflict & Not Found + # ======================================== + + Scenario: Create duplicate secret returns conflict error + Given I authenticate using basic auth as "admin" + When I create this secret: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: Secret + metadata: + name: duplicate-test-secret + spec: + displayName: Duplicate Test Secret + description: First secret creation + type: default + value: first-value + """ + Then the response status code should be 201 + And the response should be valid JSON + And the JSON response field "id" should be "duplicate-test-secret" + + Given I authenticate using basic auth as "admin" + When I create this secret: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: Secret + metadata: + name: duplicate-test-secret + spec: + displayName: Duplicate Test Secret + description: Second secret creation with same name + type: default + value: second-value + """ + Then the response status code should be 409 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "already exists" + + Given I authenticate using basic auth as "admin" + When I retrieve the secret "duplicate-test-secret" + Then the response status code should be 200 + And the JSON response field "value" should be "first-value" + + Given I authenticate using basic auth as "admin" + When I delete the secret "duplicate-test-secret" + Then the response status code should be 200 + + Scenario: Retrieve non-existent secret returns not found + Given I authenticate using basic auth as "admin" + When I retrieve the secret "non-existent-secret-xyz-12345" + Then the response status code should be 404 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "not found" + + Scenario: Update non-existent secret returns not found + Given I authenticate using basic auth as "admin" + When I update the secret "non-existent-update-secret-abc" with: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: Secret + metadata: + name: non-existent-update-secret-abc + spec: + displayName: Non-existent Secret + description: Attempting to update non-existent secret + type: default + value: new-value + """ + Then the response status code should be 404 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "does not exists" + + Scenario: Delete non-existent secret returns not found + Given I authenticate using basic auth as "admin" + When I delete the secret "non-existent-delete-secret-xyz" + Then the response status code should be 404 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "not found" + + Scenario: Delete operation is idempotent - second delete returns not found + Given I authenticate using basic auth as "admin" + When I create this secret: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: Secret + metadata: + name: idempotent-delete-test + spec: + displayName: Idempotent Delete Test + description: Testing delete idempotency + type: default + value: test-value + """ + Then the response status code should be 201 + + Given I authenticate using basic auth as "admin" + When I delete the secret "idempotent-delete-test" + Then the response status code should be 200 + + Given I authenticate using basic auth as "admin" + When I delete the secret "idempotent-delete-test" + Then the response status code should be 404 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "not found" diff --git a/gateway/it/steps_secrets.go b/gateway/it/steps_secrets.go new file mode 100644 index 000000000..3dd44cc94 --- /dev/null +++ b/gateway/it/steps_secrets.go @@ -0,0 +1,77 @@ +/* + * 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 it + +import ( + "github.com/cucumber/godog" + "github.com/wso2/api-platform/gateway/it/steps" +) + +// RegisterSecretSteps registers all secret management step definitions +func RegisterSecretSteps(ctx *godog.ScenarioContext, state *TestState, httpSteps *steps.HTTPSteps) { + // Create secret steps + ctx.Step(`^I create this secret:$`, func(body *godog.DocString) error { + httpSteps.SetHeader("Content-Type", "application/yaml") + return httpSteps.SendPOSTToService("gateway-controller", "/secrets", body) + }) + + // Create secret with oversized value (for validation testing) + ctx.Step(`^I create a secret with oversized value$`, func() error { + // Generate a value larger than 10KB (10,240 bytes) + oversizedValue := make([]byte, 10241) + for i := range oversizedValue { + oversizedValue[i] = 'x' + } + + body := &godog.DocString{ + Content: `apiVersion: gateway.api-platform.wso2.com/v1alpha1 +kind: Secret +metadata: + name: oversized-secret +spec: + displayName: Oversized Secret + description: Secret with value exceeding 10KB limit + type: default + value: ` + string(oversizedValue), + } + httpSteps.SetHeader("Content-Type", "application/yaml") + return httpSteps.SendPOSTToService("gateway-controller", "/secrets", body) + }) + + // Retrieve secret step + ctx.Step(`^I retrieve the secret "([^"]*)"$`, func(secretID string) error { + return httpSteps.SendGETToService("gateway-controller", "/secrets/"+secretID) + }) + + // Update secret steps + ctx.Step(`^I update the secret "([^"]*)" with:$`, func(secretID string, body *godog.DocString) error { + httpSteps.SetHeader("Content-Type", "application/yaml") + return httpSteps.SendPUTToService("gateway-controller", "/secrets/"+secretID, body) + }) + + // Delete secret step + ctx.Step(`^I delete the secret "([^"]*)"$`, func(secretID string) error { + return httpSteps.SendDELETEToService("gateway-controller", "/secrets/"+secretID) + }) + + // List secrets step + ctx.Step(`^I list all secrets$`, func() error { + return httpSteps.SendGETToService("gateway-controller", "/secrets") + }) +} diff --git a/gateway/it/suite_test.go b/gateway/it/suite_test.go index 8145eb3ac..d1a7bae9a 100644 --- a/gateway/it/suite_test.go +++ b/gateway/it/suite_test.go @@ -83,6 +83,7 @@ func TestFeatures(t *testing.T) { "features/llm-provider-templates.feature", "features/analytics-header-filter.feature", "features/lazy-resources-xds.feature", + "features/secrets.feature", }, TestingT: t, }, @@ -240,6 +241,8 @@ func InitializeScenario(ctx *godog.ScenarioContext) { RegisterMCPSteps(ctx, testState, httpSteps) RegisterLLMSteps(ctx, testState, httpSteps) RegisterJWTSteps(ctx, testState, httpSteps, jwtSteps) + RegisterLLMSteps(ctx, testState, httpSteps) + RegisterSecretSteps(ctx, testState, httpSteps) } // Register common HTTP and assertion steps From 7644c653698e40eb5fe6bae20ef8cd19ccd39ad0 Mon Sep 17 00:00:00 2001 From: Nimsara Fernando Date: Fri, 23 Jan 2026 05:48:56 +0530 Subject: [PATCH 2/6] Review and apply based on coderabbit feedback --- gateway/gateway-controller/api/openapi.yaml | 22 +++--- .../pkg/api/handlers/handlers.go | 64 ++++++++++++++--- .../pkg/config/secret_validator.go | 8 ++- .../pkg/encryption/aesgcm/keymgmt.go | 5 ++ .../pkg/encryption/aesgcm/provider.go | 8 ++- .../pkg/encryption/manager.go | 3 + .../pkg/resolver/policy_resolver.go | 14 +++- .../gateway-controller/pkg/secrets/service.go | 19 +++-- .../pkg/storage/interface.go | 6 +- .../gateway-controller/pkg/storage/sqlite.go | 31 +++++---- gateway/it/features/secrets.feature | 69 ++++++++++++++++--- gateway/it/steps_secrets.go | 24 +++++++ 12 files changed, 220 insertions(+), 53 deletions(-) diff --git a/gateway/gateway-controller/api/openapi.yaml b/gateway/gateway-controller/api/openapi.yaml index 7c70866b9..d79b405fe 100644 --- a/gateway/gateway-controller/api/openapi.yaml +++ b/gateway/gateway-controller/api/openapi.yaml @@ -1701,7 +1701,7 @@ paths: /secrets: get: tags: - - secrets + - Secrets Management summary: List all secrets description: | Retrieve a list of all stored secrets. Returns secret metadata without @@ -1729,7 +1729,7 @@ paths: post: tags: - - secrets + - Secrets Management summary: Create a new secret description: | Stores a new secret encrypted at rest. The secret ID must be unique. @@ -1802,7 +1802,7 @@ paths: get: tags: - - secrets + - Secrets Management summary: Retrieve a secret description: | Retrieves and decrypts a secret. The secret value is decrypted using the @@ -1831,7 +1831,7 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' '404': - description: Secret not found + description: Secret configuration not found content: application/json: schema: @@ -1845,7 +1845,7 @@ paths: put: tags: - - secrets + - Secrets Management summary: Update a secret description: | Updates an existing secret with a new value. The new value is encrypted @@ -1889,7 +1889,7 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' '404': - description: Secret not found + description: Secret configuration not found content: application/json: schema: @@ -1903,7 +1903,7 @@ paths: delete: tags: - - secrets + - Secrets Management summary: Delete a secret description: | Permanently deletes a secret from the database. This is a hard delete with @@ -1920,7 +1920,7 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' '404': - description: Secret not found + description: Secret configuration not found content: application/json: schema: @@ -1930,7 +1930,7 @@ paths: summary: Secret does not exist value: error: "not_found" - message: "Secret not found" + message: "secret configuration not found" correlation_id: "req-bcd-890" '500': description: Internal server error - database failure @@ -3642,7 +3642,7 @@ components: value: type: string - description: Secret value (stored securely and never returned in API responses) + description: Secret value (stored encrypted) minLength: 1 maxLength: 8192 format: password @@ -3934,3 +3934,5 @@ tags: description: CRUD operations for LLM Provider configurations - name: LLM Proxy Management description: CRUD operations for LLM Proxy configurations + - name: Secrets Management + description: CRUD operations for Secrets diff --git a/gateway/gateway-controller/pkg/api/handlers/handlers.go b/gateway/gateway-controller/pkg/api/handlers/handlers.go index 3dc6ea7cb..87bf44544 100644 --- a/gateway/gateway-controller/pkg/api/handlers/handlers.go +++ b/gateway/gateway-controller/pkg/api/handlers/handlers.go @@ -2273,11 +2273,20 @@ func (s *APIServer) CreateSecret(c *gin.Context) { // Get correlation ID from context correlationID := middleware.GetCorrelationID(c) + // Avoid secretService nil panic + if s.secretService == nil { + log.Error("Secret service is not initialized properly") + c.JSON(http.StatusInternalServerError, api.ErrorResponse{Status: "error", + Message: "Secret service is not initialized properly"}) + return + } + // Delegate to service which parses/validates/encrypt and persists secret, err := s.secretService.CreateSecret(secrets.SecretParams{ - Data: body, - ContentType: c.GetHeader("Content-Type"), - Logger: log, + Data: body, + ContentType: c.GetHeader("Content-Type"), + CorrelationID: correlationID, + Logger: log, }) if err != nil { log.Error("Failed to encrypt Secret", slog.Any("error", err)) @@ -2296,7 +2305,7 @@ func (s *APIServer) CreateSecret(c *gin.Context) { // Return created secret c.JSON(http.StatusCreated, gin.H{ "id": secret.Handle, - "value": secret.Value, + "value": "", "created_at": secret.CreatedAt, "updated_at": secret.UpdatedAt, }) @@ -2310,6 +2319,14 @@ func (s *APIServer) ListSecrets(c *gin.Context) { log.Debug("Retrieving secretsList", slog.String("correlation_id", correlationID)) + // Avoid secretService nil panic + if s.secretService == nil { + log.Error("Secret service is not initialized properly") + c.JSON(http.StatusInternalServerError, api.ErrorResponse{Status: "error", + Message: "Secret service is not initialized properly"}) + return + } + ids, err := s.secretService.GetSecrets(correlationID) if err != nil { log.Error("Failed to retrieve secretsList", @@ -2360,6 +2377,14 @@ func (s *APIServer) GetSecret(c *gin.Context, id string) { return } + // Avoid secretService nil panic + if s.secretService == nil { + log.Error("Secret service is not initialized properly") + c.JSON(http.StatusInternalServerError, api.ErrorResponse{Status: "error", + Message: "Secret service is not initialized properly"}) + return + } + // Retrieve secret secret, err := s.secretService.Get(id, correlationID) if err != nil { @@ -2415,16 +2440,27 @@ func (s *APIServer) UpdateSecret(c *gin.Context, id string) { // Get correlation ID from context correlationID := middleware.GetCorrelationID(c) + // Avoid secretService nil panic + if s.secretService == nil { + log.Error("Secret service is not initialized properly") + c.JSON(http.StatusInternalServerError, api.ErrorResponse{Status: "error", + Message: "Secret service is not initialized properly"}) + return + } + // Delegate to service which parses/validates/encrypt and persists - secret, err := s.secretService.UpdateSecret(secrets.SecretParams{ - Data: body, - ContentType: c.GetHeader("Content-Type"), - Logger: log, + secret, err := s.secretService.UpdateSecret(id, secrets.SecretParams{ + Data: body, + ContentType: c.GetHeader("Content-Type"), + CorrelationID: correlationID, + Logger: log, }) if err != nil { log.Error("Failed to encrypt Secret", slog.Any("error", err)) - if strings.Contains(err.Error(), "does not exist") { + if strings.Contains(err.Error(), "secret configuration not found") { c.JSON(http.StatusNotFound, api.ErrorResponse{Status: "error", Message: err.Error()}) + } else if strings.Contains(err.Error(), "already exists") { + c.JSON(http.StatusConflict, api.ErrorResponse{Status: "error", Message: err.Error()}) } else { c.JSON(http.StatusBadRequest, api.ErrorResponse{Status: "error", Message: err.Error()}) } @@ -2438,7 +2474,7 @@ func (s *APIServer) UpdateSecret(c *gin.Context, id string) { // Return created secret c.JSON(http.StatusOK, gin.H{ "id": secret.Handle, - "value": secret.Value, + "value": "", "created_at": secret.CreatedAt, "updated_at": secret.UpdatedAt, }) @@ -2469,6 +2505,14 @@ func (s *APIServer) DeleteSecret(c *gin.Context, id string) { return } + // Avoid secretService nil panic + if s.secretService == nil { + log.Error("Secret service is not initialized properly") + c.JSON(http.StatusInternalServerError, api.ErrorResponse{Status: "error", + Message: "Secret service is not initialized properly"}) + return + } + // Delete secret if err := s.secretService.Delete(id, correlationID); err != nil { // Check for not found error diff --git a/gateway/gateway-controller/pkg/config/secret_validator.go b/gateway/gateway-controller/pkg/config/secret_validator.go index 9df3c55f3..71b8ee14c 100644 --- a/gateway/gateway-controller/pkg/config/secret_validator.go +++ b/gateway/gateway-controller/pkg/config/secret_validator.go @@ -37,6 +37,9 @@ type SecretValidator struct { // supportedTypes defines supported secret types supportedTypes []string + + // maxSecretSize defines maximum allowed size for a secret value in KB + maxSecretSize int } // NewSecretValidator creates a new Secret configuration validator @@ -45,6 +48,7 @@ func NewSecretValidator() *SecretValidator { urlFriendlyNameRegex: regexp.MustCompile(`^[a-zA-Z0-9\-_. ]+$`), supportedKinds: []string{"Secret"}, supportedTypes: []string{"default"}, + maxSecretSize: 10, } } @@ -143,10 +147,10 @@ func (v *SecretValidator) validateSpec(spec *api.SecretConfigData) []ValidationE Field: "spec.value", Message: "Secret value is required", }) - } else if len(spec.Value) > 8192 { + } else if len(spec.Value) > v.maxSecretSize*1024 { errors = append(errors, ValidationError{ Field: "spec.value", - Message: "Secret value must be at most 8192 characters", + Message: fmt.Sprintf("Secret value must be less than %dKB", v.maxSecretSize), }) } diff --git a/gateway/gateway-controller/pkg/encryption/aesgcm/keymgmt.go b/gateway/gateway-controller/pkg/encryption/aesgcm/keymgmt.go index db678bc5f..e0c44c3c9 100644 --- a/gateway/gateway-controller/pkg/encryption/aesgcm/keymgmt.go +++ b/gateway/gateway-controller/pkg/encryption/aesgcm/keymgmt.go @@ -58,6 +58,11 @@ func NewKeyManager(keyConfigs []KeyConfig, logger *slog.Logger) (*KeyManager, er // Load all keys for i, config := range keyConfigs { + // Avoid duplicate key override + if _, exists := km.keys[config.Version]; exists { + return nil, fmt.Errorf("duplicate key version: %s", config.Version) + } + key, err := km.loadKey(config) if err != nil { return nil, fmt.Errorf("failed to load key %s: %w", config.Version, err) diff --git a/gateway/gateway-controller/pkg/encryption/aesgcm/provider.go b/gateway/gateway-controller/pkg/encryption/aesgcm/provider.go index 89f7944e0..399cd370a 100644 --- a/gateway/gateway-controller/pkg/encryption/aesgcm/provider.go +++ b/gateway/gateway-controller/pkg/encryption/aesgcm/provider.go @@ -31,6 +31,8 @@ import ( const ( // NonceSize is the size of the nonce for AES-GCM (12 bytes is standard) NonceSize = 12 + // GCM tag is 16 bytes + GCMTagSize = 16 ) // AESGCMProvider implements encryption using AES-GCM @@ -70,6 +72,9 @@ func (p *AESGCMProvider) Name() string { func (p *AESGCMProvider) Encrypt(plaintext []byte) (*encryption.EncryptedPayload, error) { // Get primary key for encryption key := p.keyManager.GetPrimaryKey() + if key == nil { + return nil, fmt.Errorf("no primary key available") + } // Create AES cipher block, err := aes.NewCipher(key.Data) @@ -115,7 +120,8 @@ func (p *AESGCMProvider) Decrypt(payload *encryption.EncryptedPayload) ([]byte, } // Validate ciphertext length (must be at least nonce size + tag size) - if len(payload.Ciphertext) < NonceSize { + // GCM tag is 16 bytes + if len(payload.Ciphertext) < NonceSize+GCMTagSize { return nil, fmt.Errorf("ciphertext too short: %d bytes", len(payload.Ciphertext)) } diff --git a/gateway/gateway-controller/pkg/encryption/manager.go b/gateway/gateway-controller/pkg/encryption/manager.go index 9b9508b35..b000fe2ec 100644 --- a/gateway/gateway-controller/pkg/encryption/manager.go +++ b/gateway/gateway-controller/pkg/encryption/manager.go @@ -113,6 +113,9 @@ func (m *ProviderManager) Encrypt(plaintext []byte) (*EncryptedPayload, error) { // Decrypt decrypts the payload using the provider chain // It tries to match the provider by name from the payload metadata func (m *ProviderManager) Decrypt(payload *EncryptedPayload) ([]byte, error) { + if payload == nil { + return nil, fmt.Errorf("encrypted payload is nil") + } m.logger.Debug("Decrypting payload", slog.String("provider", payload.Provider), slog.String("key_version", payload.KeyVersion), diff --git a/gateway/gateway-controller/pkg/resolver/policy_resolver.go b/gateway/gateway-controller/pkg/resolver/policy_resolver.go index 99b48e0e4..0e208085a 100644 --- a/gateway/gateway-controller/pkg/resolver/policy_resolver.go +++ b/gateway/gateway-controller/pkg/resolver/policy_resolver.go @@ -115,6 +115,11 @@ func (pr *PolicyResolver) ResolvePolicies(apiConfig *models.StoredConfig) ( *models.StoredConfig, []config.ValidationError) { var errors []config.ValidationError + // Only support RESTAPI kind + if apiConfig.Configuration.Kind != api.RestApi { + return apiConfig, nil + } + apiData, err := apiConfig.Configuration.Spec.AsAPIConfigData() if err != nil { errors = append(errors, config.ValidationError{ @@ -219,7 +224,10 @@ func (pr *PolicyResolver) ResolvePolicies(apiConfig *models.StoredConfig) ( err = resolvedConfig.Configuration.Spec.FromAPIConfigData(apiData) if err != nil { - errors = append(errors, config.ValidationError{}) + errors = append(errors, config.ValidationError{ + Field: "spec", + Message: fmt.Sprintf("Failed to rebuild API spec after policy resolution: %v", err), + }) } if len(errors) > 0 { @@ -300,6 +308,10 @@ func (pr *PolicyResolver) resolveValuesByPath(params map[string]interface{}, pat // Example: "Bearer $secret{wso2-openai-apikey}" -> "Bearer sk_xxx" // Example: "$secret{auth-type} $secret{wso2-openai-apikey}" -> "Bearer sk_xxx" func (pr *PolicyResolver) resolveSecretTemplates(templateStr string) (string, error) { + if pr.secretsService == nil { + return "", fmt.Errorf("secret service is not initialized properly") + } + // Pattern to match $secret{key} secretPattern := `\$secret\{([^}]+)\}` re := regexp.MustCompile(secretPattern) diff --git a/gateway/gateway-controller/pkg/secrets/service.go b/gateway/gateway-controller/pkg/secrets/service.go index c4cb539ea..04396525e 100644 --- a/gateway/gateway-controller/pkg/secrets/service.go +++ b/gateway/gateway-controller/pkg/secrets/service.go @@ -75,10 +75,10 @@ func (s *SecretService) CreateSecret(params SecretParams) (*models.Secret, error var secretConfig api.SecretConfiguration // Parse configuration err := s.parser.Parse(params.Data, params.ContentType, &secretConfig) - handle := secretConfig.Metadata.Name if err != nil { return nil, fmt.Errorf("failed to parse configuration: %w", err) } + handle := secretConfig.Metadata.Name // Validate configuration validationErrors := s.validator.Validate(&secretConfig) @@ -237,11 +237,10 @@ func (s *SecretService) Get(handle string, correlationID string) (*models.Secret } // UpdateSecret updates an existing secret with re-encryption using current primary key -func (s *SecretService) UpdateSecret(params SecretParams) (*models.Secret, error) { +func (s *SecretService) UpdateSecret(handle string, params SecretParams) (*models.Secret, error) { var secretConfig api.SecretConfiguration // Parse configuration err := s.parser.Parse(params.Data, params.ContentType, &secretConfig) - handle := secretConfig.Metadata.Name if err != nil { return nil, fmt.Errorf("failed to parse configuration: %w", err) } @@ -274,7 +273,19 @@ func (s *SecretService) UpdateSecret(params SecretParams) (*models.Secret, error return nil, fmt.Errorf("failed to check secret existence: %w", err) } if !exists { - return nil, fmt.Errorf("secret with id '%s' does not exists", handle) + return nil, fmt.Errorf("secret configuration not found: id=%s", handle) + } + + // Check for metadata.name conflicts + if secretConfig.Metadata.Name != handle { + conflict, err := s.storage.SecretExists(secretConfig.Metadata.Name) + if err != nil { + return nil, fmt.Errorf("failed to check conflicting secret existence: %w", err) + } + if conflict { + return nil, fmt.Errorf("unable to change the secret id because a secret with the id '%s'"+ + " already exists", secretConfig.Metadata.Name) + } } // Encrypt with current primary key (automatic key migration) diff --git a/gateway/gateway-controller/pkg/storage/interface.go b/gateway/gateway-controller/pkg/storage/interface.go index c7d2e633f..5b11ed6bb 100644 --- a/gateway/gateway-controller/pkg/storage/interface.go +++ b/gateway/gateway-controller/pkg/storage/interface.go @@ -258,12 +258,12 @@ type Storage interface { // DeleteSecret permanently removes a secret. // // Returns error if the secret does not exist. - DeleteSecret(id string) error + DeleteSecret(handle string) error - // SecretExists checks if a secret with the given ID exists. + // SecretExists checks if a secret with the given handle exists. // // Returns true if the secret exists, false otherwise. - SecretExists(id string) (bool, error) + SecretExists(handle string) (bool, error) // Close closes the storage connection and releases resources. // diff --git a/gateway/gateway-controller/pkg/storage/sqlite.go b/gateway/gateway-controller/pkg/storage/sqlite.go index 6dd395a87..219516cb2 100644 --- a/gateway/gateway-controller/pkg/storage/sqlite.go +++ b/gateway/gateway-controller/pkg/storage/sqlite.go @@ -1711,22 +1711,13 @@ func (s *SQLiteStorage) CountActiveAPIKeysByUserAndAPI(apiId, userID string) (in // SaveSecret persists a new encrypted secret func (s *SQLiteStorage) SaveSecret(secret *models.Secret) error { - // Check if secret already exists - exists, err := s.SecretExists(secret.Handle) - if err != nil { - return fmt.Errorf("failed to check secret existence: %w", err) - } - if exists { - return fmt.Errorf("%w: secret with id '%s' already exists", ErrConflict, secret.Handle) - } - query := ` INSERT INTO secrets (id, handle, provider, key_version, ciphertext, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ` now := time.Now().UTC() - _, err = s.db.Exec(query, + _, err := s.db.Exec(query, secret.ID, secret.Handle, secret.Provider, @@ -1737,6 +1728,9 @@ func (s *SQLiteStorage) SaveSecret(secret *models.Secret) error { ) if err != nil { + if err.Error() == "UNIQUE constraint failed: secrets.handle" { + return fmt.Errorf("%w: secret with id '%s' already exists", ErrConflict, secret.Handle) + } s.logger.Error("Failed to save secret", slog.String("secret_handle", secret.Handle), slog.Any("error", err), @@ -1761,9 +1755,14 @@ func (s *SQLiteStorage) GetSecrets() ([]string, error) { if err != nil { return nil, fmt.Errorf("failed to query secrets: %w", err) } + defer func(rows *sql.Rows) { + err := rows.Close() + if err != nil { - var ids []string + } + }(rows) + var ids []string for rows.Next() { var handle string if err := rows.Scan(&handle); err != nil { @@ -1772,6 +1771,10 @@ func (s *SQLiteStorage) GetSecrets() ([]string, error) { ids = append(ids, handle) } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating secrets: %w", err) + } + s.logger.Debug("Secrets retrieved successfully", slog.Int("count", len(ids)), ) @@ -1799,7 +1802,7 @@ func (s *SQLiteStorage) GetSecret(handle string) (*models.Secret, error) { ) if err == sql.ErrNoRows { - return nil, fmt.Errorf("%w: id=%s", ErrNotFound, handle) + return nil, fmt.Errorf("secret %w: id=%s", ErrNotFound, handle) } if err != nil { @@ -1850,7 +1853,7 @@ func (s *SQLiteStorage) UpdateSecret(secret *models.Secret) error { } if rowsAffected == 0 { - return fmt.Errorf("%w: id=%s", ErrNotFound, secret.Handle) + return fmt.Errorf("secret %w: id=%s", ErrNotFound, secret.Handle) } s.logger.Debug("Secret updated successfully", @@ -1881,7 +1884,7 @@ func (s *SQLiteStorage) DeleteSecret(handle string) error { } if rowsAffected == 0 { - return fmt.Errorf("%w: id=%s", ErrNotFound, handle) + return fmt.Errorf("secret %w: id=%s", ErrNotFound, handle) } s.logger.Debug("Secret deleted successfully", diff --git a/gateway/it/features/secrets.feature b/gateway/it/features/secrets.feature index 2ffd2d0b7..9c9acbb83 100644 --- a/gateway/it/features/secrets.feature +++ b/gateway/it/features/secrets.feature @@ -45,7 +45,7 @@ Feature: Secrets Management Then the response status code should be 201 And the response should be valid JSON And the JSON response field "id" should be "wso2-openai-key" - And the JSON response field "value" should be "sk_xxx" + And the JSON response field "value" should be "" Given I authenticate using basic auth as "admin" When I retrieve the secret "wso2-openai-key" @@ -70,7 +70,7 @@ Feature: Secrets Management Then the response status code should be 200 And the response should be valid JSON And the JSON response field "id" should be "wso2-openai-key" - And the JSON response field "value" should be "sk_yyy" + And the JSON response field "value" should be "" Given I authenticate using basic auth as "admin" When I retrieve the secret "wso2-openai-key" @@ -82,11 +82,15 @@ Feature: Secrets Management Then the response status code should be 200 Given I authenticate using basic auth as "admin" - When I retrieve the secret "wso2-openai-keye" + When I retrieve the secret "wso2-openai-key" Then the response status code should be 404 And the response should be valid JSON And the JSON response field "status" should be "error" + Given I authenticate using basic auth as "admin" + When I create a secret with value size 10000 + Then the response status code should be 201 + # ======================================== # Scenario Group 2: Listing and Filtering # ======================================== @@ -285,7 +289,7 @@ Feature: Secrets Management Then the response status code should be 400 And the response should be valid JSON And the JSON response field "status" should be "error" - And the response body should contain "Secret value must be at most 8192 characters" + And the response body should contain "Secret value must be less than 10KB" Scenario: Update secret with missing value field Given I authenticate using basic auth as "admin" @@ -479,7 +483,7 @@ Feature: Secrets Management Then the response status code should be 404 And the response should be valid JSON And the JSON response field "status" should be "error" - And the response body should contain "not found" + And the response body should contain "secret configuration not found" Scenario: Update non-existent secret returns not found Given I authenticate using basic auth as "admin" @@ -498,7 +502,7 @@ Feature: Secrets Management Then the response status code should be 404 And the response should be valid JSON And the JSON response field "status" should be "error" - And the response body should contain "does not exists" + And the response body should contain "secret configuration not found" Scenario: Delete non-existent secret returns not found Given I authenticate using basic auth as "admin" @@ -506,7 +510,7 @@ Feature: Secrets Management Then the response status code should be 404 And the response should be valid JSON And the JSON response field "status" should be "error" - And the response body should contain "not found" + And the response body should contain "secret configuration not found" Scenario: Delete operation is idempotent - second delete returns not found Given I authenticate using basic auth as "admin" @@ -533,4 +537,53 @@ Feature: Secrets Management Then the response status code should be 404 And the response should be valid JSON And the JSON response field "status" should be "error" - And the response body should contain "not found" + And the response body should contain "secret configuration not found" + + Scenario: Update secret with an existing secret name returns conflict + Given I authenticate using basic auth as "admin" + When I create this secret: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: Secret + metadata: + name: update-conflict-secret-1 + spec: + displayName: Update Conflict Secret 1 + description: First secret for update conflict test + type: default + value: first-value + """ + Then the response status code should be 201 + + Given I authenticate using basic auth as "admin" + When I create this secret: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: Secret + metadata: + name: update-conflict-secret-2 + spec: + displayName: Update Conflict Secret 2 + description: Second secret for update conflict test + type: default + value: second-value + """ + Then the response status code should be 201 + + Given I authenticate using basic auth as "admin" + When I update the secret "update-conflict-secret-1" with: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: Secret + metadata: + name: update-conflict-secret-2 + spec: + displayName: Update Conflict Secret 1 + description: Attempting to update secret with duplicate name + type: default + value: updated-value + """ + Then the response status code should be 409 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "already exists" diff --git a/gateway/it/steps_secrets.go b/gateway/it/steps_secrets.go index 3dd44cc94..5a6ab9f85 100644 --- a/gateway/it/steps_secrets.go +++ b/gateway/it/steps_secrets.go @@ -54,6 +54,30 @@ spec: return httpSteps.SendPOSTToService("gateway-controller", "/secrets", body) }) + // Create secret with value of given size (bytes) + ctx.Step(`^I create a secret with value size (\d+)$`, func(size int) error { + // Generate value of requested size + value := make([]byte, size) + for i := range value { + value[i] = 'x' + } + + body := &godog.DocString{ + Content: `apiVersion: gateway.api-platform.wso2.com/v1alpha1 +kind: Secret +metadata: + name: size-test-secret +spec: + displayName: Size Test Secret + description: Secret with configurable value size + type: default + value: ` + string(value), + } + + httpSteps.SetHeader("Content-Type", "application/yaml") + return httpSteps.SendPOSTToService("gateway-controller", "/secrets", body) + }) + // Retrieve secret step ctx.Step(`^I retrieve the secret "([^"]*)"$`, func(secretID string) error { return httpSteps.SendGETToService("gateway-controller", "/secrets/"+secretID) From d3f651e6c26c1339d0c97ad7496e58c3c0a757b1 Mon Sep 17 00:00:00 2001 From: Nimsara Fernando Date: Fri, 23 Jan 2026 06:15:54 +0530 Subject: [PATCH 3/6] Fix minor typo --- gateway/gateway-controller/api/openapi.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gateway/gateway-controller/api/openapi.yaml b/gateway/gateway-controller/api/openapi.yaml index d79b405fe..3573e4e52 100644 --- a/gateway/gateway-controller/api/openapi.yaml +++ b/gateway/gateway-controller/api/openapi.yaml @@ -3644,7 +3644,7 @@ components: type: string description: Secret value (stored encrypted) minLength: 1 - maxLength: 8192 + maxLength: 10240 format: password example: sk_xxx From 574fb30d6871708131b0546b7986cd523983c95f Mon Sep 17 00:00:00 2001 From: Nimsara Fernando Date: Fri, 23 Jan 2026 07:17:40 +0530 Subject: [PATCH 4/6] Remove unused secret schemas in openapi --- gateway/gateway-controller/api/openapi.yaml | 45 +-------------------- 1 file changed, 1 insertion(+), 44 deletions(-) diff --git a/gateway/gateway-controller/api/openapi.yaml b/gateway/gateway-controller/api/openapi.yaml index 3573e4e52..1764759f8 100644 --- a/gateway/gateway-controller/api/openapi.yaml +++ b/gateway/gateway-controller/api/openapi.yaml @@ -1704,7 +1704,7 @@ paths: - Secrets Management summary: List all secrets description: | - Retrieve a list of all stored secrets. Returns secret metadata without + Retrieve a list of all stored secrets. Returns secret identifiers without the actual secret values for security purposes. operationId: listSecrets responses: @@ -3741,49 +3741,6 @@ components: type: string example: success - SecretCreateRequest: - type: object - required: - - id - - value - properties: - id: - type: string - description: | - Unique identifier for the secret. Must be unique across all secrets. - Recommended format: alphanumeric with hyphens/underscores. - minLength: 1 - maxLength: 255 - example: "database-password" - value: - type: string - description: | - The secret value to encrypt and store. Will be encrypted at rest - using the primary encryption provider. Maximum size: 10KB. - minLength: 1 - maxLength: 10240 - example: "sup3rs3cr3t!" - example: - id: "api-key-production" - value: "sk-abcd1234efgh5678" - - SecretUpdateRequest: - type: object - required: - - value - properties: - value: - type: string - description: | - New secret value to encrypt and store. The secret will be re-encrypted - using the current primary encryption provider, enabling automatic key - rotation. Maximum size: 10KB. - minLength: 1 - maxLength: 10240 - example: "n3w_s3cr3t_v@lu3!" - example: - value: "updated-secret-value" - SecretResponse: type: object required: From 9aa4bb2a8400db580c8d98f9a91faa618058c47a Mon Sep 17 00:00:00 2001 From: Nimsara Fernando Date: Wed, 28 Jan 2026 14:06:21 +0530 Subject: [PATCH 5/6] Update policy manifest --- cli/src/internal/mcp/generator.go | 2 +- .../pkg/api/generated/generated.go | 215 +++++++++--------- gateway/policies/policy-manifest.yaml | 2 +- 3 files changed, 109 insertions(+), 110 deletions(-) diff --git a/cli/src/internal/mcp/generator.go b/cli/src/internal/mcp/generator.go index 76ba3d3a0..7304425b1 100644 --- a/cli/src/internal/mcp/generator.go +++ b/cli/src/internal/mcp/generator.go @@ -421,7 +421,7 @@ func generateMCPConfigFile(url string, toolsResult ToolsResult, } mcp := gwmodels.MCPProxyConfiguration{ - ApiVersion: gwmodels.GatewayApiPlatformWso2Comv1alpha1, + ApiVersion: gwmodels.MCPProxyConfigurationApiVersionGatewayApiPlatformWso2Comv1alpha1, Kind: gwmodels.Mcp, Metadata: gwmodels.Metadata{ Name: "Generated-MCP-v1.0", diff --git a/gateway/gateway-controller/pkg/api/generated/generated.go b/gateway/gateway-controller/pkg/api/generated/generated.go index 066b2352e..608a6dff8 100644 --- a/gateway/gateway-controller/pkg/api/generated/generated.go +++ b/gateway/gateway-controller/pkg/api/generated/generated.go @@ -1229,7 +1229,7 @@ type SecretConfigData struct { // Type Secret category Type SecretConfigDataType `json:"type" yaml:"type"` - // Value Secret value (stored securely and never returned in API responses) + // Value Secret value (stored encrypted) Value string `json:"value" yaml:"value"` } @@ -3213,113 +3213,112 @@ var swaggerSpec = []string{ "1VlH6VTHkNMUMDXxb/VhcM1JYuo5KpdSbgXdCep0Zza+/+FWolciVwfLqj6zOBVEWZdWKHBKc4ysm4+0", "Pn4eeWl4wgUKT9ZLNiQJUNwAA3NOVt0ia6F+xb2Sy5H3INum25kE9IY3bJ8FOfxt9vjmfIF5/bXWbJB3", "AkpKqb+askfVWiKFnHvtbZJ2WaRcJojKgGa5oLvOrE0uHjhDHlNpw2oP5ZY9TeaqxdoMROk1jf2TIfio", - "CvIUzs+2d+54fKf7f/Bw/vwYK8NaUyy/cDrxegmBNGGmlOVr99m7HSWsSv/oQpVUEsO6DrQFscEFZciX", - "0xwzFCRqWgi6Vvm4RcyIPihRF0WMVOHFmeJXl/P5PI+YRJDzG8r84qT9uPVue4HWazy+MHCGHtQi3l/p", - "2MJMzHM4szCkpmBIKVhbPy6ScGY38oOdX1SE0bpOL3TDzZpOy40GD8y8ADakZ0hjsyG0UaF2AxZJwavK", - "sbz6VNec7bTQDPdYH84Oszmb8W3tJOYnMG2heO6jK3kMtnoDFW+ZIu7Yl3udUb/nj3u5PZ8HWuu+NaKp", - "EyY9PYKsgdvm/OoLUwMbhXEDeYpab1ycHziKk6Rkre/8ynSey6larOUm4BhylJ+tBTj1MsMNIBcpet48", - "5iWB7lpdcog8lihHghe0ShRArM6sSroijt4w/sZjb8SfFpo7qi6ATcRbqCGbmyKXeLjIBWaUVsk8SaMn", - "ioW8Njgm0wABAdkUCUClDpwghoinHGxKkAnyKOLAQefrbbf4o5SKX2+/lll5RqXIumHYZie0lzVhrGoD", - "lypmaGiEgxm9UWv9M+XCJi/G3CCAvuYFE1xhM+zaI/4++E22/RvwUYCmqiaUisxgigrzwRG5pkkX3Myw", - "NzNPEK/0GHPrSNrGgRfEXCCmmuyD30JIYhj8BnzMpe3Ggew6hNJ7yvozZXaRJ7j8r3Q0SoneTbCFTc2r", - "p0a37TTElQqq1ik1KycHCEHEkHLVkJ9Sf5h33RzHBo6SK4eYIU+k3HNx+km2rm7QAsHgZIK9cpHnmRDR", - "3uamkpPmu73dwWCwCSO8eb1dKIXBcDtHqBAeVDVxnL+aNNJ73+rt0HTG09K2YwRZ4UacQyA0b2L1tF05", - "ynKW88oQJhgFDtH7k/xZl6eflBOtF6VPhLy+PUtfpuZXySXRedSd9b5MOGPlLrXieg8SQgWQnKN/bbfa", - "v6DxjNKr/ZNhTZilLlzTYOrYN0AP7KtCQFmd7o2zi/f6mPcXND6Lx6pGUOtKFKY0kTqUJEP9yZajJEZd", - "IOjab6Rn9TyXuZJeeyP9zvfR5XAe/CZ6oZToY0SzVo8n0mMOKYyDIF8tPyaBqqenzzl8RO4e67o0mLd/", - "MlzLnW/HnByoIH9wnQuL5JsmkDFfzbhalgu6aiI7mjOtqcakmok9XaxZ66PK3exi1GINA/RHo17N8nNI", - "/DGdL02a+c5Jl3nWuzt95WMsOYlO9dMmTjLTFamMdbSlzt0n1AZqQV1tUB8baBxJSoET4/WDcx0AXDZX", - "js7O1XtyqkJI4NSWWC8G8tqo1Wq7HwzEMCIjcj5D9m9grMgAMYP685pm//f+8Sdp8yrMXBslWtIkBIbY", - "g0GQjIj9zNiHCpJlYENZkDqK8hW4xhDMD88kMwrq0SAX8DuJgwAcnF4cggBPkJd4ARoRW2erRJKS+QzB", - "QDklxrFJS0jontVoX7/+iBLwE4JC0rX3+vWI9MBZPA6xaDFU+fJp2kuu7okyVJWqZ0hSj8lUvvtvxGjP", - "pzdEve+qBMzlayeSi7jQNfkog1OkB3T2j09YIPnGP2LEkqayVLoUog5k6DiWU0uNVNh19JnDbVenLopw", - "Z6/zpj/ov+nkqkVs2gJZUyRcRrNgGF0jALMrS82ls/Sg0qixETlVqCEHY8ixly9uYso7IejNVDsbcod0", - "rSTu2qsgXZBd4lYF503sdaoxhr7RNMZSyR+U/Vq1DQPJn+NEdVl7AeQXrTLNjEq5q6ut2ACfvZKI4GkA", - "S0X8NFPQEGDv6jV7feUezbRuVm/YZLaSq+tM9q3UdW4R01o/uTI1aSSmq+v0g6znliGcZQK/Zon+FNNv", - "DwZpPK1G15RdoqHezf9wbTNk3bpry7WyTvLF6F1V4ioHcc4iZHc8M7ut6Jmciea4IrK75Pw0ZjwsVMty", - "kDIkUrPDwF5f0ZWjblXRXlUbzpJr/YJKATcBp3LTq6qXx1JnInUl8KsyUl2XU4xSgICgm2qTVrVURW0f", - "nM9Ksn5EMLfaAvldEBl572vzXDBIeKCQOAuwKJ2Y3pzSTWotNiJjNJX2YAriGCwhZx8qQYsJ2AUceZT4", - "3Ki+zGsGp3GQqr/U8fghtXU9Go4xMRVdC4Vx5Qd1rutvm7/pM6G85/rb5m+qEwECBCVDkRwoJN+WP2RB", - "TNbWkt/8pEzBMHONUsmfEuVRYlWnqdhrhsAdmkDfYNCC28QDvad+0oKNc3C3PmTpnCIu9pXXZI83Us+1", - "sxkhoc7KMjl1gsSZ/MWojcynUYrIhsWZ02V9Oqya2fwWITH0VTRZ6kv96o6cWiLGyVDlCFLKYpIsCtT5", - "V086afZkk/hQULnhZFN5B8oGMtwqhDMdkjkqz42pU3x+0fg4PVx3TUrhzeLcqfnfxOQaEUVuE036Xcrk", - "cBUQ+1xmupv6DhyJn1UrvNCyodM+kuP5V8+UDu+dmcjzzPHQglFPglWG5W/1r40fu7kiu0umG1OoaYp5", - "mh3T5zdwOkWsj+nm9bb6qthUwWdve1Z6222piKpVi2+7BYGQwDBor9cczRXcSbOwJbtja216VfZfvLXl", - "0K1V3ea8znTb7ew8rMpXGrN87FIpNvlKU/bu4SiTSxpgT4Beqmy1mlJKVKo0q0ZhwBD0E4DmmIunaTVp", - "/qgzc5oMp9uu9hA3v2H/VttPAXKV0jpFIZV+InGYURNGw0ZDyqAGXNCIj4hGJapmD+ZuuwcMSW8S4OlM", - "ACMKOTBhVGhE8vz9/6sJSF9iyEP4GoGdwQ74TAX4icbEd3mXh2rQBpRrci9tFGg8Doq17DNcUZp/ehLL", - "d4gdwfnKGzKemlEC6hS0KF2aXLL1ejyVLEYL7xPU3O6qMomek3u/XtlGNDpJUQJo5+H2dZUsaXFPJIs+", - "SRmj94hTADR7Zs3Aky7urDdzWaxQBmAanaW6HScACw6w3MVSsGBfh/3GJhpDsUV+X27ISYXg4mJ46MSV", - "PiCxfzJ8nwz9lbd+zmd7Fjt+ga1RStl1Z9up0l6rDSq/4S97csGe/IAcgLfaJP4CtCQWrmAZH+odrkwd", - "53mE1uca9gZ5QEQnIjGHflBQc4Bg9qkxAIxC5iaoRH9YJL6N9h+RVGIou022RoNSSyVbIOaovlcDrQzD", - "iDIBidh7/RoMJ+Wa6LyrWkgnp0i4rnXCAfQEvkYuWaPnd31Whh7Kw8mcZbCWZ+SprVV6lm7kt5Ixzvvv", - "T9xTexHKtUK5jRht7ZJtmtisNkd4QWCEj+pPfpTaJsaIsid6BhpQd8FUvibWl/LO/qFsKgKgH2LSBcx0", - "oKJPpPfm7iI1f5wHdx/lENYj9ljedLQkfAeG10dUvMu14DBHMcXLXlxsIKmcX6WJ074FMUD+0sdKHxCR", - "PJ5HXK5Q4t5t2mKami989aIHCRijEVGZ0sYJ8AKskoQKCvIYdGa9mIMq2RGe2KtXOftkRMz1RJxGXul+", - "ZW/KQ3qz3RsnsklIfBqasG1EPOrrCz8zNIc+8nAI5R4nPogYmuA5MgdAow6McHQ56ridKD04zcRr2uZ2", - "xuw2f9Bdfi+mzkeUmJnClBj8/u4WT02rDw1Rl8hoFiFyObMd8WL3fCey1mzYvDhcLGPd5s7mN33Y9hmG", - "aAEufU2vkA74v8Y05kGetRZI5i/Ek0JWtuB3R8RKGimeCQUBJVPE1Kk5N5GsLuHskoiaqrXKQ03mQ0vD", - "blPGDDu7KXUNFOn7BQ6CsnV+YsaYXEOvtTgzXPQizL4PYWbFCrFMfkcRtsmQlUrq1MVpVp6igtEzxdeI", - "tDEuCbopSL3v0MTMpua7MDLbi1VL4UOL1vuygNOFXLMN7G73oeG/pa1gRsWLDfw9qY3p6lawh5jQeShQ", - "S7hPFxQB55/OQP5j4MWMISKCBAQU+lnq89xLQEdqqeMZjoqfQ5ZL4n6NGJ4kmEzBz+fnJ2e5u8CUEKSu", - "HvE64O8gP6J73Hm5ftpCaIXJftKR0GaRveJcWk7KDb0ddnURSZ4wyJWbgawtUGUXHRad/TwiNoYXE3By", - "dGyuEfXB/kSoUs6yr667MWU02Pvi+rIRQ4ZfpXVwdngGNkzek0PMPXqNWALOELvGHnolv7YHJ4KCKOb6", - "HJCgmxEpjUVHY0eMzjGyYdSH+pIT0Gj93uvX4GAGyRRxIOAVAmgyQZ4AOAyRj6FAQQJs2hCGVLS0vRs/", - "Ta9hOQ785HByK3SXkOXcmDp7nZ783/ujD8PP4ODo9Hz40/Bg//xI/Toix8Ph4b/ODw72r36Z7t8M3+9P", - "h3/f//hpcPHhh/D0o/jP8f7gw8HZ7x/OhuM3h/84en9wc7F/fHQxP/hj/+/vp5//OSL9fn9EVGtHnw8d", - "PWQ2Rpj0NBP1PNg+RDM3J3qSHgm4ytHRGCyY4yfN009GZecoM3kFnuaZWF7oFDbEQkFWVo2bWkrUu1HH", - "KnNEkADB8HSKGIBAf2KvtxWUXRq8OMEB0jkNlfjRwmVEzg7PbJ5BFUUwiQPpICU0/p9rBELbF/QlTxSW", - "Q7ZnRGlBJHHgq7QSlCVGGH2mWgSpbhDxI4p1OS2RRFo2KruHIKSEIzdMyPV1TWTyyY1IQZymw9eDr8Gp", - "ShLqzmq6nKHPERuY7w5URP49ZYNXOZYudYbymiRL6qHlEUNVSetmWRS3t3bfvWuTeKlRmuTGXxYnT24P", - "p9vKbKZlDZLKPl4UcmwjDh0WEBgnYHhozQy7A2oMDXVzq7g1KlxXtCaYDnYutyZFxYg8ljWhp6NoTTRi", - "IMNDiyiU7CEz4Xk8YXd3gH7cGQx6aPvduLez5e/04F+23vZ2dt6+3d3d2RkMBs8hXrnlMNrFMOc52YYM", - "36uUWlJ4PI045jxBzyOCeSUDRIEQl34cRotd82JMs/bF1a3oFOJzXO7HxAtiH5Ppnrpp2XwL375ipFgl", - "N3H6AkNTzAViJVWmEiZoW0dzqGJsexFOZ5QomSLG9FF1EtA4nk4xmXZBSAkWlKl/yybG0LuKo6yCgjvk", - "2qSIlJN5n6hA2kvzRSBn8Lla6SfJxXEYpUxVpFmxWI6j9QobDp4hGOg8YXXMq9I4SO7Ur1rOqGXZysr+", - "rL47mCHvar1mpEuKaiITdy0QkzVRJRFbrahwZW1cW5YDS0VxjfREAE/ORLqJ6hYmCMK02HpPoDAKWgKA", - "hZQdpmi4zs2ctlIHzOmq5url87TH1qk1bPP1+TV0IuU7ptZYr6lQTcDw5s4JGLqdwnq1yhPhmPr6vBHL", - "ZHhwc8DTxjZraM52inzBThew87VC7gdnR66rDRlvq+yRfEQEvUIqu5Z3ZdNZhtRHAUBz+aMJ/tepH3J5", - "cRpyNaTjUHdNq6kZzlWP2aEl1+/E3KQrUmmMVJ55lNZVqU+V4GC4zv2c8Ll6utvZnrvFB4UIHSQsvo5d", - "w24vV7JbXslOd0j5XvYzu4vt5IPlxFu9ibDEnW03Pzbd264FINzi5E7BGClBbmxCZTfDzwB9SAlthy+4", - "F+XR7kkvQc5DYwxu0p7Lfek1CIH13aKuI6biozv2+VquSdcR8CT3+5KGwVrvTrdpv/Umfpz71M9x335A", - "okZdlu9Vt/NNWt60buOg1N4pvmedrMHue92j37Uzcq8yZ/GNYzdrvdw6/u5EV1uxcjcH5E7QJF8ERy4B", - "QxbGtgCKTAd5f+l+C+Q8bN7fQtf1CYCLQvt7zP/benlmlItKpnw9PyYRvXOZzGePh1m7q3fmd+ayIHRT", - "0uJ7y0ZcFAnPBqKuRaZbAdKHioNdyJADh84Em8ahdU0oCuSyM+iJEVHQmHEvuY6K7WanyFmctj1/4t38", - "zR8VLgPVUtqS3F1zK8ckTu23QZTvH0leZ4KYhmabDYCifeKundB5JFR6STTagtA6dNBEGLwg0osQ6XSr", - "f0eZQvN80UqyVUzBVSHoBuR5Mezc0rWt+rSl8eZu03G63dPavxeVDMWnnJ3TTfYK6PPTAJ2fHtb8HCHm", - "ljbKfQDKC3DkJfDj72HzttTf9wUaLwkWPwmM+JlBwwoRzlX+beUbNADColx0REEoNXDOYiz4ue2179KP", - "uDA4a70/0XkkBHlJ5PjpAsYvMmtlTHhZs3+OVw5NVZ/Wo7/m8XLY7zwBReT28XDfefI4oO88eUF8Fy7M", - "9wb32m24BNg7Tx4T6VUEPwec14ihklCcJ6tDvHqDOvDdedIO3LWvGqzO5PIwNwXzkG8F3l0CzZ0n9wrl", - "zpP121/VNusUdXkJng6CO09aw7dyEC/Y7YrY7Tz5DoHbebJYUpVst+UB23myMlo7T+7qgaoWSu6nTz3e", - "g5xjLqC6VfUsYNoK1UuhtEr+Py5EW0fCI/le8+S5gbNtduvaYVnVaQ0mO0/WAcg+ky3aRhXfAxLraLRx", - "gz0qBvvk91QOgJ0n0sWLy8zZynRfAwLr2Fd5+PV5ab7vzOgvIa5l4/8R4NZ50hprnScvQOvzE0z1KGtb", - "Gz30olXx1dQTPD44Mc0Xs4Vo1yd3N9kmexhDjj2AiXZ/lSc0prEACHqzXGsbumy78ZbS+u3dPAQocIhe", - "1aUbOD44WRrhld0XEN5qaO9xkhF5f/huRsjD4rtZv/X4LrpGLBGzeqD1u8B47xtl3XWhrKEXXS6LtBo+", - "fxyktW73P23YtZbqTGpmryyf96GmeZvhtq5gdeFllT8uLXTZBZHc11z9ExIfCAYJD2zqOJ0dbn54Bhji", - "qrw+z9fAHpExmmLCAaOxowQ2yhFcqYRZi+BatrsnBNc2v05jrq7NB03tkBKxEIKtY6OXjA5tMdgiX38n", - "OGwNWyyWXSWLbwlUto4T11eDv0ECPVQl/pxAu9Ml1mwotUX5MwuqJxfkmdTld1PdDlKu46BHA5iXIuih", - "PdA64p4L+LyyiFofFJ11cB/l+62suFNyirK8eC5SYoF1s1ZA29XeEnv5cZDt57l9PyBRq+jLWSjqvaOW", - "mSdqOnqp9H/XSv/3YsW4i/7fq3z67j3KwdoHthjur9veL2k5vkN53l7qtnMdbVBf2yphpWpg1XThmfvo", - "5VMAnlRfhAzZZtQ3usqJiUm0dGUFTgAUUoanKZNVsYM4UjpEFWdQ8YwhCnUxFOfpwYkd7T1uXD3StsXD", - "qhP4tEHWXBZ4B+kZy5n1rvAbV1W4lj6YMgtsvu4De+ykf0gPpGwtDF2AGXoiVsNS76giorpcP0dezLBI", - "GpPLyyGfGWrvkV10F23ZxUwAYGaaXNJ96+FY54LAWMwow38gH/RKUcMgVZZPmqN5usaWd+0vDQcDkh25", - "ge4MfyHisSRSVq2uxaLNXvN0eFjKzmyMXF3aVhVIsJ/H3JZwiRiW1Npnck6zC4NoQhlKTxCIh+pRfc1j", - "94Tp68bXaX+5W1wTnm8sXX1+pzF3RVnKGGfphtKYvFalap1y31yqQgDbg+23vcFWb7B7vjXYezPYGwz+", - "La1lXxcJgGPIUS+CnN9QJs1lY5I1fmx66vA4esP4G4+9EX/Sw19m9pq2h6mE+CROHN5D3/pboAdCzBXz", - "UwawMRwnGAU+f8KS7bGOQYxgMc4s5lLEPMVTD9DLyy9zNN9wEsKtsKqK45wBsfCc4wSxEBJtrOp3pLw2", - "k5baqHaTZnX5IJhBZkrhaahgRAgFDJmqoSHyZpBgHmrxnopb+S32URhROdGgZ0qJkakcFiU9tSSIiBEx", - "NDBjwuwMduqPL3KSu2p/OHe1C+oGG4QCwwKvnvRW2llSghMqetpZK8pwMxcUceXPqckvSHHKVKEFTMml", - "ktYM/d4be37vx3eDTrejDYY92fylbj53AmJbzzmK7aXzwtmqNv5ENrHdKmoLxwzVlcRq2L8LDgJsEUol", - "LLLdWrCjUnvJvJa3l0bEZSh5M4iJNZfGSL6rtx7y+2Co3Ys0C5EaHBB0REz7SkrovrsAgt3BwEwI5mkz", - "WgZDoAp2Yw8YTqk5ZWjc0kuwvj0oqzNejIsAg+/Tesk8oBzTPB9/6EGhrqcrU1CzYZBDAxrFShtQ3AiQ", - "QoB/wRlQxwPz4n76DEPEI+ghHwwPc+wdMer3/XE/hJj00y0iace2ynZuU6rfig1U95cc+Vpg9obTGl4A", - "DvMWpLa9FHVa3qZ/FpzTEcm8U1s5rsFL7QJE4DgwN4p11VQQ4qmNhhNU9oMYuEIJB37MdLpJRWoffAn8", - "HOAhpxpI8xaOAwSuMTQucl7M15+g/Le4wMtqESPTa7VImrD9nlXI1t7ObkmFkDc3l9H/4vxmwPz70SFP", - "4oRkoQes5+NFlT1tVbbIx7UHN43+rfxCNevSY5+oBwPgo2sU0EhB6t1OzILOXmcmRLS3uRnIF2aUi713", - "g3eDTjUa/5B6V4htfozHiBHlEWdR+eXGTFKIXnbiYlr9mlJe8UB1GVhT81Ofy6i6n2ne4EytmbqVVRoP", - "Ti8OM69a4/XVqrVZQ/JZ7pShXYMNoeKmWeeRWbXxU3Uckt3vYyjmSj85D0dM29WzkWrD+qGtV37+6axQ", - "dFcN4ufz85OzLDnzNWL6sQYsTV81pYjbTZM7pX/dlC0sALBCp636WrWLhuV3Xq+6/Xr7/wIAAP//Pg9Z", - "k11OAQA=", + "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/gateway/policies/policy-manifest.yaml b/gateway/policies/policy-manifest.yaml index aa3a91936..4974b1f57 100644 --- a/gateway/policies/policy-manifest.yaml +++ b/gateway/policies/policy-manifest.yaml @@ -35,7 +35,7 @@ policies: - name: model-weighted-round-robin gomodule: github.com/wso2/gateway-controllers/policies/model-weighted-round-robin@v0.1.0 - name: modify-headers - gomodule: github.com/wso2/gateway-controllers/policies/modify-headers@v0.1.0 + gomodule: github.com/wso2/gateway-controllers/policies/modify-headers@v0.1.1 - name: pii-masking-regex gomodule: github.com/wso2/gateway-controllers/policies/pii-masking-regex@v0.1.0 - name: prompt-decorator From 1dba118f2a49a97ef6dee410f39e87b716ff76db Mon Sep 17 00:00:00 2001 From: Nimsara Fernando Date: Wed, 28 Jan 2026 14:33:27 +0530 Subject: [PATCH 6/6] Add missing cleanup in secrets test --- gateway/it/features/secrets.feature | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/gateway/it/features/secrets.feature b/gateway/it/features/secrets.feature index 9c9acbb83..29cb3ff3b 100644 --- a/gateway/it/features/secrets.feature +++ b/gateway/it/features/secrets.feature @@ -587,3 +587,11 @@ Feature: Secrets Management And the response should be valid JSON And the JSON response field "status" should be "error" And the response body should contain "already exists" + + Given I authenticate using basic auth as "admin" + When I delete the secret "update-conflict-secret-1" + Then the response status code should be 200 + + Given I authenticate using basic auth as "admin" + When I delete the secret "update-conflict-secret-2" + Then the response status code should be 200