From e1c3fe2cb9ccbafd5cb65fb5f85e60613d034397 Mon Sep 17 00:00:00 2001 From: Tharindu Dharmarathna Date: Thu, 11 Jun 2026 19:04:48 +0530 Subject: [PATCH 1/2] add platform api changes for event gateway hmac secret generation --- .../gateway-controller/cmd/controller/main.go | 2 + .../pkg/controlplane/client.go | 151 +++++- .../controlplane/client_integration_test.go | 2 +- .../pkg/controlplane/controlplane_test.go | 2 +- .../gateway-controller/pkg/utils/api_utils.go | 64 +++ platform-api/src/api/generated.go | 437 ++++++++++++++---- platform-api/src/api/manual_types.go | 84 ++++ platform-api/src/api/websub_hmac_secret.go | 24 + platform-api/src/config/config.go | 226 ++++++--- platform-api/src/internal/constants/error.go | 7 + .../src/internal/database/schema.postgres.sql | 15 + platform-api/src/internal/database/schema.sql | 15 + .../src/internal/database/schema.sqlite.sql | 15 + .../src/internal/dto/gateway_internal.go | 13 + .../src/internal/handler/gateway_internal.go | 48 +- .../handler/websub_api_hmac_secret.go | 260 +++++++++++ .../internal/model/websub_api_hmac_secret.go | 43 ++ .../src/internal/repository/interfaces.go | 9 + .../repository/websub_api_hmac_secret.go | 139 ++++++ platform-api/src/internal/server/server.go | 25 +- .../src/internal/service/gateway_events.go | 21 + .../service/websub_api_hmac_secret.go | 300 ++++++++++++ .../src/resources/gateway-internal-api.yaml | 78 ++++ platform-api/src/resources/openapi.yaml | 245 ++++++++++ 24 files changed, 2038 insertions(+), 187 deletions(-) create mode 100644 platform-api/src/api/manual_types.go create mode 100644 platform-api/src/api/websub_hmac_secret.go create mode 100644 platform-api/src/internal/handler/websub_api_hmac_secret.go create mode 100644 platform-api/src/internal/model/websub_api_hmac_secret.go create mode 100644 platform-api/src/internal/repository/websub_api_hmac_secret.go create mode 100644 platform-api/src/internal/service/websub_api_hmac_secret.go diff --git a/gateway/gateway-controller/cmd/controller/main.go b/gateway/gateway-controller/cmd/controller/main.go index 199b766025..43b0fc6dc1 100644 --- a/gateway/gateway-controller/cmd/controller/main.go +++ b/gateway/gateway-controller/cmd/controller/main.go @@ -501,6 +501,8 @@ func main() { subscriptionSnapshotManager, eventHubInstance, secretsService, + webhookSecretStore, + webhookSecretSnapshotManager, ) if err := cpClient.Start(); err != nil { log.Error("Failed to start control plane client", slog.Any("error", err)) diff --git a/gateway/gateway-controller/pkg/controlplane/client.go b/gateway/gateway-controller/pkg/controlplane/client.go index e7cb962189..20de40d02b 100644 --- a/gateway/gateway-controller/pkg/controlplane/client.go +++ b/gateway/gateway-controller/pkg/controlplane/client.go @@ -40,12 +40,14 @@ import ( "github.com/gorilla/websocket" "github.com/wso2/api-platform/common/eventhub" + "github.com/wso2/api-platform/common/webhooksecret" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/lazyresourcexds" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/policyxds" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/version" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/webhooksecretxds" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/xds" ) @@ -134,6 +136,8 @@ type Client struct { gatewayPath string // cached gateway path from well-known discovery syncOnce sync.Once // ensures deployment sync runs only on first connect isFirstConnect atomic.Bool // true on first connect, flipped to false after + webhookSecretStore *webhooksecret.WebhookSecretStore + webhookSecretSnapshotManager *webhooksecretxds.SnapshotManager } // NewClient creates a new control plane client @@ -156,6 +160,8 @@ func NewClient( subSnapshotManager utils.SubscriptionSnapshotUpdater, eventHubInstance eventhub.EventHub, secretResolver funcs.SecretResolver, + webhookSecretStore *webhooksecret.WebhookSecretStore, + webhookSecretSnapshotManager *webhooksecretxds.SnapshotManager, ) *Client { if db == nil { panic("control plane client requires non-nil storage") @@ -178,25 +184,27 @@ func NewClient( subscriptionResourceService := utils.NewSubscriptionResourceService(db, subSnapshotManager, eventHubInstance, gatewayID) client := &Client{ - config: cfg, - logger: logger, - store: store, - db: db, - snapshotManager: snapshotManager, - parser: config.NewParser(), - validator: validator, - deploymentService: deploymentService, - apiKeyService: apiKeyService, - apiKeyXDSManager: apiKeyXDSManager, - apiKeyStore: apiKeyStore, - routerConfig: routerConfig, - policyManager: policyManager, - systemConfig: systemConfig, - policyDefinitions: policyDefinitions, - subscriptionSnapshotUpdater: subSnapshotManager, - subscriptionResourceService: subscriptionResourceService, - eventHub: eventHubInstance, - gatewayID: gatewayID, + config: cfg, + logger: logger, + store: store, + db: db, + snapshotManager: snapshotManager, + parser: config.NewParser(), + validator: validator, + deploymentService: deploymentService, + apiKeyService: apiKeyService, + apiKeyXDSManager: apiKeyXDSManager, + apiKeyStore: apiKeyStore, + routerConfig: routerConfig, + policyManager: policyManager, + systemConfig: systemConfig, + policyDefinitions: policyDefinitions, + subscriptionSnapshotUpdater: subSnapshotManager, + subscriptionResourceService: subscriptionResourceService, + eventHub: eventHubInstance, + gatewayID: gatewayID, + webhookSecretStore: webhookSecretStore, + webhookSecretSnapshotManager: webhookSecretSnapshotManager, state: &ConnectionState{ Current: Disconnected, Conn: nil, @@ -1301,6 +1309,8 @@ func (c *Client) handleMessage(messageType int, message []byte) { c.handleWebSubAPIUndeployedEvent(event) case "websub.deleted": c.handleWebSubAPIDeletedEvent(event) + case "websub.hmacsecret.created", "websub.hmacsecret.updated", "websub.hmacsecret.deleted": + c.handleWebSubAPIHmacSecretEvent(event) case "webbroker.deployed": c.handleWebBrokerAPIDeployedEvent(event) case "webbroker.undeployed": @@ -2470,6 +2480,76 @@ func (c *Client) handleLLMProxyDeletedEvent(event map[string]interface{}) { ) } +// syncHmacSecretsForArtifact fetches all platform-managed HMAC secrets for a WebSub API +// artifact from platform-API and loads them into the in-memory webhook secret store. +// It replaces any previously loaded secrets for this artifact atomically (clear then re-add). +func (c *Client) syncHmacSecretsForArtifact(artifactID string) { + if c.webhookSecretStore == nil { + return + } + + secrets, err := c.apiUtilsService.FetchWebSubAPIHmacSecrets(artifactID) + if err != nil { + c.logger.Warn("Failed to fetch platform HMAC secrets for WebSub API", + slog.String("artifact_id", artifactID), + slog.Any("error", err)) + return + } + + if err := c.webhookSecretStore.RemoveAllByAPI(artifactID); err != nil { + c.logger.Warn("Failed to clear existing HMAC secrets for WebSub API", + slog.String("artifact_id", artifactID), + slog.Any("error", err)) + } + + for _, s := range secrets { + if err := c.webhookSecretStore.Store(artifactID, s.Name, s.Plaintext); err != nil { + c.logger.Warn("Failed to store platform HMAC secret in memory", + slog.String("artifact_id", artifactID), + slog.String("secret_name", s.Name), + slog.Any("error", err)) + } + } + + if c.webhookSecretSnapshotManager != nil { + if err := c.webhookSecretSnapshotManager.RefreshSnapshot(); err != nil { + c.logger.Warn("Failed to refresh webhook secret xDS snapshot after platform sync", + slog.String("artifact_id", artifactID), + slog.Any("error", err)) + } + } + + c.logger.Info("Loaded platform HMAC secrets for WebSub API", + slog.String("artifact_id", artifactID), + slog.Int("count", len(secrets))) +} + +// cleanupHmacSecretsForArtifact removes all in-memory HMAC secrets for an artifact and +// refreshes the xDS snapshot. Called on WebSub API deletion (found and not-found paths). +func (c *Client) cleanupHmacSecretsForArtifact(artifactID string) { + if c.webhookSecretStore == nil { + return + } + if err := c.webhookSecretStore.RemoveAllByAPI(artifactID); err != nil { + c.logger.Warn("Failed to remove HMAC secrets from store during WebSub API cleanup", + slog.String("artifact_id", artifactID), + slog.Any("error", err)) + } + if c.webhookSecretSnapshotManager != nil { + if err := c.webhookSecretSnapshotManager.RefreshSnapshot(); err != nil { + c.logger.Warn("Failed to refresh webhook secret xDS snapshot after WebSub API cleanup", + slog.String("artifact_id", artifactID), + slog.Any("error", err)) + } + } +} + +// platformHmacSecretEventPayload is the payload for websub.hmacsecret.* events. +type platformHmacSecretEventPayload struct { + ArtifactUUID string `json:"artifactUuid"` + SecretName string `json:"secretName"` +} + func (c *Client) handleWebSubAPIDeployedEvent(event map[string]any) { c.logger.Debug("WebSub API Deployment Event", slog.Any("payload", event["payload"]), @@ -2551,6 +2631,11 @@ func (c *Client) handleWebSubAPIDeployedEvent(event map[string]any) { return } + // Load platform-managed HMAC secrets into the webhook secret store. + if result.StoredConfig != nil { + c.syncHmacSecretsForArtifact(result.StoredConfig.UUID) + } + c.sendDeploymentAck(deployedEvent.Payload.DeploymentID, apiID, "websub", "deploy", "success", deployedEvent.Payload.PerformedAt, "") @@ -2702,6 +2787,7 @@ func (c *Client) handleWebSubAPIDeletedEvent(event map[string]any) { c.logger.Warn("WebSub API configuration not found for deletion", slog.String("api_id", apiID), ) + c.cleanupHmacSecretsForArtifact(apiID) return } c.logger.Error("Failed to fetch WebSub API configuration for deletion", @@ -2713,6 +2799,7 @@ func (c *Client) handleWebSubAPIDeletedEvent(event map[string]any) { } c.performFullAPIDeletion(apiID, apiConfig, deletedEvent.CorrelationID) + c.cleanupHmacSecretsForArtifact(apiConfig.UUID) } func (c *Client) handleWebBrokerAPIDeployedEvent(event map[string]any) { @@ -4236,3 +4323,29 @@ func (c *Client) pushGatewayManifestOnConnect(gatewayID string) { slog.Int("policy_count", len(policies)), ) } + +// handleWebSubAPIHmacSecretEvent handles websub.hmacsecret.created/updated/deleted events +// from platform-API. It re-syncs all platform-managed HMAC secrets for the affected artifact. +func (c *Client) handleWebSubAPIHmacSecretEvent(event map[string]any) { + payloadRaw, _ := event["payload"] + payloadBytes, err := json.Marshal(payloadRaw) + if err != nil { + c.logger.Error("Failed to marshal HMAC secret event payload", slog.Any("error", err)) + return + } + var payload platformHmacSecretEventPayload + if err := json.Unmarshal(payloadBytes, &payload); err != nil { + c.logger.Error("Failed to parse HMAC secret event payload", slog.Any("error", err)) + return + } + if payload.ArtifactUUID == "" { + c.logger.Warn("HMAC secret event missing artifactUuid, skipping") + return + } + c.logger.Info("Processing platform HMAC secret event", + slog.Any("type", event["type"]), + slog.String("artifact_uuid", payload.ArtifactUUID), + slog.String("secret_name", payload.SecretName), + ) + c.syncHmacSecretsForArtifact(payload.ArtifactUUID) +} diff --git a/gateway/gateway-controller/pkg/controlplane/client_integration_test.go b/gateway/gateway-controller/pkg/controlplane/client_integration_test.go index 10111a3292..ca22826252 100644 --- a/gateway/gateway-controller/pkg/controlplane/client_integration_test.go +++ b/gateway/gateway-controller/pkg/controlplane/client_integration_test.go @@ -144,7 +144,7 @@ func createIntegrationTestClientWithConfig(t *testing.T, cfg config.ControlPlane APIKey: *apiKeyConfig, } - client := NewClient(cfg, logger, store, db, nil, nil, routerConfig, nil, nil, apiKeyConfig, nil, systemConfig, nil, nil, nil, nil, mockHub, nil) + client := NewClient(cfg, logger, store, db, nil, nil, routerConfig, nil, nil, apiKeyConfig, nil, systemConfig, nil, nil, nil, nil, mockHub, nil, nil, nil) require.NotNil(t, client.eventHub) require.Equal(t, "test-gateway", client.gatewayID) return client diff --git a/gateway/gateway-controller/pkg/controlplane/controlplane_test.go b/gateway/gateway-controller/pkg/controlplane/controlplane_test.go index 7205e4101a..2208842af8 100644 --- a/gateway/gateway-controller/pkg/controlplane/controlplane_test.go +++ b/gateway/gateway-controller/pkg/controlplane/controlplane_test.go @@ -222,7 +222,7 @@ func createTestClientWithConfig(t *testing.T, cfg config.ControlPlaneConfig) *Cl APIKey: *apiKeyConfig, } - return NewClient(cfg, logger, store, db, nil, nil, routerConfig, nil, nil, apiKeyConfig, nil, systemConfig, nil, nil, nil, nil, mockHub, nil) + return NewClient(cfg, logger, store, db, nil, nil, routerConfig, nil, nil, apiKeyConfig, nil, systemConfig, nil, nil, nil, nil, mockHub, nil, nil, nil) } func createTestClientWithHost(t *testing.T, host string) *Client { diff --git a/gateway/gateway-controller/pkg/utils/api_utils.go b/gateway/gateway-controller/pkg/utils/api_utils.go index 707ff50cda..a0afc79f2d 100644 --- a/gateway/gateway-controller/pkg/utils/api_utils.go +++ b/gateway/gateway-controller/pkg/utils/api_utils.go @@ -1036,6 +1036,70 @@ func MapToStruct(data map[string]interface{}, out interface{}) error { return nil } +// platformHmacSecretInfo is the per-secret DTO returned by the internal HMAC endpoint. +type platformHmacSecretInfo struct { + Name string `json:"name"` + Secret string `json:"secret"` +} + +// platformHmacSecretsResponse is the response body from GET /websub-apis/:id/hmac-secrets. +type platformHmacSecretsResponse struct { + ArtifactID string `json:"artifactId"` + Secrets []platformHmacSecretInfo `json:"secrets"` +} + +// HmacSecretInfo is the public view of a platform-managed HMAC secret. +type HmacSecretInfo struct { + Name string + Plaintext string +} + +// FetchWebSubAPIHmacSecrets fetches the plaintext HMAC secrets for a WebSub API artifact +// from the platform-API internal endpoint. +func (s *APIUtilsService) FetchWebSubAPIHmacSecrets(artifactID string) ([]HmacSecretInfo, error) { + secretsURL := s.getBaseURL() + "/websub-apis/" + artifactID + "/hmac-secrets" + + s.logger.Debug("Fetching WebSub API HMAC secrets", + slog.String("artifact_id", artifactID), + slog.String("url", secretsURL), + ) + + req, err := http.NewRequest("GET", secretsURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create HMAC secrets request: %w", err) + } + req.Header.Add("api-key", s.config.Token) + req.Header.Add("Accept", "application/json") + + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch HMAC secrets: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("HMAC secrets request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var response platformHmacSecretsResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode HMAC secrets response: %w", err) + } + + secrets := make([]HmacSecretInfo, 0, len(response.Secrets)) + for _, s := range response.Secrets { + secrets = append(secrets, HmacSecretInfo{Name: s.Name, Plaintext: s.Secret}) + } + + s.logger.Debug("Successfully fetched WebSub API HMAC secrets", + slog.String("artifact_id", artifactID), + slog.Int("count", len(secrets)), + ) + + return secrets, nil +} + // CheckArtifactsExist checks which artifact UUIDs still exist on the platform. // Returns the subset of provided UUIDs that exist. Used during sync to avoid // deleting artifacts that still exist but have no active deployment. diff --git a/platform-api/src/api/generated.go b/platform-api/src/api/generated.go index 2420211e64..f2e737b48f 100644 --- a/platform-api/src/api/generated.go +++ b/platform-api/src/api/generated.go @@ -13,7 +13,7 @@ import ( ) const ( - BearerAuthScopes = "BearerAuth.Scopes" + OAuth2SecurityScopes = "OAuth2Security.Scopes" ) // Defines values for APIKeyItemStatus. @@ -358,8 +358,8 @@ const ( // Defines values for UpdateDevPortalRequestVisibility. const ( - Private UpdateDevPortalRequestVisibility = "private" - Public UpdateDevPortalRequestVisibility = "public" + UpdateDevPortalRequestVisibilityPrivate UpdateDevPortalRequestVisibility = "private" + UpdateDevPortalRequestVisibilityPublic UpdateDevPortalRequestVisibility = "public" ) // Defines values for UpdateSubscriptionPlanRequestStatus. @@ -435,6 +435,12 @@ const ( WebBrokerAPITransportHttps WebBrokerAPITransport = "https" ) +// Defines values for WebBrokerAPIDevPortalResponseVisibility. +const ( + WebBrokerAPIDevPortalResponseVisibilityPrivate WebBrokerAPIDevPortalResponseVisibility = "private" + WebBrokerAPIDevPortalResponseVisibilityPublic WebBrokerAPIDevPortalResponseVisibility = "public" +) + // Defines values for WebBrokerAPIListItemLifeCycleStatus. const ( WebBrokerAPIListItemLifeCycleStatusCREATED WebBrokerAPIListItemLifeCycleStatus = "CREATED" @@ -443,6 +449,13 @@ const ( WebBrokerAPIListItemLifeCycleStatusRETIRED WebBrokerAPIListItemLifeCycleStatus = "RETIRED" ) +// Defines values for WebBrokerAPIPublicationDetailsStatus. +const ( + WebBrokerAPIPublicationDetailsStatusFailed WebBrokerAPIPublicationDetailsStatus = "failed" + WebBrokerAPIPublicationDetailsStatusPublished WebBrokerAPIPublicationDetailsStatus = "published" + WebBrokerAPIPublicationDetailsStatusPublishing WebBrokerAPIPublicationDetailsStatus = "publishing" +) + // Defines values for WebSubAPILifeCycleStatus. const ( WebSubAPILifeCycleStatusCREATED WebSubAPILifeCycleStatus = "CREATED" @@ -457,6 +470,12 @@ const ( WebSubAPITransportHttps WebSubAPITransport = "https" ) +// Defines values for WebSubAPIDevPortalResponseVisibility. +const ( + Private WebSubAPIDevPortalResponseVisibility = "private" + Public WebSubAPIDevPortalResponseVisibility = "public" +) + // Defines values for WebSubAPIListItemLifeCycleStatus. const ( WebSubAPIListItemLifeCycleStatusCREATED WebSubAPIListItemLifeCycleStatus = "CREATED" @@ -465,6 +484,13 @@ const ( WebSubAPIListItemLifeCycleStatusRETIRED WebSubAPIListItemLifeCycleStatus = "RETIRED" ) +// Defines values for WebSubAPIPublicationDetailsStatus. +const ( + WebSubAPIPublicationDetailsStatusFailed WebSubAPIPublicationDetailsStatus = "failed" + WebSubAPIPublicationDetailsStatusPublished WebSubAPIPublicationDetailsStatus = "published" + WebSubAPIPublicationDetailsStatusPublishing WebSubAPIPublicationDetailsStatus = "publishing" +) + // Defines values for ArtifactTypeQ. const ( ArtifactTypeQAPIPRODUCT ArtifactTypeQ = "API_PRODUCT" @@ -1368,29 +1394,6 @@ type GatewayResponse struct { // GatewayResponseFunctionalityType Type of gateway functionality type GatewayResponseFunctionalityType string -// GatewayStatusListResponse List of gateway status information for polling -type GatewayStatusListResponse struct { - // Count Number of items in current response - Count int `binding:"required" json:"count" yaml:"count"` - List []GatewayStatusResponse `binding:"required" json:"list" yaml:"list"` - Pagination Pagination `json:"pagination" yaml:"pagination"` -} - -// GatewayStatusResponse Lightweight gateway status information optimized for frequent polling -type GatewayStatusResponse struct { - // Id Unique identifier for the gateway - Id *openapi_types.UUID `json:"id,omitempty" yaml:"id,omitempty"` - - // IsActive Indicates if the gateway is currently connected to the platform via WebSocket - IsActive *bool `json:"isActive,omitempty" yaml:"isActive,omitempty"` - - // IsCritical Whether the gateway is critical for production - IsCritical *bool `json:"isCritical,omitempty" yaml:"isCritical,omitempty"` - - // Name URL-friendly gateway identifier - Name *string `json:"name,omitempty" yaml:"name,omitempty"` -} - // GitRepoBranch defines model for GitRepoBranch. type GitRepoBranch struct { // IsDefault Whether this branch is the default branch @@ -2613,21 +2616,6 @@ type RESTAPIPublicationDetails struct { // RESTAPIPublicationDetailsStatus Current publication status type RESTAPIPublicationDetailsStatus string -// RESTAPIValidationResponse defines model for RESTAPIValidationResponse. -type RESTAPIValidationResponse struct { - // Error Error details if validation fails - Error *struct { - // Code Error code indicating the type of validation failure - Code string `json:"code" yaml:"code"` - - // Message Human-readable error message - Message string `json:"message" yaml:"message"` - } `binding:"required" json:"error" yaml:"error"` - - // Valid Whether the API identifier or name-version combination is valid (not already in use) in the organization - Valid bool `binding:"required" json:"valid" yaml:"valid"` -} - // RateLimitResetWindow defines model for RateLimitResetWindow. type RateLimitResetWindow struct { // Duration Reset duration for the limit window. @@ -2823,12 +2811,6 @@ type TokenRotationResponse struct { Token *string `json:"token,omitempty" yaml:"token,omitempty"` } -// UnpublishFromDevPortalRequest defines model for UnpublishFromDevPortalRequest. -type UnpublishFromDevPortalRequest struct { - // DevPortalUuid UUID of the DevPortal to unpublish from - DevPortalUuid openapi_types.UUID `binding:"required" json:"devPortalUuid" yaml:"devPortalUuid"` -} - // UpdateAPIKeyRequest defines model for UpdateAPIKeyRequest. type UpdateAPIKeyRequest struct { // ApiKey The new plain text API key value that will be hashed before storage @@ -3159,6 +3141,74 @@ type WebBrokerAPIReceiverType string // WebBrokerAPITransport defines model for WebBrokerAPI.Transport. type WebBrokerAPITransport string +// WebBrokerAPIDevPortalListResponse defines model for WebBrokerAPIDevPortalListResponse. +type WebBrokerAPIDevPortalListResponse struct { + // Count Number of DevPortals in current response + Count int `binding:"required" json:"count" yaml:"count"` + List []WebBrokerAPIDevPortalResponse `binding:"required" json:"list" yaml:"list"` + Pagination Pagination `json:"pagination" yaml:"pagination"` +} + +// WebBrokerAPIDevPortalResponse defines model for WebBrokerAPIDevPortalResponse. +type WebBrokerAPIDevPortalResponse struct { + // ApiUrl API URL of the DevPortal + ApiUrl string `binding:"required" json:"apiUrl" yaml:"apiUrl"` + + // AssociatedAt Timestamp when the DevPortal was associated with the API + AssociatedAt time.Time `json:"associatedAt" yaml:"associatedAt"` + + // CreatedAt Timestamp when the DevPortal was created + CreatedAt time.Time `binding:"required" json:"createdAt" yaml:"createdAt"` + + // Description Description of the DevPortal + Description *string `json:"description,omitempty" yaml:"description,omitempty"` + + // HeaderKeyName Custom header name for API key + HeaderKeyName *string `json:"headerKeyName,omitempty" yaml:"headerKeyName,omitempty"` + + // Hostname Hostname of the DevPortal + Hostname string `binding:"required" json:"hostname" yaml:"hostname"` + + // Identifier Unique identifier for the DevPortal + Identifier string `binding:"required" json:"identifier" yaml:"identifier"` + + // IsActive Whether the DevPortal is currently active + IsActive bool `binding:"required" json:"isActive" yaml:"isActive"` + + // IsDefault Whether this is the default DevPortal for the organization + IsDefault bool `binding:"required" json:"isDefault" yaml:"isDefault"` + + // IsEnabled Whether the DevPortal is enabled + IsEnabled bool `binding:"required" json:"isEnabled" yaml:"isEnabled"` + + // IsPublished Whether the API is currently published to this DevPortal + IsPublished bool `json:"isPublished" yaml:"isPublished"` + + // Name Display name of the DevPortal + Name string `binding:"required" json:"name" yaml:"name"` + + // OrganizationUuid UUID of the organization this DevPortal belongs to + OrganizationUuid openapi_types.UUID `binding:"required" json:"organizationUuid" yaml:"organizationUuid"` + + // Publication Details about WebBroker API publication to a specific DevPortal + Publication *WebBrokerAPIPublicationDetails `json:"publication,omitempty" yaml:"publication,omitempty"` + + // UiUrl UI URL of the DevPortal + UiUrl string `binding:"required" json:"uiUrl" yaml:"uiUrl"` + + // UpdatedAt Timestamp when the DevPortal was last updated + UpdatedAt time.Time `binding:"required" json:"updatedAt" yaml:"updatedAt"` + + // Uuid Unique identifier for the DevPortal + Uuid openapi_types.UUID `binding:"required" json:"uuid" yaml:"uuid"` + + // Visibility Visibility of the DevPortal + Visibility WebBrokerAPIDevPortalResponseVisibility `binding:"required" json:"visibility" yaml:"visibility"` +} + +// WebBrokerAPIDevPortalResponseVisibility Visibility of the DevPortal +type WebBrokerAPIDevPortalResponseVisibility string + // WebBrokerAPIListItem defines model for WebBrokerAPIListItem. type WebBrokerAPIListItem struct { Context *string `json:"context,omitempty" yaml:"context,omitempty"` @@ -3181,6 +3231,27 @@ type WebBrokerAPIListResponse struct { Pagination Pagination `json:"pagination" yaml:"pagination"` } +// WebBrokerAPIPublicationDetails Details about WebBroker API publication to a specific DevPortal +type WebBrokerAPIPublicationDetails struct { + // ApiVersion Version of the API that was published + ApiVersion *string `json:"apiVersion,omitempty" yaml:"apiVersion,omitempty"` + + // DevPortalRefId Reference ID in the DevPortal + DevPortalRefId *string `json:"devPortalRefId,omitempty" yaml:"devPortalRefId,omitempty"` + + // PublishedAt Timestamp when the API was published + PublishedAt time.Time `binding:"required" json:"publishedAt" yaml:"publishedAt"` + + // Status Current publication status + Status WebBrokerAPIPublicationDetailsStatus `binding:"required" json:"status" yaml:"status"` + + // UpdatedAt Timestamp when the publication was last updated + UpdatedAt time.Time `binding:"required" json:"updatedAt" yaml:"updatedAt"` +} + +// WebBrokerAPIPublicationDetailsStatus Current publication status +type WebBrokerAPIPublicationDetailsStatus string + // WebBrokerAllChannelPolicies Policies applied to all channels, organized by event type. type WebBrokerAllChannelPolicies struct { // OnConnectionInit Policies for a single event type. @@ -3272,6 +3343,118 @@ type WebSubAPILifeCycleStatus string // WebSubAPITransport defines model for WebSubAPI.Transport. type WebSubAPITransport string +// WebSubAPIDevPortalListResponse defines model for WebSubAPIDevPortalListResponse. +type WebSubAPIDevPortalListResponse struct { + // Count Number of DevPortals in current response + Count int `binding:"required" json:"count" yaml:"count"` + List []WebSubAPIDevPortalResponse `binding:"required" json:"list" yaml:"list"` + Pagination Pagination `json:"pagination" yaml:"pagination"` +} + +// WebSubAPIDevPortalResponse defines model for WebSubAPIDevPortalResponse. +type WebSubAPIDevPortalResponse struct { + // ApiUrl API URL of the DevPortal + ApiUrl string `binding:"required" json:"apiUrl" yaml:"apiUrl"` + + // AssociatedAt Timestamp when the DevPortal was associated with the API + AssociatedAt time.Time `json:"associatedAt" yaml:"associatedAt"` + + // CreatedAt Timestamp when the DevPortal was created + CreatedAt time.Time `binding:"required" json:"createdAt" yaml:"createdAt"` + + // Description Description of the DevPortal + Description *string `json:"description,omitempty" yaml:"description,omitempty"` + + // HeaderKeyName Custom header name for API key + HeaderKeyName *string `json:"headerKeyName,omitempty" yaml:"headerKeyName,omitempty"` + + // Hostname Hostname of the DevPortal + Hostname string `binding:"required" json:"hostname" yaml:"hostname"` + + // Identifier Unique identifier for the DevPortal + Identifier string `binding:"required" json:"identifier" yaml:"identifier"` + + // IsActive Whether the DevPortal is currently active + IsActive bool `binding:"required" json:"isActive" yaml:"isActive"` + + // IsDefault Whether this is the default DevPortal for the organization + IsDefault bool `binding:"required" json:"isDefault" yaml:"isDefault"` + + // IsEnabled Whether the DevPortal is enabled + IsEnabled bool `binding:"required" json:"isEnabled" yaml:"isEnabled"` + + // IsPublished Whether the API is currently published to this DevPortal + IsPublished bool `json:"isPublished" yaml:"isPublished"` + + // Name Display name of the DevPortal + Name string `binding:"required" json:"name" yaml:"name"` + + // OrganizationUuid UUID of the organization this DevPortal belongs to + OrganizationUuid openapi_types.UUID `binding:"required" json:"organizationUuid" yaml:"organizationUuid"` + + // Publication Details about WebSub API publication to a specific DevPortal + Publication *WebSubAPIPublicationDetails `json:"publication,omitempty" yaml:"publication,omitempty"` + + // UiUrl UI URL of the DevPortal + UiUrl string `binding:"required" json:"uiUrl" yaml:"uiUrl"` + + // UpdatedAt Timestamp when the DevPortal was last updated + UpdatedAt time.Time `binding:"required" json:"updatedAt" yaml:"updatedAt"` + + // Uuid Unique identifier for the DevPortal + Uuid openapi_types.UUID `binding:"required" json:"uuid" yaml:"uuid"` + + // Visibility Visibility of the DevPortal + Visibility WebSubAPIDevPortalResponseVisibility `binding:"required" json:"visibility" yaml:"visibility"` +} + +// WebSubAPIDevPortalResponseVisibility Visibility of the DevPortal +type WebSubAPIDevPortalResponseVisibility string + +// WebSubAPIHmacSecretCreationResponse defines model for WebSubAPIHmacSecretCreationResponse. +type WebSubAPIHmacSecretCreationResponse struct { + // Message Human-readable confirmation message. + Message string `binding:"required" json:"message" yaml:"message"` + + // Secret The plaintext HMAC secret value. This is returned **once** at creation/regeneration time + // and is never stored unencrypted — save it immediately. + Secret string `binding:"required" json:"secret" yaml:"secret"` + WebhookSecret *WebSubAPIHmacSecretInfo `json:"webhookSecret,omitempty" yaml:"webhookSecret,omitempty"` +} + +// WebSubAPIHmacSecretInfo defines model for WebSubAPIHmacSecretInfo. +type WebSubAPIHmacSecretInfo struct { + CreatedAt time.Time `binding:"required" json:"createdAt" yaml:"createdAt"` + + // DisplayName Human-readable label. + DisplayName string `binding:"required" json:"displayName" yaml:"displayName"` + + // Name URL-safe slug derived from the display name. + Name string `binding:"required" json:"name" yaml:"name"` + + // Status Status of the HMAC secret. + Status string `binding:"required" json:"status" yaml:"status"` + UpdatedAt time.Time `binding:"required" json:"updatedAt" yaml:"updatedAt"` + + // Uuid Unique identifier for the HMAC secret. + Uuid string `binding:"required" json:"uuid" yaml:"uuid"` +} + +// WebSubAPIHmacSecretListResponse defines model for WebSubAPIHmacSecretListResponse. +type WebSubAPIHmacSecretListResponse struct { + Secrets []WebSubAPIHmacSecretInfo `binding:"required" json:"secrets" yaml:"secrets"` +} + +// WebSubAPIHmacSecretRequest defines model for WebSubAPIHmacSecretRequest. +type WebSubAPIHmacSecretRequest struct { + // DisplayName Human-readable label for the HMAC secret (used to derive the URL-safe name/slug). + DisplayName string `binding:"required" json:"displayName" yaml:"displayName"` + + // Secret Optional. If provided, this value is used as the HMAC secret instead of auto-generating one. + // Must be at least 32 characters long. + Secret *string `json:"secret,omitempty" yaml:"secret,omitempty"` +} + // WebSubAPIListItem defines model for WebSubAPIListItem. type WebSubAPIListItem struct { Context *string `json:"context,omitempty" yaml:"context,omitempty"` @@ -3294,6 +3477,27 @@ type WebSubAPIListResponse struct { Pagination Pagination `json:"pagination" yaml:"pagination"` } +// WebSubAPIPublicationDetails Details about WebSub API publication to a specific DevPortal +type WebSubAPIPublicationDetails struct { + // ApiVersion Version of the API that was published + ApiVersion *string `json:"apiVersion,omitempty" yaml:"apiVersion,omitempty"` + + // DevPortalRefId Reference ID in the DevPortal + DevPortalRefId *string `json:"devPortalRefId,omitempty" yaml:"devPortalRefId,omitempty"` + + // PublishedAt Timestamp when the API was published + PublishedAt time.Time `binding:"required" json:"publishedAt" yaml:"publishedAt"` + + // Status Current publication status + Status WebSubAPIPublicationDetailsStatus `binding:"required" json:"status" yaml:"status"` + + // UpdatedAt Timestamp when the publication was last updated + UpdatedAt time.Time `binding:"required" json:"updatedAt" yaml:"updatedAt"` +} + +// WebSubAPIPublicationDetailsStatus Current publication status +type WebSubAPIPublicationDetailsStatus string + // WebSubAllChannelPolicies Policies applied to all channels, organized by event type. type WebSubAllChannelPolicies struct { // OnMessageDelivery Policies for a single event type. @@ -3345,9 +3549,6 @@ type ProjectID = openapi_types.UUID // TokenID defines model for TokenID. type TokenID = openapi_types.UUID -// ApiIdentifierQ defines model for api-identifier-Q. -type ApiIdentifierQ = string - // ApiNameQ defines model for api-name-Q. type ApiNameQ = string @@ -3526,6 +3727,18 @@ type GetLLMProviderDeploymentsParams struct { // GetLLMProviderDeploymentsParamsStatus defines parameters for GetLLMProviderDeployments. type GetLLMProviderDeploymentsParamsStatus string +// RestoreLLMProviderDeploymentDeprecatedParams defines parameters for RestoreLLMProviderDeploymentDeprecated. +type RestoreLLMProviderDeploymentDeprecatedParams struct { + DeploymentId string `form:"deploymentId" json:"deploymentId" yaml:"deploymentId"` + GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` +} + +// UndeployLLMProviderDeploymentDeprecatedParams defines parameters for UndeployLLMProviderDeploymentDeprecated. +type UndeployLLMProviderDeploymentDeprecatedParams struct { + DeploymentId string `form:"deploymentId" json:"deploymentId" yaml:"deploymentId"` + GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` +} + // RestoreLLMProviderDeploymentParams defines parameters for RestoreLLMProviderDeployment. type RestoreLLMProviderDeploymentParams struct { // GatewayId UUID of the gateway (validated against deployment's bound gateway) @@ -3571,6 +3784,18 @@ type GetLLMProxyDeploymentsParams struct { // GetLLMProxyDeploymentsParamsStatus defines parameters for GetLLMProxyDeployments. type GetLLMProxyDeploymentsParamsStatus string +// RestoreLLMProxyDeploymentDeprecatedParams defines parameters for RestoreLLMProxyDeploymentDeprecated. +type RestoreLLMProxyDeploymentDeprecatedParams struct { + DeploymentId string `form:"deploymentId" json:"deploymentId" yaml:"deploymentId"` + GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` +} + +// UndeployLLMProxyDeploymentDeprecatedParams defines parameters for UndeployLLMProxyDeploymentDeprecated. +type UndeployLLMProxyDeploymentDeprecatedParams struct { + DeploymentId string `form:"deploymentId" json:"deploymentId" yaml:"deploymentId"` + GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` +} + // RestoreLLMProxyDeploymentParams defines parameters for RestoreLLMProxyDeployment. type RestoreLLMProxyDeploymentParams struct { // GatewayId UUID of the gateway (validated against deployment's bound gateway) @@ -3607,6 +3832,18 @@ type GetMCPProxyDeploymentsParams struct { // GetMCPProxyDeploymentsParamsStatus defines parameters for GetMCPProxyDeployments. type GetMCPProxyDeploymentsParamsStatus string +// RestoreMCPProxyDeploymentDeprecatedParams defines parameters for RestoreMCPProxyDeploymentDeprecated. +type RestoreMCPProxyDeploymentDeprecatedParams struct { + DeploymentId string `form:"deploymentId" json:"deploymentId" yaml:"deploymentId"` + GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` +} + +// UndeployMCPProxyDeploymentDeprecatedParams defines parameters for UndeployMCPProxyDeploymentDeprecated. +type UndeployMCPProxyDeploymentDeprecatedParams struct { + DeploymentId string `form:"deploymentId" json:"deploymentId" yaml:"deploymentId"` + GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` +} + // RestoreMCPProxyDeploymentParams defines parameters for RestoreMCPProxyDeployment. type RestoreMCPProxyDeploymentParams struct { // GatewayId UUID of the gateway (validated against deployment's bound gateway) @@ -3629,28 +3866,17 @@ type ListUserAPIKeysParams struct { // ListUserAPIKeysParamsType defines parameters for ListUserAPIKeys. type ListUserAPIKeysParamsType string -// ValidateRESTAPIParams defines parameters for ValidateRESTAPI. -// Kept for use in the ValidateAPI service method. -type ValidateRESTAPIParams struct { - // Identifier **API Identifier** to check for existence within the organization. - Identifier *ApiIdentifierQ `form:"identifier,omitempty" json:"identifier,omitempty" yaml:"identifier,omitempty"` - - // Name **API Name** to check for existence within the organization. - Name *ApiNameQ `form:"name,omitempty" json:"name,omitempty" yaml:"name,omitempty"` - - // Version **API Version** to check for existence within the organization. - Version *ApiVersionQ `form:"version,omitempty" json:"version,omitempty" yaml:"version,omitempty"` -} - // ListRESTAPIsParams defines parameters for ListRESTAPIs. type ListRESTAPIsParams struct { // ProjectId **Project ID** consisting of the **UUID** of the Project to filter APIs by. ProjectId ProjectIdQ `form:"projectId" json:"projectId" yaml:"projectId"` - // Name **API Name** to filter by. Provide together with Version to check name/version uniqueness. + // Name **API Name** to check for existence within the organization. + // Must be used together with 'version' parameter if 'identifier' is not provided. Name *ApiNameQ `form:"name,omitempty" json:"name,omitempty" yaml:"name,omitempty"` - // Version **API Version** to filter by. Provide together with Name to check name/version uniqueness. + // Version **API Version** to check for existence within the organization. + // Must be used together with 'name' parameter if 'identifier' is not provided. Version *ApiVersionQ `form:"version,omitempty" json:"version,omitempty" yaml:"version,omitempty"` } @@ -3666,6 +3892,18 @@ type GetDeploymentsParams struct { // GetDeploymentsParamsStatus defines parameters for GetDeployments. type GetDeploymentsParamsStatus string +// RestoreDeploymentDeprecatedParams defines parameters for RestoreDeploymentDeprecated. +type RestoreDeploymentDeprecatedParams struct { + DeploymentId string `form:"deploymentId" json:"deploymentId" yaml:"deploymentId"` + GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` +} + +// UndeployDeploymentDeprecatedParams defines parameters for UndeployDeploymentDeprecated. +type UndeployDeploymentDeprecatedParams struct { + DeploymentId string `form:"deploymentId" json:"deploymentId" yaml:"deploymentId"` + GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` +} + // RestoreDeploymentParams defines parameters for RestoreDeployment. type RestoreDeploymentParams struct { // GatewayId UUID of the gateway (validated against deployment's bound gateway) @@ -3736,6 +3974,18 @@ type GetWebBrokerAPIDeploymentsParams struct { Status *string `form:"status,omitempty" json:"status,omitempty" yaml:"status,omitempty"` } +// RestoreWebBrokerAPIDeploymentDeprecatedParams defines parameters for RestoreWebBrokerAPIDeploymentDeprecated. +type RestoreWebBrokerAPIDeploymentDeprecatedParams struct { + DeploymentId string `form:"deploymentId" json:"deploymentId" yaml:"deploymentId"` + GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` +} + +// UndeployWebBrokerAPIDeprecatedParams defines parameters for UndeployWebBrokerAPIDeprecated. +type UndeployWebBrokerAPIDeprecatedParams struct { + DeploymentId string `form:"deploymentId" json:"deploymentId" yaml:"deploymentId"` + GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` +} + // RestoreWebBrokerAPIDeploymentParams defines parameters for RestoreWebBrokerAPIDeployment. type RestoreWebBrokerAPIDeploymentParams struct { GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` @@ -3759,6 +4009,18 @@ type GetWebSubAPIDeploymentsParams struct { Status *string `form:"status,omitempty" json:"status,omitempty" yaml:"status,omitempty"` } +// RestoreWebSubAPIDeploymentDeprecatedParams defines parameters for RestoreWebSubAPIDeploymentDeprecated. +type RestoreWebSubAPIDeploymentDeprecatedParams struct { + DeploymentId string `form:"deploymentId" json:"deploymentId" yaml:"deploymentId"` + GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` +} + +// UndeployWebSubAPIDeprecatedParams defines parameters for UndeployWebSubAPIDeprecated. +type UndeployWebSubAPIDeprecatedParams struct { + DeploymentId string `form:"deploymentId" json:"deploymentId" yaml:"deploymentId"` + GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` +} + // RestoreWebSubAPIDeploymentParams defines parameters for RestoreWebSubAPIDeployment. type RestoreWebSubAPIDeploymentParams struct { GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` @@ -3769,6 +4031,12 @@ type UndeployWebSubAPIParams struct { GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` } +// ImportAPIProjectJSONRequestBody defines body for ImportAPIProject for application/json ContentType. +type ImportAPIProjectJSONRequestBody = ImportAPIProjectRequest + +// ValidateAPIProjectJSONRequestBody defines body for ValidateAPIProject for application/json ContentType. +type ValidateAPIProjectJSONRequestBody = ValidateAPIProjectRequest + // CreateApplicationJSONRequestBody defines body for CreateApplication for application/json ContentType. type CreateApplicationJSONRequestBody = CreateApplicationRequest @@ -3799,12 +4067,6 @@ type FetchGitRepoContentJSONRequestBody = GitRepoContentRequest // FetchGitRepoBranchesJSONRequestBody defines body for FetchGitRepoBranches for application/json ContentType. type FetchGitRepoBranchesJSONRequestBody = GitRepoBranchesRequest -// ImportAPIProjectJSONRequestBody defines body for ImportAPIProject for application/json ContentType. -type ImportAPIProjectJSONRequestBody = ImportAPIProjectRequest - -// ImportOpenAPIMultipartRequestBody defines body for ImportOpenAPI for multipart/form-data ContentType. -type ImportOpenAPIMultipartRequestBody = ImportOpenAPIRequest - // CreateLLMProviderTemplateJSONRequestBody defines body for CreateLLMProviderTemplate for application/json ContentType. type CreateLLMProviderTemplateJSONRequestBody = LLMProviderTemplate @@ -3859,6 +4121,12 @@ type UpdateProjectJSONRequestBody = UpdateProjectRequest // CreateRESTAPIJSONRequestBody defines body for CreateRESTAPI for application/json ContentType. type CreateRESTAPIJSONRequestBody = CreateRESTAPIRequest +// ImportOpenAPIMultipartRequestBody defines body for ImportOpenAPI for multipart/form-data ContentType. +type ImportOpenAPIMultipartRequestBody = ImportOpenAPIRequest + +// ValidateOpenAPIMultipartRequestBody defines body for ValidateOpenAPI for multipart/form-data ContentType. +type ValidateOpenAPIMultipartRequestBody = ValidateOpenAPIRequest + // UpdateRESTAPIJSONRequestBody defines body for UpdateRESTAPI for application/json ContentType. type UpdateRESTAPIJSONRequestBody = UpdateRESTAPIRequest @@ -3871,15 +4139,12 @@ type UpdateAPIKeyJSONRequestBody = UpdateAPIKeyRequest // DeployAPIJSONRequestBody defines body for DeployAPI for application/json ContentType. type DeployAPIJSONRequestBody = DeployRequest -// PublishRESTAPIToDevPortalJSONRequestBody defines body for PublishRESTAPIToDevPortal for application/json ContentType. -type PublishRESTAPIToDevPortalJSONRequestBody = PublishToDevPortalRequest - -// UnpublishRESTAPIFromDevPortalJSONRequestBody defines body for UnpublishRESTAPIFromDevPortal for application/json ContentType. -type UnpublishRESTAPIFromDevPortalJSONRequestBody = UnpublishFromDevPortalRequest - // AddGatewaysToAPIJSONRequestBody defines body for AddGatewaysToAPI for application/json ContentType. type AddGatewaysToAPIJSONRequestBody = AddGatewaysToAPIJSONBody +// PublishRESTAPIToDevPortalJSONRequestBody defines body for PublishRESTAPIToDevPortal for application/json ContentType. +type PublishRESTAPIToDevPortalJSONRequestBody = PublishToDevPortalRequest + // CreateSubscriptionPlanJSONRequestBody defines body for CreateSubscriptionPlan for application/json ContentType. type CreateSubscriptionPlanJSONRequestBody = CreateSubscriptionPlanRequest @@ -3892,12 +4157,6 @@ type CreateSubscriptionJSONRequestBody = CreateSubscriptionRequest // UpdateSubscriptionJSONRequestBody defines body for UpdateSubscription for application/json ContentType. type UpdateSubscriptionJSONRequestBody = UpdateSubscriptionRequest -// ValidateAPIProjectJSONRequestBody defines body for ValidateAPIProject for application/json ContentType. -type ValidateAPIProjectJSONRequestBody = ValidateAPIProjectRequest - -// ValidateOpenAPIMultipartRequestBody defines body for ValidateOpenAPI for multipart/form-data ContentType. -type ValidateOpenAPIMultipartRequestBody = ValidateOpenAPIRequest - // CreateWebBrokerAPIJSONRequestBody defines body for CreateWebBrokerAPI for application/json ContentType. type CreateWebBrokerAPIJSONRequestBody = WebBrokerAPI @@ -3916,9 +4175,6 @@ type DeployWebBrokerAPIJSONRequestBody = DeployRequest // PublishWebBrokerAPIToDevPortalJSONRequestBody defines body for PublishWebBrokerAPIToDevPortal for application/json ContentType. type PublishWebBrokerAPIToDevPortalJSONRequestBody = PublishToDevPortalRequest -// UnpublishWebBrokerAPIFromDevPortalJSONRequestBody defines body for UnpublishWebBrokerAPIFromDevPortal for application/json ContentType. -type UnpublishWebBrokerAPIFromDevPortalJSONRequestBody = UnpublishFromDevPortalRequest - // CreateWebSubAPIJSONRequestBody defines body for CreateWebSubAPI for application/json ContentType. type CreateWebSubAPIJSONRequestBody = WebSubAPI @@ -3934,12 +4190,15 @@ type UpdateWebSubAPIKeyJSONRequestBody = UpdateAPIKeyRequest // DeployWebSubAPIJSONRequestBody defines body for DeployWebSubAPI for application/json ContentType. type DeployWebSubAPIJSONRequestBody = DeployRequest +// CreateWebSubAPIHmacSecretJSONRequestBody defines body for CreateWebSubAPIHmacSecret for application/json ContentType. +type CreateWebSubAPIHmacSecretJSONRequestBody = WebSubAPIHmacSecretRequest + +// RegenerateWebSubAPIHmacSecretJSONRequestBody defines body for RegenerateWebSubAPIHmacSecret for application/json ContentType. +type RegenerateWebSubAPIHmacSecretJSONRequestBody = WebSubAPIHmacSecretRequest + // PublishWebSubAPIToDevPortalJSONRequestBody defines body for PublishWebSubAPIToDevPortal for application/json ContentType. type PublishWebSubAPIToDevPortalJSONRequestBody = PublishToDevPortalRequest -// UnpublishWebSubAPIFromDevPortalJSONRequestBody defines body for UnpublishWebSubAPIFromDevPortal for application/json ContentType. -type UnpublishWebSubAPIFromDevPortalJSONRequestBody = UnpublishFromDevPortalRequest - // AsImportOpenAPIRequest0 returns the union data inside the ImportOpenAPIRequest as a ImportOpenAPIRequest0 func (t ImportOpenAPIRequest) AsImportOpenAPIRequest0() (ImportOpenAPIRequest0, error) { var body ImportOpenAPIRequest0 diff --git a/platform-api/src/api/manual_types.go b/platform-api/src/api/manual_types.go new file mode 100644 index 0000000000..dbb81fb116 --- /dev/null +++ b/platform-api/src/api/manual_types.go @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://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 api contains manually preserved types that are defined in the OpenAPI spec +// but are not referenced by any path — oapi-codegen v2 only emits types reachable from paths. +// These types must be kept here to avoid breaking service code that references them. +package api + +import openapi_types "github.com/oapi-codegen/runtime/types" + +// ApiIdentifierQ defines the type for the api-identifier query parameter. +// Removed from the spec; preserved here because ValidateRESTAPIParams still uses it. +type ApiIdentifierQ = string + +// GatewayStatusResponse is defined in the OpenAPI spec but not referenced by any path. +// Kept here because service/gateway.go uses it for polling responses. +type GatewayStatusResponse struct { + // Id Unique identifier for the gateway + Id *openapi_types.UUID `json:"id,omitempty" yaml:"id,omitempty"` + + // IsActive Indicates if the gateway is currently connected to the platform via WebSocket + IsActive *bool `json:"isActive,omitempty" yaml:"isActive,omitempty"` + + // IsCritical Whether the gateway is critical for production + IsCritical *bool `json:"isCritical,omitempty" yaml:"isCritical,omitempty"` + + // Name URL-friendly gateway identifier + Name *string `json:"name,omitempty" yaml:"name,omitempty"` +} + +// GatewayStatusListResponse is defined in the OpenAPI spec but not referenced by any path. +// Kept here because service/gateway.go uses it for polling responses. +type GatewayStatusListResponse struct { + // Count Number of items in current response + Count int `binding:"required" json:"count" yaml:"count"` + List []GatewayStatusResponse `binding:"required" json:"list" yaml:"list"` + Pagination Pagination `json:"pagination" yaml:"pagination"` +} + +// RESTAPIValidationResponse is defined in the OpenAPI spec but not referenced by any path. +// Kept here because service/api.go uses it for identifier/name-version uniqueness checks. +type RESTAPIValidationResponse struct { + // Error Error details if validation fails + Error *struct { + // Code Error code indicating the type of validation failure + Code string `json:"code" yaml:"code"` + + // Message Human-readable error message + Message string `json:"message" yaml:"message"` + } `binding:"required" json:"error" yaml:"error"` + + // Valid Whether the API identifier or name-version combination is valid (not already in use) in the organization + Valid bool `binding:"required" json:"valid" yaml:"valid"` +} + +// ValidateRESTAPIParams defines query parameters used internally by the ValidateAPI service method. +// The corresponding /rest-apis/validate path was removed from the spec; this type is preserved +// because service/api.go still uses it for identifier/name/version validation. +type ValidateRESTAPIParams struct { + // Identifier **API Identifier** to check for existence within the organization. + Identifier *ApiIdentifierQ `form:"identifier,omitempty" json:"identifier,omitempty" yaml:"identifier,omitempty"` + + // Name **API Name** to check for existence within the organization. + Name *ApiNameQ `form:"name,omitempty" json:"name,omitempty" yaml:"name,omitempty"` + + // Version **API Version** to check for existence within the organization. + Version *ApiVersionQ `form:"version,omitempty" json:"version,omitempty" yaml:"version,omitempty"` +} diff --git a/platform-api/src/api/websub_hmac_secret.go b/platform-api/src/api/websub_hmac_secret.go new file mode 100644 index 0000000000..b51831cfdd --- /dev/null +++ b/platform-api/src/api/websub_hmac_secret.go @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://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 api contains API types for WebSub HMAC secrets. +// The structs WebSubAPIHmacSecretRequest, WebSubAPIHmacSecretInfo, +// WebSubAPIHmacSecretCreationResponse, and WebSubAPIHmacSecretListResponse +// are generated into generated.go from openapi.yaml — do not re-declare them here. +package api diff --git a/platform-api/src/config/config.go b/platform-api/src/config/config.go index 22d5955eb6..335e4a2ac7 100644 --- a/platform-api/src/config/config.go +++ b/platform-api/src/config/config.go @@ -87,8 +87,8 @@ type Server struct { Gateway Gateway `koanf:"gateway"` EventHub EventHub `koanf:"event_hub"` - EnableScopeValidation bool `koanf:"enable_scope_validation"` - OrgCreationRequiresAuth bool `koanf:"org_creation_requires_auth"` + EnableScopeValidation bool `koanf:"enable_scope_validation"` + OrgCreationRequiresAuth bool `koanf:"org_creation_requires_auth"` } // Auth groups all authentication-related configuration. @@ -176,6 +176,11 @@ type Database struct { ExecuteSchemaDDL bool `koanf:"execute_schema_ddl"` SubscriptionTokenEncryptionKey string `koanf:"subscription_token_encryption_key"` + + // HMACSecretEncryptionKey is the 32-byte key for AES-256-GCM encryption of WebSub API HMAC secrets. + // Provide as 64 hex chars or 44 base64 chars. + // Env: DATABASE_HMAC_SECRET_ENCRYPTION_KEY. If empty, falls back to SubscriptionTokenEncryptionKey then JWT_SECRET_KEY. + HMACSecretEncryptionKey string `envconfig:"HMAC_SECRET_ENCRYPTION_KEY" default:""` } // DefaultDevPortal holds default DevPortal configuration for new organizations. @@ -314,7 +319,6 @@ func generateRandomSecret() (string, error) { return hex.EncodeToString(b), nil } - // envToKoanfKey maps a lowercased environment variable name to its koanf dot-notation key. // Returns "" for unknown variables, which causes koanf to skip them. // Supports both the current env var names (e.g. DATABASE_DB_PATH) and the legacy @@ -322,62 +326,108 @@ func generateRandomSecret() (string, error) { func envToKoanfKey(s string) string { switch s { // Server-level - case "log_level": return "log_level" - case "port": return "port" - case "db_schema_path": return "db_schema_path" - case "openapi_spec_path": return "openapi_spec_path" - case "llm_template_definitions_path": return "llm_template_definitions_path" - case "enable_scope_validation": return "enable_scope_validation" - case "org_creation_requires_auth": return "org_creation_requires_auth" + case "log_level": + return "log_level" + case "port": + return "port" + case "db_schema_path": + return "db_schema_path" + case "openapi_spec_path": + return "openapi_spec_path" + case "llm_template_definitions_path": + return "llm_template_definitions_path" + case "enable_scope_validation": + return "enable_scope_validation" + case "org_creation_requires_auth": + return "org_creation_requires_auth" // Database - case "database_driver": return "database.driver" - case "database_db_path": return "database.path" - case "database_host": return "database.host" - case "database_port": return "database.port" - case "database_name": return "database.name" - case "database_user": return "database.user" - case "database_password": return "database.password" - case "database_ssl_mode": return "database.ssl_mode" - case "database_max_open_conns": return "database.max_open_conns" - case "database_max_idle_conns": return "database.max_idle_conns" - case "database_conn_max_lifetime": return "database.conn_max_lifetime" - case "database_execute_schema_ddl": return "database.execute_schema_ddl" - case "database_subscription_token_encryption_key": return "database.subscription_token_encryption_key" + case "database_driver": + return "database.driver" + case "database_db_path": + return "database.path" + case "database_host": + return "database.host" + case "database_port": + return "database.port" + case "database_name": + return "database.name" + case "database_user": + return "database.user" + case "database_password": + return "database.password" + case "database_ssl_mode": + return "database.ssl_mode" + case "database_max_open_conns": + return "database.max_open_conns" + case "database_max_idle_conns": + return "database.max_idle_conns" + case "database_conn_max_lifetime": + return "database.conn_max_lifetime" + case "database_execute_schema_ddl": + return "database.execute_schema_ddl" + case "database_subscription_token_encryption_key": + return "database.subscription_token_encryption_key" // Auth - case "auth_skip_paths": return "auth.skip_paths" + case "auth_skip_paths": + return "auth.skip_paths" // Auth JWT - case "auth_jwt_enabled": return "auth.jwt.enabled" - case "auth_jwt_secret_key": return "auth.jwt.secret_key" - case "auth_jwt_issuer": return "auth.jwt.issuer" - case "auth_jwt_skip_validation": return "auth.jwt.skip_validation" + case "auth_jwt_enabled": + return "auth.jwt.enabled" + case "auth_jwt_secret_key": + return "auth.jwt.secret_key" + case "auth_jwt_issuer": + return "auth.jwt.issuer" + case "auth_jwt_skip_validation": + return "auth.jwt.skip_validation" // Auth IDP - case "auth_idp_enabled": return "auth.idp.enabled" - case "auth_idp_name": return "auth.idp.name" - case "auth_idp_jwks_url": return "auth.idp.jwks_url" - case "auth_idp_issuer": return "auth.idp.issuer" - case "auth_idp_audience": return "auth.idp.audience" - case "auth_idp_validation_mode": return "auth.idp.validation_mode" - case "auth_idp_role_mappings_file": return "auth.idp.role_mappings_file" - case "auth_idp_claim_mappings_organization_claim_name": return "auth.idp.claim_mappings.organization_claim_name" - case "auth_idp_claim_mappings_org_name_claim_name": return "auth.idp.claim_mappings.org_name_claim_name" - case "auth_idp_claim_mappings_org_handle_claim_name": return "auth.idp.claim_mappings.org_handle_claim_name" - case "auth_idp_claim_mappings_user_id_claim_name": return "auth.idp.claim_mappings.user_id_claim_name" - case "auth_idp_claim_mappings_username_claim_name": return "auth.idp.claim_mappings.username_claim_name" - case "auth_idp_claim_mappings_email_claim_name": return "auth.idp.claim_mappings.email_claim_name" - case "auth_idp_claim_mappings_scope_claim_name": return "auth.idp.claim_mappings.scope_claim_name" - case "auth_idp_claim_mappings_roles_claim_path": return "auth.idp.claim_mappings.roles_claim_path" + case "auth_idp_enabled": + return "auth.idp.enabled" + case "auth_idp_name": + return "auth.idp.name" + case "auth_idp_jwks_url": + return "auth.idp.jwks_url" + case "auth_idp_issuer": + return "auth.idp.issuer" + case "auth_idp_audience": + return "auth.idp.audience" + case "auth_idp_validation_mode": + return "auth.idp.validation_mode" + case "auth_idp_role_mappings_file": + return "auth.idp.role_mappings_file" + case "auth_idp_claim_mappings_organization_claim_name": + return "auth.idp.claim_mappings.organization_claim_name" + case "auth_idp_claim_mappings_org_name_claim_name": + return "auth.idp.claim_mappings.org_name_claim_name" + case "auth_idp_claim_mappings_org_handle_claim_name": + return "auth.idp.claim_mappings.org_handle_claim_name" + case "auth_idp_claim_mappings_user_id_claim_name": + return "auth.idp.claim_mappings.user_id_claim_name" + case "auth_idp_claim_mappings_username_claim_name": + return "auth.idp.claim_mappings.username_claim_name" + case "auth_idp_claim_mappings_email_claim_name": + return "auth.idp.claim_mappings.email_claim_name" + case "auth_idp_claim_mappings_scope_claim_name": + return "auth.idp.claim_mappings.scope_claim_name" + case "auth_idp_claim_mappings_roles_claim_path": + return "auth.idp.claim_mappings.roles_claim_path" // Auth FileBased - case "auth_file_based_enabled": return "auth.file_based.enabled" - case "auth_file_based_organization_id": return "auth.file_based.organization.id" - case "auth_file_based_organization_name": return "auth.file_based.organization.name" - case "auth_file_based_organization_handle": return "auth.file_based.organization.handle" - case "auth_file_based_organization_region": return "auth.file_based.organization.region" - case "auth_file_based_users": return "auth.file_based.users" + case "auth_file_based_enabled": + return "auth.file_based.enabled" + case "auth_file_based_organization_id": + return "auth.file_based.organization.id" + case "auth_file_based_organization_name": + return "auth.file_based.organization.name" + case "auth_file_based_organization_handle": + return "auth.file_based.organization.handle" + case "auth_file_based_organization_region": + return "auth.file_based.organization.region" + case "auth_file_based_users": + return "auth.file_based.users" // WebSocket — accept both legacy WEBSOCKET_WS_* and clean WEBSOCKET_* case "websocket_ws_max_connections", "websocket_max_connections": @@ -394,42 +444,68 @@ func envToKoanfKey(s string) string { return "websocket.metrics_log_interval" // Default DevPortal - case "default_devportal_enabled": return "default_devportal.enabled" - case "default_devportal_name": return "default_devportal.name" - case "default_devportal_identifier": return "default_devportal.identifier" - case "default_devportal_api_url": return "default_devportal.api_url" - case "default_devportal_hostname": return "default_devportal.hostname" - case "default_devportal_api_key": return "default_devportal.api_key" - case "default_devportal_header_key_name": return "default_devportal.header_key_name" - case "default_devportal_timeout": return "default_devportal.timeout" - case "default_devportal_role_claim_name": return "default_devportal.role_claim_name" - case "default_devportal_groups_claim_name": return "default_devportal.groups_claim_name" - case "default_devportal_organization_claim_name": return "default_devportal.organization_claim_name" - case "default_devportal_admin_role": return "default_devportal.admin_role" - case "default_devportal_subscriber_role": return "default_devportal.subscriber_role" - case "default_devportal_super_admin_role": return "default_devportal.super_admin_role" + case "default_devportal_enabled": + return "default_devportal.enabled" + case "default_devportal_name": + return "default_devportal.name" + case "default_devportal_identifier": + return "default_devportal.identifier" + case "default_devportal_api_url": + return "default_devportal.api_url" + case "default_devportal_hostname": + return "default_devportal.hostname" + case "default_devportal_api_key": + return "default_devportal.api_key" + case "default_devportal_header_key_name": + return "default_devportal.header_key_name" + case "default_devportal_timeout": + return "default_devportal.timeout" + case "default_devportal_role_claim_name": + return "default_devportal.role_claim_name" + case "default_devportal_groups_claim_name": + return "default_devportal.groups_claim_name" + case "default_devportal_organization_claim_name": + return "default_devportal.organization_claim_name" + case "default_devportal_admin_role": + return "default_devportal.admin_role" + case "default_devportal_subscriber_role": + return "default_devportal.subscriber_role" + case "default_devportal_super_admin_role": + return "default_devportal.super_admin_role" // Deployments - case "deployments_max_per_api_gateway": return "deployments.max_per_api_gateway" - case "deployments_transitional_status_enabled": return "deployments.transitional_status_enabled" - case "deployments_timeout_enabled": return "deployments.timeout_enabled" - case "deployments_timeout_interval": return "deployments.timeout_interval" - case "deployments_timeout_duration": return "deployments.timeout_duration" + case "deployments_max_per_api_gateway": + return "deployments.max_per_api_gateway" + case "deployments_transitional_status_enabled": + return "deployments.transitional_status_enabled" + case "deployments_timeout_enabled": + return "deployments.timeout_enabled" + case "deployments_timeout_interval": + return "deployments.timeout_interval" + case "deployments_timeout_duration": + return "deployments.timeout_duration" // TLS - case "tls_cert_dir": return "tls.cert_dir" + case "tls_cert_dir": + return "tls.cert_dir" // API Key - case "api_key_hashing_algorithms": return "api_key.hashing_algorithms" + case "api_key_hashing_algorithms": + return "api_key.hashing_algorithms" // Gateway - case "gateway_enable_version_verification": return "gateway.enable_version_verification" - case "gateway_enable_functionality_type_verification": return "gateway.enable_functionality_type_verification" + case "gateway_enable_version_verification": + return "gateway.enable_version_verification" + case "gateway_enable_functionality_type_verification": + return "gateway.enable_functionality_type_verification" // EventHub - case "event_hub_poll_interval": return "event_hub.poll_interval" - case "event_hub_cleanup_interval": return "event_hub.cleanup_interval" - case "event_hub_retention_period": return "event_hub.retention_period" + case "event_hub_poll_interval": + return "event_hub.poll_interval" + case "event_hub_cleanup_interval": + return "event_hub.cleanup_interval" + case "event_hub_retention_period": + return "event_hub.retention_period" default: return "" diff --git a/platform-api/src/internal/constants/error.go b/platform-api/src/internal/constants/error.go index 1f4c578557..23c093b6a3 100644 --- a/platform-api/src/internal/constants/error.go +++ b/platform-api/src/internal/constants/error.go @@ -171,6 +171,13 @@ var ( ErrProjectHasAssociatedWebSubAPIs = errors.New("project has associated WebSub APIs") ) +var ( + ErrHmacSecretNotFound = errors.New("hmac secret not found") + ErrHmacSecretAlreadyExists = errors.New("hmac secret with this name already exists") + ErrHmacSecretEncryptionKeyMissing = errors.New("hmac secret encryption key is not configured") + ErrHmacSecretInvalidValue = errors.New("secret value must be at least 32 characters") +) + var ( ErrWebBrokerAPIExists = errors.New("webbroker api already exists") ErrWebBrokerAPINotFound = errors.New("webbroker api not found") diff --git a/platform-api/src/internal/database/schema.postgres.sql b/platform-api/src/internal/database/schema.postgres.sql index 635527fea8..0df6cd668a 100644 --- a/platform-api/src/internal/database/schema.postgres.sql +++ b/platform-api/src/internal/database/schema.postgres.sql @@ -369,6 +369,21 @@ CREATE TABLE IF NOT EXISTS websub_apis ( ); CREATE INDEX IF NOT EXISTS idx_websub_apis_project ON websub_apis(project_uuid); +-- WebSub API HMAC secrets table (for inbound webhook event verification) +CREATE TABLE IF NOT EXISTS websub_api_hmac_secrets ( + uuid VARCHAR(40) PRIMARY KEY, + artifact_uuid VARCHAR(40) NOT NULL, + name VARCHAR(63) NOT NULL, + display_name VARCHAR(255), + encrypted_secret TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (artifact_uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE, + UNIQUE(artifact_uuid, name) +); +CREATE INDEX IF NOT EXISTS idx_websub_api_hmac_secrets_artifact ON websub_api_hmac_secrets(artifact_uuid); + -- WEBBROKER APIs table CREATE TABLE IF NOT EXISTS webbroker_apis ( uuid VARCHAR(40) PRIMARY KEY, diff --git a/platform-api/src/internal/database/schema.sql b/platform-api/src/internal/database/schema.sql index 8308c207ee..1a57075c0d 100644 --- a/platform-api/src/internal/database/schema.sql +++ b/platform-api/src/internal/database/schema.sql @@ -364,6 +364,21 @@ CREATE TABLE IF NOT EXISTS websub_apis ( ); CREATE INDEX IF NOT EXISTS idx_websub_apis_project ON websub_apis(project_uuid); +-- WebSub API HMAC secrets table (for inbound webhook event verification) +CREATE TABLE IF NOT EXISTS websub_api_hmac_secrets ( + uuid VARCHAR(40) PRIMARY KEY, + artifact_uuid VARCHAR(40) NOT NULL, + name VARCHAR(63) NOT NULL, + display_name VARCHAR(255), + encrypted_secret TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (artifact_uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE, + UNIQUE(artifact_uuid, name) +); +CREATE INDEX IF NOT EXISTS idx_websub_api_hmac_secrets_artifact ON websub_api_hmac_secrets(artifact_uuid); + -- WEBBROKER APIs table CREATE TABLE IF NOT EXISTS webbroker_apis ( uuid VARCHAR(40) PRIMARY KEY, diff --git a/platform-api/src/internal/database/schema.sqlite.sql b/platform-api/src/internal/database/schema.sqlite.sql index c90f4df318..08d28d9531 100644 --- a/platform-api/src/internal/database/schema.sqlite.sql +++ b/platform-api/src/internal/database/schema.sqlite.sql @@ -367,6 +367,21 @@ CREATE TABLE IF NOT EXISTS websub_apis ( ); CREATE INDEX IF NOT EXISTS idx_websub_apis_project ON websub_apis(project_uuid); +-- WebSub API HMAC secrets table (for inbound webhook event verification) +CREATE TABLE IF NOT EXISTS websub_api_hmac_secrets ( + uuid VARCHAR(40) PRIMARY KEY, + artifact_uuid VARCHAR(40) NOT NULL, + name VARCHAR(63) NOT NULL, + display_name VARCHAR(255), + encrypted_secret TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (artifact_uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE, + UNIQUE(artifact_uuid, name) +); +CREATE INDEX IF NOT EXISTS idx_websub_api_hmac_secrets_artifact ON websub_api_hmac_secrets(artifact_uuid); + -- WEBBROKER APIs table CREATE TABLE IF NOT EXISTS webbroker_apis ( uuid VARCHAR(40) PRIMARY KEY, diff --git a/platform-api/src/internal/dto/gateway_internal.go b/platform-api/src/internal/dto/gateway_internal.go index 21b103827e..fc49282683 100644 --- a/platform-api/src/internal/dto/gateway_internal.go +++ b/platform-api/src/internal/dto/gateway_internal.go @@ -118,6 +118,19 @@ type GatewaySubscriptionPlanInfo struct { Etag string `json:"etag"` // Deterministic UUIDv7 derived from id + updatedAt } +// GatewayHmacSecretInfo represents a single HMAC secret returned to the gateway-controller. +// The Secret field contains the plaintext value — this is only exposed on the internal endpoint. +type GatewayHmacSecretInfo struct { + Name string `json:"name"` + Secret string `json:"secret"` +} + +// GatewayHmacSecretsResponse is the response for GET /api/internal/v1/websub-apis/:apiId/hmac-secrets. +type GatewayHmacSecretsResponse struct { + ArtifactID string `json:"artifactId"` + Secrets []GatewayHmacSecretInfo `json:"secrets"` +} + // GatewaySubscriptionInfo represents a subscription in internal gateway responses. type GatewaySubscriptionInfo struct { ID string `json:"id"` diff --git a/platform-api/src/internal/handler/gateway_internal.go b/platform-api/src/internal/handler/gateway_internal.go index fe2cd40f7f..e1c62205ee 100644 --- a/platform-api/src/internal/handler/gateway_internal.go +++ b/platform-api/src/internal/handler/gateway_internal.go @@ -36,14 +36,18 @@ import ( type GatewayInternalAPIHandler struct { gatewayService *service.GatewayService gatewayInternalService *service.GatewayInternalAPIService + hmacSecretService *service.WebSubAPIHmacSecretService slogger *slog.Logger } func NewGatewayInternalAPIHandler(gatewayService *service.GatewayService, - gatewayInternalService *service.GatewayInternalAPIService, slogger *slog.Logger) *GatewayInternalAPIHandler { + gatewayInternalService *service.GatewayInternalAPIService, + hmacSecretService *service.WebSubAPIHmacSecretService, + slogger *slog.Logger) *GatewayInternalAPIHandler { return &GatewayInternalAPIHandler{ gatewayService: gatewayService, gatewayInternalService: gatewayInternalService, + hmacSecretService: hmacSecretService, slogger: slogger, } } @@ -805,6 +809,47 @@ func (h *GatewayInternalAPIHandler) CheckArtifactsExist(c *gin.Context) { }) } +// GetWebSubAPIHmacSecrets handles GET /api/internal/v1/websub-apis/:apiId/hmac-secrets +// Returns decrypted plaintext HMAC secrets for the gateway-controller to load into its webhook secret store. +func (h *GatewayInternalAPIHandler) GetWebSubAPIHmacSecrets(c *gin.Context) { + _, _, ok := h.authenticateRequest(c) + if !ok { + return + } + + apiID := c.Param("apiId") + if apiID == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API ID is required")) + return + } + + if h.hmacSecretService == nil { + h.slogger.Warn("HMAC secret service not configured", "apiID", apiID) + c.JSON(http.StatusServiceUnavailable, utils.NewErrorResponse(503, "Service Unavailable", "HMAC secret management is not configured on this server")) + return + } + + secrets, err := h.hmacSecretService.ListByArtifactUUID(apiID) + if err != nil { + h.slogger.Error("Failed to list HMAC secrets for WebSub API", "apiID", apiID, "error", err) + c.JSON(http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to get HMAC secrets")) + return + } + + items := make([]dto.GatewayHmacSecretInfo, 0, len(secrets)) + for _, s := range secrets { + plaintext, err := h.hmacSecretService.DecryptSecret(s) + if err != nil { + h.slogger.Error("Failed to decrypt HMAC secret", "apiID", apiID, "secretName", s.Name, "error", err) + c.JSON(http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to decrypt HMAC secret")) + return + } + items = append(items, dto.GatewayHmacSecretInfo{Name: s.Name, Secret: plaintext}) + } + + c.JSON(http.StatusOK, dto.GatewayHmacSecretsResponse{ArtifactID: apiID, Secrets: items}) +} + func (h *GatewayInternalAPIHandler) RegisterRoutes(r *gin.Engine) { orgGroup := r.Group("/api/internal/v1/apis") { @@ -846,6 +891,7 @@ func (h *GatewayInternalAPIHandler) RegisterRoutes(r *gin.Engine) { { websubAPIGroup.GET("/api-keys", h.GetWebSubAPIAPIKeys) websubAPIGroup.GET("/:apiId", h.GetWebSubAPI) + websubAPIGroup.GET("/:apiId/hmac-secrets", h.GetWebSubAPIHmacSecrets) } webbrokerAPIGroup := r.Group("/api/internal/v1/webbroker-apis") diff --git a/platform-api/src/internal/handler/websub_api_hmac_secret.go b/platform-api/src/internal/handler/websub_api_hmac_secret.go new file mode 100644 index 0000000000..6990669abc --- /dev/null +++ b/platform-api/src/internal/handler/websub_api_hmac_secret.go @@ -0,0 +1,260 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://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 handler + +import ( + "errors" + "io" + "log/slog" + "net/http" + + "platform-api/src/api" + "platform-api/src/internal/constants" + "platform-api/src/internal/middleware" + "platform-api/src/internal/model" + "platform-api/src/internal/service" + "platform-api/src/internal/utils" + + "github.com/gin-gonic/gin" +) + +// WebSubAPIHmacSecretHandler handles HMAC secret CRUD for WebSub APIs. +type WebSubAPIHmacSecretHandler struct { + secretService *service.WebSubAPIHmacSecretService + slogger *slog.Logger +} + +// NewWebSubAPIHmacSecretHandler creates a new WebSubAPIHmacSecretHandler. +func NewWebSubAPIHmacSecretHandler(secretService *service.WebSubAPIHmacSecretService, slogger *slog.Logger) *WebSubAPIHmacSecretHandler { + return &WebSubAPIHmacSecretHandler{ + secretService: secretService, + slogger: slogger, + } +} + +// RegisterRoutes registers the HMAC secret routes. +func (h *WebSubAPIHmacSecretHandler) RegisterRoutes(r *gin.Engine) { + v1 := r.Group("/api/v1/websub-apis/:apiId/secrets") + { + v1.POST("", h.CreateHmacSecret) + v1.GET("", h.ListHmacSecrets) + v1.DELETE("/:secretName", h.DeleteHmacSecret) + v1.POST("/:secretName/regenerate", h.RegenerateHmacSecret) + } +} + +func (h *WebSubAPIHmacSecretHandler) featureUnavailable(c *gin.Context) bool { + if h.secretService == nil { + c.JSON(http.StatusServiceUnavailable, utils.NewErrorResponse(503, "Service Unavailable", + "HMAC secret management is not configured on this server")) + return true + } + return false +} + +// CreateHmacSecret handles POST /api/v1/websub-apis/:apiId/hmac-secrets +func (h *WebSubAPIHmacSecretHandler) CreateHmacSecret(c *gin.Context) { + if h.featureUnavailable(c) { + return + } + orgID, ok := middleware.GetOrganizationFromContext(c) + if !ok { + c.JSON(http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + return + } + + apiHandle := c.Param("apiId") + if apiHandle == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API handle is required")) + return + } + + var req api.WebSubAPIHmacSecretRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body")) + return + } + + if req.Secret != nil && *req.Secret == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "secret must not be empty; omit the field to auto-generate")) + return + } + + var externalSecret string + if req.Secret != nil { + externalSecret = *req.Secret + } + + secret, plaintext, err := h.secretService.Generate(orgID, apiHandle, req.DisplayName, externalSecret) + if err != nil { + h.handleServiceError(c, err) + return + } + + msg := "HMAC secret generated successfully. Save the secret value — it will not be shown again." + if externalSecret != "" { + msg = "HMAC secret stored successfully." + } + c.JSON(http.StatusCreated, api.WebSubAPIHmacSecretCreationResponse{ + Secret: plaintext, + WebhookSecret: secretToInfo(secret), + Message: msg, + }) +} + +// ListHmacSecrets handles GET /api/v1/websub-apis/:apiId/hmac-secrets +func (h *WebSubAPIHmacSecretHandler) ListHmacSecrets(c *gin.Context) { + if h.featureUnavailable(c) { + return + } + orgID, ok := middleware.GetOrganizationFromContext(c) + if !ok { + c.JSON(http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + return + } + + apiHandle := c.Param("apiId") + if apiHandle == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API handle is required")) + return + } + + secrets, err := h.secretService.List(orgID, apiHandle) + if err != nil { + h.handleServiceError(c, err) + return + } + + items := make([]api.WebSubAPIHmacSecretInfo, 0, len(secrets)) + for _, s := range secrets { + items = append(items, *secretToInfo(s)) + } + c.JSON(http.StatusOK, api.WebSubAPIHmacSecretListResponse{Secrets: items}) +} + +// DeleteHmacSecret handles DELETE /api/v1/websub-apis/:apiId/hmac-secrets/:secretName +func (h *WebSubAPIHmacSecretHandler) DeleteHmacSecret(c *gin.Context) { + if h.featureUnavailable(c) { + return + } + orgID, ok := middleware.GetOrganizationFromContext(c) + if !ok { + c.JSON(http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + return + } + + apiHandle := c.Param("apiId") + secretName := c.Param("secretName") + if apiHandle == "" || secretName == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API handle and secret name are required")) + return + } + + if err := h.secretService.Delete(orgID, apiHandle, secretName); err != nil { + h.handleServiceError(c, err) + return + } + + c.Status(http.StatusNoContent) +} + +// RegenerateHmacSecret handles POST /api/v1/websub-apis/:apiId/hmac-secrets/:secretName/regenerate +func (h *WebSubAPIHmacSecretHandler) RegenerateHmacSecret(c *gin.Context) { + if h.featureUnavailable(c) { + return + } + orgID, ok := middleware.GetOrganizationFromContext(c) + if !ok { + c.JSON(http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + return + } + + apiHandle := c.Param("apiId") + secretName := c.Param("secretName") + if apiHandle == "" || secretName == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "API handle and secret name are required")) + return + } + + var req api.WebSubAPIHmacSecretRequest + if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) { + c.AbortWithStatusJSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body")) + return + } + + if req.Secret != nil && *req.Secret == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "secret must not be empty; omit the field to auto-generate")) + return + } + + var externalSecret string + if req.Secret != nil { + externalSecret = *req.Secret + } + + secret, plaintext, err := h.secretService.Regenerate(orgID, apiHandle, secretName, externalSecret) + if err != nil { + h.handleServiceError(c, err) + return + } + + msg := "HMAC secret regenerated successfully. Save the new secret value — it will not be shown again." + if externalSecret != "" { + msg = "HMAC secret rotated to the provided value successfully." + } + c.JSON(http.StatusOK, api.WebSubAPIHmacSecretCreationResponse{ + Secret: plaintext, + WebhookSecret: secretToInfo(secret), + Message: msg, + }) +} + +func (h *WebSubAPIHmacSecretHandler) handleServiceError(c *gin.Context, err error) { + switch { + case errors.Is(err, constants.ErrWebSubAPINotFound): + c.JSON(http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "WebSub API not found")) + case errors.Is(err, constants.ErrHmacSecretNotFound): + c.JSON(http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "HMAC secret not found")) + case errors.Is(err, constants.ErrHmacSecretAlreadyExists): + c.JSON(http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "An HMAC secret with this name already exists")) + case errors.Is(err, constants.ErrHmacSecretInvalidValue): + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Secret value must be at least 32 characters")) + case errors.Is(err, constants.ErrHmacSecretEncryptionKeyMissing): + h.slogger.Error("HMAC secret encryption key is not configured") + c.JSON(http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "HMAC secret management is not configured")) + default: + h.slogger.Error("HMAC secret service error", "error", err) + c.JSON(http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "An unexpected error occurred")) + } +} + +func secretToInfo(s *model.WebSubAPIHmacSecret) *api.WebSubAPIHmacSecretInfo { + if s == nil { + return nil + } + return &api.WebSubAPIHmacSecretInfo{ + Uuid: s.UUID, + Name: s.Name, + DisplayName: s.DisplayName, + Status: s.Status, + CreatedAt: s.CreatedAt, + UpdatedAt: s.UpdatedAt, + } +} diff --git a/platform-api/src/internal/model/websub_api_hmac_secret.go b/platform-api/src/internal/model/websub_api_hmac_secret.go new file mode 100644 index 0000000000..a858b0e4a5 --- /dev/null +++ b/platform-api/src/internal/model/websub_api_hmac_secret.go @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://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 model + +import "time" + +// WebSubAPIHmacSecret represents a platform-managed HMAC secret for a WebSub API. +// Plaintext is never persisted; only the AES-256-GCM encrypted form is stored. +type WebSubAPIHmacSecret struct { + UUID string `json:"uuid"` + ArtifactUUID string `json:"artifactUuid"` + Name string `json:"name"` + DisplayName string `json:"displayName"` + EncryptedSecret string `json:"-"` + Status string `json:"status"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// WebSubAPIHmacSecretEvent is broadcast to gateways when a secret is created, regenerated, or deleted. +type WebSubAPIHmacSecretEvent struct { + // ArtifactUUID is the UUID of the WebSub API artifact. + ArtifactUUID string `json:"artifactUuid"` + // SecretName is the slug name of the secret. + SecretName string `json:"secretName"` +} diff --git a/platform-api/src/internal/repository/interfaces.go b/platform-api/src/internal/repository/interfaces.go index 2184e2e10c..3a1ff551d5 100644 --- a/platform-api/src/internal/repository/interfaces.go +++ b/platform-api/src/internal/repository/interfaces.go @@ -285,6 +285,15 @@ type MCPProxyRepository interface { Exists(handle, orgUUID string) (bool, error) } +// WebSubAPIHmacSecretRepository defines the interface for WebSub API HMAC secret persistence +type WebSubAPIHmacSecretRepository interface { + Create(secret *model.WebSubAPIHmacSecret) error + GetByArtifactAndName(artifactUUID, name string) (*model.WebSubAPIHmacSecret, error) + ListByArtifact(artifactUUID string) ([]*model.WebSubAPIHmacSecret, error) + Update(secret *model.WebSubAPIHmacSecret) error + Delete(artifactUUID, name string) error +} + // WebSubAPIRepository defines the interface for WebSub API persistence type WebSubAPIRepository interface { Create(api *model.WebSubAPI) error diff --git a/platform-api/src/internal/repository/websub_api_hmac_secret.go b/platform-api/src/internal/repository/websub_api_hmac_secret.go new file mode 100644 index 0000000000..a6982fd481 --- /dev/null +++ b/platform-api/src/internal/repository/websub_api_hmac_secret.go @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://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 repository + +import ( + "database/sql" + "errors" + "time" + + "platform-api/src/internal/database" + "platform-api/src/internal/model" +) + +// WebSubAPIHmacSecretRepo handles database operations for WebSub API HMAC secrets +type WebSubAPIHmacSecretRepo struct { + db *database.DB +} + +// NewWebSubAPIHmacSecretRepo creates a new WebSubAPIHmacSecretRepo +func NewWebSubAPIHmacSecretRepo(db *database.DB) *WebSubAPIHmacSecretRepo { + return &WebSubAPIHmacSecretRepo{db: db} +} + +// Create persists a new HMAC secret +func (r *WebSubAPIHmacSecretRepo) Create(secret *model.WebSubAPIHmacSecret) error { + now := time.Now().UTC() + secret.CreatedAt = now + secret.UpdatedAt = now + query := ` + INSERT INTO websub_api_hmac_secrets (uuid, artifact_uuid, name, display_name, encrypted_secret, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + _, err := r.db.Exec(r.db.Rebind(query), + secret.UUID, secret.ArtifactUUID, secret.Name, secret.DisplayName, + secret.EncryptedSecret, secret.Status, secret.CreatedAt, secret.UpdatedAt, + ) + return err +} + +// GetByArtifactAndName fetches a specific HMAC secret by artifact UUID and name +func (r *WebSubAPIHmacSecretRepo) GetByArtifactAndName(artifactUUID, name string) (*model.WebSubAPIHmacSecret, error) { + query := ` + SELECT uuid, artifact_uuid, name, display_name, encrypted_secret, status, created_at, updated_at + FROM websub_api_hmac_secrets + WHERE artifact_uuid = ? AND name = ?` + row := r.db.QueryRow(r.db.Rebind(query), artifactUUID, name) + s := &model.WebSubAPIHmacSecret{} + var displayName sql.NullString + if err := row.Scan(&s.UUID, &s.ArtifactUUID, &s.Name, &displayName, &s.EncryptedSecret, &s.Status, &s.CreatedAt, &s.UpdatedAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + s.DisplayName = displayName.String + return s, nil +} + +// ListByArtifact returns all HMAC secrets for an artifact +func (r *WebSubAPIHmacSecretRepo) ListByArtifact(artifactUUID string) ([]*model.WebSubAPIHmacSecret, error) { + query := ` + SELECT uuid, artifact_uuid, name, display_name, encrypted_secret, status, created_at, updated_at + FROM websub_api_hmac_secrets + WHERE artifact_uuid = ? + ORDER BY created_at ASC` + rows, err := r.db.Query(r.db.Rebind(query), artifactUUID) + if err != nil { + return nil, err + } + defer rows.Close() + + var secrets []*model.WebSubAPIHmacSecret + for rows.Next() { + s := &model.WebSubAPIHmacSecret{} + var displayName sql.NullString + if err := rows.Scan(&s.UUID, &s.ArtifactUUID, &s.Name, &displayName, &s.EncryptedSecret, &s.Status, &s.CreatedAt, &s.UpdatedAt); err != nil { + return nil, err + } + s.DisplayName = displayName.String + secrets = append(secrets, s) + } + return secrets, rows.Err() +} + +// Update replaces the encrypted secret value (used on regenerate) +func (r *WebSubAPIHmacSecretRepo) Update(secret *model.WebSubAPIHmacSecret) error { + secret.UpdatedAt = time.Now().UTC() + query := ` + UPDATE websub_api_hmac_secrets + SET encrypted_secret = ?, updated_at = ? + WHERE artifact_uuid = ? AND name = ?` + result, err := r.db.Exec(r.db.Rebind(query), + secret.EncryptedSecret, secret.UpdatedAt, secret.ArtifactUUID, secret.Name, + ) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows + } + return nil +} + +// Delete permanently removes a secret +func (r *WebSubAPIHmacSecretRepo) Delete(artifactUUID, name string) error { + query := `DELETE FROM websub_api_hmac_secrets WHERE artifact_uuid = ? AND name = ?` + result, err := r.db.Exec(r.db.Rebind(query), artifactUUID, name) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows + } + return nil +} diff --git a/platform-api/src/internal/server/server.go b/platform-api/src/internal/server/server.go index 700d6e96c6..0067d37d82 100644 --- a/platform-api/src/internal/server/server.go +++ b/platform-api/src/internal/server/server.go @@ -21,9 +21,11 @@ import ( "context" "crypto/rand" "crypto/rsa" + "crypto/sha256" "crypto/tls" "crypto/x509" "crypto/x509/pkix" + "encoding/hex" "encoding/pem" "fmt" "log/slog" @@ -228,6 +230,25 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, mcpProxyService := service.NewMCPProxyService(mcpProxyRepo, projectRepo, deploymentRepo, gatewayRepo, gatewayEventsService, slogger) websubAPIService := service.NewWebSubAPIService(websubAPIRepo, projectRepo, gatewayRepo, devPortalService, gatewayEventsService, apiUtil, slogger) webbrokerAPIService := service.NewWebBrokerAPIService(webbrokerAPIRepo, projectRepo, gatewayRepo, devPortalService, gatewayEventsService, apiUtil, slogger) + + // Initialize HMAC secret service with fallback key chain. + // DeriveEncryptionKey requires 64-char hex or base64-to-32-bytes; when falling back to + // the raw JWT secret (arbitrary length), hash it to a valid 64-char hex key. + hmacEncryptionKey := cfg.Database.HMACSecretEncryptionKey + if hmacEncryptionKey == "" { + hmacEncryptionKey = cfg.Database.SubscriptionTokenEncryptionKey + } + if hmacEncryptionKey == "" { + h := sha256.Sum256([]byte(cfg.Auth.JWT.SecretKey)) + hmacEncryptionKey = hex.EncodeToString(h[:]) + } + hmacSecretRepo := repository.NewWebSubAPIHmacSecretRepo(db) + hmacSecretService, hmacErr := service.NewWebSubAPIHmacSecretService( + hmacSecretRepo, websubAPIRepo, gatewayEventsService, gatewayRepo, hmacEncryptionKey, slogger, + ) + if hmacErr != nil { + slogger.Warn("WebSub HMAC secret service disabled — no valid encryption key configured", "error", hmacErr) + } llmProviderDeploymentService := service.NewLLMProviderDeploymentService( llmProviderRepo, llmTemplateRepo, @@ -293,7 +314,8 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, subscriptionPlanHandler := handler.NewSubscriptionPlanHandler(subscriptionPlanService, slogger) appHandler := handler.NewApplicationHandler(appService, slogger) wsHandler := handler.NewWebSocketHandler(wsManager, gatewayService, deploymentService, cfg.WebSocket.RateLimitPerMin, slogger) - internalGatewayHandler := handler.NewGatewayInternalAPIHandler(gatewayService, internalGatewayService, slogger) + internalGatewayHandler := handler.NewGatewayInternalAPIHandler(gatewayService, internalGatewayService, hmacSecretService, slogger) + hmacSecretHandler := handler.NewWebSubAPIHmacSecretHandler(hmacSecretService, slogger) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService, slogger) gitHandler := handler.NewGitHandler(gitService, slogger) deploymentHandler := handler.NewDeploymentHandler(deploymentService, slogger) @@ -419,6 +441,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, websubAPIHandler.RegisterRoutes(router) websubAPIKeyHandler.RegisterRoutes(router) websubAPIDeploymentHandler.RegisterRoutes(router) + hmacSecretHandler.RegisterRoutes(router) webbrokerAPIHandler.RegisterRoutes(router) webbrokerAPIKeyHandler.RegisterRoutes(router) webbrokerAPIDeploymentHandler.RegisterRoutes(router) diff --git a/platform-api/src/internal/service/gateway_events.go b/platform-api/src/internal/service/gateway_events.go index 5861b4373a..4f98e42925 100644 --- a/platform-api/src/internal/service/gateway_events.go +++ b/platform-api/src/internal/service/gateway_events.go @@ -68,6 +68,10 @@ const ( EventTypeAPIKeyRevoked = "apikey.revoked" EventTypeAPIKeyUpdated = "apikey.updated" + EventTypeWebSubAPIHmacSecretCreated = "websub.hmacsecret.created" + EventTypeWebSubAPIHmacSecretUpdated = "websub.hmacsecret.updated" + EventTypeWebSubAPIHmacSecretDeleted = "websub.hmacsecret.deleted" + EventTypeSubscriptionCreated = "subscription.created" EventTypeSubscriptionUpdated = "subscription.updated" EventTypeSubscriptionDeleted = "subscription.deleted" @@ -183,6 +187,23 @@ func (s *GatewayEventsService) BroadcastLLMProxyDeletionEvent(gatewayID string, return s.broadcastEvent(gatewayID, EventTypeLLMProxyDeleted, deletion) } +// BroadcastWebSubAPIHmacSecretEvent sends a WebSub API HMAC secret lifecycle event to target gateway. +// action should be "CREATED", "UPDATED", or "DELETED". +func (s *GatewayEventsService) BroadcastWebSubAPIHmacSecretEvent(gatewayID, action string, event *model.WebSubAPIHmacSecretEvent) error { + var eventType string + switch action { + case "CREATED": + eventType = EventTypeWebSubAPIHmacSecretCreated + case "UPDATED": + eventType = EventTypeWebSubAPIHmacSecretUpdated + case "DELETED": + eventType = EventTypeWebSubAPIHmacSecretDeleted + default: + eventType = EventTypeWebSubAPIHmacSecretUpdated + } + return s.broadcastEvent(gatewayID, eventType, event) +} + // BroadcastAPIKeyCreatedEvent sends an API key created event to target gateway. func (s *GatewayEventsService) BroadcastAPIKeyCreatedEvent(gatewayID, userId string, event *model.APIKeyCreatedEvent) error { return s.broadcastEventWithUserID(gatewayID, userId, EventTypeAPIKeyCreated, event) diff --git a/platform-api/src/internal/service/websub_api_hmac_secret.go b/platform-api/src/internal/service/websub_api_hmac_secret.go new file mode 100644 index 0000000000..66355c2a62 --- /dev/null +++ b/platform-api/src/internal/service/websub_api_hmac_secret.go @@ -0,0 +1,300 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://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 service + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "strings" + + "platform-api/src/internal/constants" + "platform-api/src/internal/model" + "platform-api/src/internal/repository" + "platform-api/src/internal/utils" +) + +const ( + hmacSecretPrefix = "whsec_" + hmacSecretLen = 32 // random bytes → 64 hex chars; total = 70 chars +) + +// WebSubAPIHmacSecretService manages platform-side HMAC secrets for WebSub APIs. +type WebSubAPIHmacSecretService struct { + repo repository.WebSubAPIHmacSecretRepository + websubRepo repository.WebSubAPIRepository + encryptionKey []byte + gatewayEventsService *GatewayEventsService + gatewayRepo repository.GatewayRepository + slogger *slog.Logger +} + +// NewWebSubAPIHmacSecretService creates a new WebSubAPIHmacSecretService. +// encryptionKeyStr must be a 32-byte key encoded as 64 hex chars or base64. +func NewWebSubAPIHmacSecretService( + repo repository.WebSubAPIHmacSecretRepository, + websubRepo repository.WebSubAPIRepository, + gatewayEventsService *GatewayEventsService, + gatewayRepo repository.GatewayRepository, + encryptionKeyStr string, + slogger *slog.Logger, +) (*WebSubAPIHmacSecretService, error) { + if encryptionKeyStr == "" { + return nil, fmt.Errorf("%w", constants.ErrHmacSecretEncryptionKeyMissing) + } + key, err := utils.DeriveEncryptionKey(encryptionKeyStr) + if err != nil { + return nil, fmt.Errorf("invalid HMAC secret encryption key: %w", err) + } + return &WebSubAPIHmacSecretService{ + repo: repo, + websubRepo: websubRepo, + encryptionKey: key, + gatewayEventsService: gatewayEventsService, + gatewayRepo: gatewayRepo, + slogger: slogger, + }, nil +} + +// Generate creates a new HMAC secret for the given WebSub API. +// externalSecret is an optional caller-supplied value; if empty, one is auto-generated. +// Returns the metadata and the plaintext value (returned once, never stored). +func (s *WebSubAPIHmacSecretService) Generate(orgUUID, apiHandle, displayName, externalSecret string) (*model.WebSubAPIHmacSecret, string, error) { + api, err := s.websubRepo.GetByHandle(apiHandle, orgUUID) + if err != nil { + return nil, "", fmt.Errorf("failed to look up WebSub API: %w", err) + } + if api == nil { + return nil, "", constants.ErrWebSubAPINotFound + } + + name := slugifyHmacSecret(displayName) + if len(name) > 63 { + name = name[:63] + } + if name == "" { + name = "secret-" + apiHandle + } + + var plaintext string + if externalSecret != "" { + if len(externalSecret) < 32 { + return nil, "", constants.ErrHmacSecretInvalidValue + } + plaintext = externalSecret + } else { + plaintext, err = generateHmacSecretValue() + if err != nil { + return nil, "", fmt.Errorf("failed to generate secret value: %w", err) + } + } + + ciphertext, err := utils.EncryptSubscriptionToken(s.encryptionKey, plaintext) + if err != nil { + return nil, "", fmt.Errorf("failed to encrypt secret: %w", err) + } + + id, err := utils.GenerateUUID() + if err != nil { + return nil, "", fmt.Errorf("failed to generate UUID: %w", err) + } + + secret := &model.WebSubAPIHmacSecret{ + UUID: id, + ArtifactUUID: api.UUID, + Name: name, + DisplayName: displayName, + EncryptedSecret: ciphertext, + Status: "active", + } + + if err := s.repo.Create(secret); err != nil { + if isUniqueConstraintError(err) { + return nil, "", constants.ErrHmacSecretAlreadyExists + } + return nil, "", fmt.Errorf("failed to persist HMAC secret: %w", err) + } + + s.broadcastSecretEvent(orgUUID, api.UUID, name, "CREATED") + return secret, plaintext, nil +} + +// List returns all HMAC secrets for a WebSub API (no plaintext values). +func (s *WebSubAPIHmacSecretService) List(orgUUID, apiHandle string) ([]*model.WebSubAPIHmacSecret, error) { + api, err := s.websubRepo.GetByHandle(apiHandle, orgUUID) + if err != nil { + return nil, fmt.Errorf("failed to look up WebSub API: %w", err) + } + if api == nil { + return nil, constants.ErrWebSubAPINotFound + } + return s.repo.ListByArtifact(api.UUID) +} + +// Regenerate replaces the secret value for an existing named secret. +// externalSecret is an optional caller-supplied value; if empty, a new value is auto-generated. +// Returns the metadata and new plaintext (returned once, never stored). +func (s *WebSubAPIHmacSecretService) Regenerate(orgUUID, apiHandle, secretName, externalSecret string) (*model.WebSubAPIHmacSecret, string, error) { + api, err := s.websubRepo.GetByHandle(apiHandle, orgUUID) + if err != nil { + return nil, "", fmt.Errorf("failed to look up WebSub API: %w", err) + } + if api == nil { + return nil, "", constants.ErrWebSubAPINotFound + } + + existing, err := s.repo.GetByArtifactAndName(api.UUID, secretName) + if err != nil { + return nil, "", fmt.Errorf("failed to look up HMAC secret: %w", err) + } + if existing == nil { + return nil, "", constants.ErrHmacSecretNotFound + } + + var plaintext string + if externalSecret != "" { + if len(externalSecret) < 32 { + return nil, "", constants.ErrHmacSecretInvalidValue + } + plaintext = externalSecret + } else { + plaintext, err = generateHmacSecretValue() + if err != nil { + return nil, "", fmt.Errorf("failed to generate secret value: %w", err) + } + } + + ciphertext, err := utils.EncryptSubscriptionToken(s.encryptionKey, plaintext) + if err != nil { + return nil, "", fmt.Errorf("failed to encrypt secret: %w", err) + } + + existing.EncryptedSecret = ciphertext + if err := s.repo.Update(existing); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, "", constants.ErrHmacSecretNotFound + } + return nil, "", fmt.Errorf("failed to update HMAC secret: %w", err) + } + + s.broadcastSecretEvent(orgUUID, api.UUID, secretName, "UPDATED") + return existing, plaintext, nil +} + +// Delete permanently removes a named HMAC secret. +func (s *WebSubAPIHmacSecretService) Delete(orgUUID, apiHandle, secretName string) error { + api, err := s.websubRepo.GetByHandle(apiHandle, orgUUID) + if err != nil { + return fmt.Errorf("failed to look up WebSub API: %w", err) + } + if api == nil { + return constants.ErrWebSubAPINotFound + } + + existing, err := s.repo.GetByArtifactAndName(api.UUID, secretName) + if err != nil { + return fmt.Errorf("failed to look up HMAC secret: %w", err) + } + if existing == nil { + return constants.ErrHmacSecretNotFound + } + + if err := s.repo.Delete(api.UUID, secretName); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return constants.ErrHmacSecretNotFound + } + return fmt.Errorf("failed to delete HMAC secret: %w", err) + } + + s.broadcastSecretEvent(orgUUID, api.UUID, secretName, "DELETED") + return nil +} + +// DecryptSecret returns the plaintext for a given HMAC secret (used by the internal gateway endpoint). +func (s *WebSubAPIHmacSecretService) DecryptSecret(secret *model.WebSubAPIHmacSecret) (string, error) { + return utils.DecryptSubscriptionToken(s.encryptionKey, secret.EncryptedSecret) +} + +// ListByArtifactUUID returns all HMAC secrets for an artifact UUID (used by internal gateway endpoint). +func (s *WebSubAPIHmacSecretService) ListByArtifactUUID(artifactUUID string) ([]*model.WebSubAPIHmacSecret, error) { + return s.repo.ListByArtifact(artifactUUID) +} + +// broadcastSecretEvent sends a HMAC secret change event to all gateways in the org. +func (s *WebSubAPIHmacSecretService) broadcastSecretEvent(orgUUID, artifactUUID, secretName, action string) { + if s.gatewayEventsService == nil || s.gatewayRepo == nil { + return + } + gateways, err := s.gatewayRepo.GetByOrganizationID(orgUUID) + if err != nil { + s.slogger.Warn("Failed to list gateways for HMAC secret event broadcast", + slog.String("artifactUUID", artifactUUID), slog.Any("error", err)) + return + } + event := &model.WebSubAPIHmacSecretEvent{ + ArtifactUUID: artifactUUID, + SecretName: secretName, + } + for _, gw := range gateways { + if err := s.gatewayEventsService.BroadcastWebSubAPIHmacSecretEvent(gw.ID, action, event); err != nil { + s.slogger.Warn("Failed to broadcast HMAC secret event", + slog.String("gatewayID", gw.ID), slog.String("action", action), slog.Any("error", err)) + } + } +} + +// generateHmacSecretValue generates a cryptographically secure HMAC secret. +// Format: whsec_ + hex(32 random bytes) = 70 characters. +func generateHmacSecretValue() (string, error) { + b := make([]byte, hmacSecretLen) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) + } + return hmacSecretPrefix + hex.EncodeToString(b), nil +} + +// isUniqueConstraintError reports whether err is a unique-constraint violation from +// SQLite ("UNIQUE constraint failed") or PostgreSQL ("duplicate key value"). +func isUniqueConstraintError(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "UNIQUE constraint failed") || + strings.Contains(msg, "duplicate key value") +} + +// slugifyHmacSecret converts a display name to a URL-safe slug, e.g. "My GitHub Secret" → "my-github-secret". +func slugifyHmacSecret(displayName string) string { + s := strings.ToLower(strings.TrimSpace(displayName)) + var b strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-': + b.WriteRune(r) + case r == ' ' || r == '_': + b.WriteRune('-') + } + } + return strings.Trim(b.String(), "-") +} diff --git a/platform-api/src/resources/gateway-internal-api.yaml b/platform-api/src/resources/gateway-internal-api.yaml index 2b45555a22..0f2c76e718 100644 --- a/platform-api/src/resources/gateway-internal-api.yaml +++ b/platform-api/src/resources/gateway-internal-api.yaml @@ -778,6 +778,51 @@ paths: message: "Internal Server Error" description: "Failed to create WebSub API package" + /websub-apis/{apiId}/hmac-secrets: + get: + summary: Get HMAC secrets for a WebSub API + description: | + Returns all active HMAC secrets (with plaintext values) for the specified WebSub API. + This endpoint is exclusively for gateway-controller to load secrets into the envoy secret store. + The plaintext values are decrypted server-side and must only be used internally by the gateway. + operationId: getWebSubAPIHmacSecrets + tags: + - Gateway Internal APIs + parameters: + - name: apiId + in: path + required: true + description: The unique identifier (UUID) of the WebSub API + schema: + type: string + format: uuid + example: "550e8400-e29b-41d4-a716-446655440000" + responses: + '200': + description: Successfully retrieved HMAC secrets + content: + application/json: + schema: + $ref: '#/components/schemas/GatewayHmacSecretsResponse' + '401': + description: Unauthorized - Invalid or missing API key + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: WebSub API not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + # ========================================== # Gateway Deployment Sync Endpoints # ========================================== @@ -1757,6 +1802,39 @@ components: description: Issuer of the API key, if applicable example: "api-platform-devportal" + GatewayHmacSecretInfo: + type: object + description: A single HMAC secret entry returned to the gateway-controller. Contains the plaintext value. + required: + - name + - secret + properties: + name: + type: string + description: URL-safe slug identifying the secret. + example: github-webhook + secret: + type: string + description: Plaintext HMAC secret value for use in signature verification. + example: whsec_a3f2c1e4b5d6789012345678901234567890abcdef1234567890abcdef12345678 + + GatewayHmacSecretsResponse: + type: object + description: Response for GET /websub-apis/{apiId}/hmac-secrets + required: + - artifactId + - secrets + properties: + artifactId: + type: string + format: uuid + description: UUID of the WebSub API artifact. + example: "550e8400-e29b-41d4-a716-446655440000" + secrets: + type: array + items: + $ref: '#/components/schemas/GatewayHmacSecretInfo' + ErrorResponse: type: object required: diff --git a/platform-api/src/resources/openapi.yaml b/platform-api/src/resources/openapi.yaml index 3d14d43029..db2d17216d 100644 --- a/platform-api/src/resources/openapi.yaml +++ b/platform-api/src/resources/openapi.yaml @@ -6453,6 +6453,173 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /websub-apis/{apiId}/hmac-secrets: + post: + summary: Generate an HMAC secret for a WebSub API + description: | + Generates (or stores an externally supplied) HMAC secret for the specified WebSub API. + The plaintext secret value is returned **once** in the response and never stored unencrypted — + save it immediately. Subsequent calls to this endpoint create additional named secrets; + all active secrets are accepted during HMAC signature verification to support zero-downtime rotation. + operationId: createWebSubAPIHmacSecret + security: + - OAuth2Security: + - ap:websub_api:hmac_secret:create + - ap:websub_api:hmac_secret:manage + - ap:websub_api:manage + tags: + - WebSubAPIs + parameters: + - name: apiId + in: path + required: true + description: Unique handle of the WebSub API + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WebSubAPIHmacSecretRequest' + responses: + '201': + description: HMAC secret created — plaintext returned once + content: + application/json: + schema: + $ref: '#/components/schemas/WebSubAPIHmacSecretCreationResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '503': + description: Service Unavailable — HMAC secret management not configured on this server + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalServerError' + + get: + summary: List HMAC secrets for a WebSub API + description: Returns metadata for all HMAC secrets belonging to the WebSub API. Plaintext values are never returned. + operationId: listWebSubAPIHmacSecrets + security: + - OAuth2Security: + - ap:websub_api:hmac_secret:read + - ap:websub_api:hmac_secret:manage + - ap:websub_api:manage + tags: + - WebSubAPIs + parameters: + - name: apiId + in: path + required: true + schema: + type: string + responses: + '200': + description: List of HMAC secret metadata + content: + application/json: + schema: + $ref: '#/components/schemas/WebSubAPIHmacSecretListResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + /websub-apis/{apiId}/hmac-secrets/{secretName}: + delete: + summary: Delete an HMAC secret for a WebSub API + description: Permanently removes the named HMAC secret. The secret will no longer be accepted for signature verification. + operationId: deleteWebSubAPIHmacSecret + security: + - OAuth2Security: + - ap:websub_api:hmac_secret:delete + - ap:websub_api:hmac_secret:manage + - ap:websub_api:manage + tags: + - WebSubAPIs + parameters: + - name: apiId + in: path + required: true + schema: + type: string + - name: secretName + in: path + required: true + description: Name (slug) of the HMAC secret + schema: + type: string + responses: + '204': + description: HMAC secret deleted + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + /websub-apis/{apiId}/hmac-secrets/{secretName}/regenerate: + post: + summary: Regenerate an HMAC secret value + description: | + Replaces the value of an existing HMAC secret with a newly generated (or caller-supplied) value. + The new plaintext is returned **once** in the response. All gateways where the API is deployed + are notified to reload their secret stores. + operationId: regenerateWebSubAPIHmacSecret + security: + - OAuth2Security: + - ap:websub_api:hmac_secret:update + - ap:websub_api:hmac_secret:manage + - ap:websub_api:manage + tags: + - WebSubAPIs + parameters: + - name: apiId + in: path + required: true + schema: + type: string + - name: secretName + in: path + required: true + description: Name (slug) of the HMAC secret to regenerate + schema: + type: string + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/WebSubAPIHmacSecretRequest' + responses: + '200': + description: HMAC secret regenerated — new plaintext returned once + content: + application/json: + schema: + $ref: '#/components/schemas/WebSubAPIHmacSecretCreationResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + /webbroker-apis: post: summary: Create a new WebBroker API @@ -11532,6 +11699,84 @@ components: on_message_delivery: $ref: '#/components/schemas/WebSubEventPolicies' + WebSubAPIHmacSecretRequest: + type: object + required: + - displayName + properties: + displayName: + type: string + description: Human-readable label for the HMAC secret (used to derive the URL-safe name/slug). + example: GitHub Webhook + secret: + type: string + description: | + Optional. If provided, this value is used as the HMAC secret instead of auto-generating one. + Must be at least 32 characters long. + minLength: 32 + example: my-external-secret-value-that-is-at-least-32-chars + + WebSubAPIHmacSecretInfo: + type: object + required: + - uuid + - name + - displayName + - status + - createdAt + - updatedAt + properties: + uuid: + type: string + description: Unique identifier for the HMAC secret. + name: + type: string + description: URL-safe slug derived from the display name. + example: github-webhook + displayName: + type: string + description: Human-readable label. + example: GitHub Webhook + status: + type: string + description: Status of the HMAC secret. + example: active + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + + WebSubAPIHmacSecretCreationResponse: + type: object + required: + - secret + - message + properties: + secret: + type: string + description: | + The plaintext HMAC secret value. This is returned **once** at creation/regeneration time + and is never stored unencrypted — save it immediately. + example: whsec_a3f2c1e4b5d6789012345678901234567890abcdef1234567890abcdef12345678 + webhookSecret: + $ref: '#/components/schemas/WebSubAPIHmacSecretInfo' + message: + type: string + description: Human-readable confirmation message. + example: HMAC secret created successfully + + WebSubAPIHmacSecretListResponse: + type: object + required: + - secrets + properties: + secrets: + type: array + items: + $ref: '#/components/schemas/WebSubAPIHmacSecretInfo' + WebBrokerAPI: type: object required: From 703fa3884f029c210dc7aaeef332338f6bab0f2e Mon Sep 17 00:00:00 2001 From: Tharindu Dharmarathna Date: Sun, 21 Jun 2026 21:41:38 +0530 Subject: [PATCH 2/2] seperate event gateway controller code from gateway-controller --- event-gateway/Makefile | 51 +- event-gateway/gateway-controller/Dockerfile | 137 +++ event-gateway/gateway-controller/Makefile | 124 +++ .../gateway-controller/cmd/main/main.go | 41 + event-gateway/gateway-controller/go.mod | 102 ++ event-gateway/gateway-controller/go.sum | 251 +++++ .../pkg/eventgateway/extension.go | 138 +++ .../sql/event-gateway-db.postgres.sql | 57 ++ .../pkg/eventgateway/sql/event-gateway-db.sql | 57 ++ .../eventgateway/subscription_processor.go | 80 ++ .../eventgateway/webhook_secret_processor.go | 183 ++++ .../gateway-controller/cmd/controller/main.go | 934 +----------------- .../cmd/controller/main_test.go | 33 +- .../gateway-controller/pkg/bootstrap/auth.go | 190 ++++ .../gateway-controller/pkg/bootstrap/run.go | 757 ++++++++++++++ .../bootstrap/runtime.go} | 25 +- .../bootstrap/runtime_test.go} | 20 +- .../pkg/controllerext/extension.go | 149 +++ .../pkg/eventlistener/api_processor.go | 20 - ..._processor.go => application_processor.go} | 55 -- .../pkg/eventlistener/listener.go | 58 +- .../pkg/eventlistener/listener_test.go | 75 +- .../subscription_processor_test.go | 202 ---- .../eventlistener/webhook_secret_processor.go | 171 ---- .../pkg/policyxds/combined_cache.go | 26 +- .../pkg/policyxds/combined_cache_test.go | 7 +- .../pkg/policyxds/server.go | 73 +- .../pkg/storage/event-gateway-db.postgres.sql | 57 ++ .../pkg/storage/event-gateway-db.sql | 57 ++ .../gateway-controller/pkg/storage/factory.go | 7 +- .../gateway-controller-db.postgres.sql | 65 +- .../pkg/storage/gateway-controller-db.sql | 58 -- .../pkg/storage/postgres.go | 18 +- .../pkg/storage/sql_store.go | 6 - .../gateway-controller/pkg/storage/sqlite.go | 18 +- .../pkg/storage/sqlite_test.go | 67 +- .../tests/integration/schema_test.go | 32 - go.work | 1 + 38 files changed, 2613 insertions(+), 1789 deletions(-) create mode 100644 event-gateway/gateway-controller/Dockerfile create mode 100644 event-gateway/gateway-controller/Makefile create mode 100644 event-gateway/gateway-controller/cmd/main/main.go create mode 100644 event-gateway/gateway-controller/go.mod create mode 100644 event-gateway/gateway-controller/go.sum create mode 100644 event-gateway/gateway-controller/pkg/eventgateway/extension.go create mode 100644 event-gateway/gateway-controller/pkg/eventgateway/sql/event-gateway-db.postgres.sql create mode 100644 event-gateway/gateway-controller/pkg/eventgateway/sql/event-gateway-db.sql create mode 100644 event-gateway/gateway-controller/pkg/eventgateway/subscription_processor.go create mode 100644 event-gateway/gateway-controller/pkg/eventgateway/webhook_secret_processor.go create mode 100644 gateway/gateway-controller/pkg/bootstrap/auth.go create mode 100644 gateway/gateway-controller/pkg/bootstrap/run.go rename gateway/gateway-controller/{cmd/controller/runtime_bootstrap.go => pkg/bootstrap/runtime.go} (82%) rename gateway/gateway-controller/{cmd/controller/runtime_bootstrap_test.go => pkg/bootstrap/runtime_test.go} (91%) create mode 100644 gateway/gateway-controller/pkg/controllerext/extension.go rename gateway/gateway-controller/pkg/eventlistener/{subscription_processor.go => application_processor.go} (78%) delete mode 100644 gateway/gateway-controller/pkg/eventlistener/subscription_processor_test.go delete mode 100644 gateway/gateway-controller/pkg/eventlistener/webhook_secret_processor.go create mode 100644 gateway/gateway-controller/pkg/storage/event-gateway-db.postgres.sql create mode 100644 gateway/gateway-controller/pkg/storage/event-gateway-db.sql diff --git a/event-gateway/Makefile b/event-gateway/Makefile index 78006f51c9..1f8379e886 100644 --- a/event-gateway/Makefile +++ b/event-gateway/Makefile @@ -69,29 +69,8 @@ help: ## Show this help message build: build-event-gateway-controller build-gateway-runtime build-webhook-listener ## Build all event gateway components .PHONY: build-event-gateway-controller -# TODO: drop the manual `policies` build-context (and event-gateway/default-policies/) -# once gateway-builder is integrated into the event-gateway build pipeline. build-event-gateway-controller: ## Build event-gateway-controller Docker image - @echo "Building event-gateway-controller Docker image ($(VERSION))..." - @mkdir -p ../gateway/gateway-controller/target - @cp ../LICENSE ../gateway/gateway-controller/target/ - @cd ../gateway/gateway-controller && \ - docker buildx build -f Dockerfile \ - --build-context sdk=../../sdk \ - --build-context sdk-core=../../sdk/core \ - --build-context common=../../common \ - --build-context build-manifest=.. \ - --build-context policies=../../event-gateway/default-policies \ - --build-context target=target \ - --build-arg VERSION=$(VERSION) \ - --build-arg FUNCTIONALITY_TYPE=event \ - --build-arg GIT_COMMIT=$$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") \ - --target production \ - -t $(EVENT_GATEWAY_CONTROLLER_IMAGE):$(VERSION) \ - -t $(EVENT_GATEWAY_CONTROLLER_IMAGE):latest \ - --load \ - . - @rm -rf ../gateway/gateway-controller/target + $(MAKE) -C gateway-controller build-docker VERSION=$(VERSION) .PHONY: build-gateway-runtime build-gateway-runtime: ## Build event-gateway-runtime Docker image @@ -108,30 +87,8 @@ build-webhook-listener: ## Build webhook-listener Docker image build-and-push-multiarch: build-and-push-multiarch-event-gateway-controller build-and-push-multiarch-gateway-runtime ## Build and push all event gateway components for multiple architectures .PHONY: build-and-push-multiarch-event-gateway-controller -# TODO: drop the manual `policies` build-context (and event-gateway/default-policies/) -# once gateway-builder is integrated into the event-gateway build pipeline. build-and-push-multiarch-event-gateway-controller: ## Build and push event-gateway-controller Docker image for multiple architectures - @echo "Building and pushing multi-arch event-gateway-controller Docker image ($(VERSION))..." - @mkdir -p ../gateway/gateway-controller/target - @cp ../LICENSE ../gateway/gateway-controller/target/ - @cd ../gateway/gateway-controller && \ - docker buildx build -f Dockerfile \ - --build-context sdk=../../sdk \ - --build-context sdk-core=../../sdk/core \ - --build-context common=../../common \ - --build-context build-manifest=.. \ - --build-context policies=../../event-gateway/default-policies \ - --build-context target=target \ - --platform linux/amd64,linux/arm64 \ - --build-arg VERSION=$(VERSION) \ - --build-arg FUNCTIONALITY_TYPE=event \ - --build-arg GIT_COMMIT=$$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") \ - --target production \ - -t $(EVENT_GATEWAY_CONTROLLER_IMAGE):$(VERSION) \ - -t $(EVENT_GATEWAY_CONTROLLER_IMAGE):latest \ - --push \ - . - @rm -rf ../gateway/gateway-controller/target + $(MAKE) -C gateway-controller build-and-push-multiarch VERSION=$(VERSION) .PHONY: build-and-push-multiarch-gateway-runtime build-and-push-multiarch-gateway-runtime: ## Build and push event-gateway-runtime Docker image for multiple architectures @@ -149,7 +106,7 @@ test: test-event-gateway-controller test-gateway-runtime ## Run all tests .PHONY: test-event-gateway-controller test-event-gateway-controller: ## Test event-gateway-controller - $(MAKE) -C ../gateway/gateway-controller test + $(MAKE) -C gateway-controller test .PHONY: test-gateway-runtime test-gateway-runtime: ## Test event-gateway-runtime @@ -200,6 +157,6 @@ version-get-release: ## Get release version (strips SNAPSHOT suffix) # Clean Targets .PHONY: clean clean: ## Clean all build artifacts - $(MAKE) -C ../gateway/gateway-controller clean + $(MAKE) -C gateway-controller clean $(MAKE) -C gateway-runtime clean $(MAKE) -C webhook-listener clean diff --git a/event-gateway/gateway-controller/Dockerfile b/event-gateway/gateway-controller/Dockerfile new file mode 100644 index 0000000000..a5b327ace2 --- /dev/null +++ b/event-gateway/gateway-controller/Dockerfile @@ -0,0 +1,137 @@ +# Stage 1: Build the Go application +FROM --platform=$BUILDPLATFORM golang:1.26.2-bookworm AS builder +ARG TARGETARCH +ARG VERSION=0.0.1-SNAPSHOT +ARG GIT_COMMIT=unknown + +# Coverage build argument - set to "true" for coverage-instrumented builds +ARG ENABLE_COVERAGE=false + +# Debug build argument - set to "true" for debug builds with dlv support +ARG ENABLE_DEBUG=false + +WORKDIR /build + +# Install build dependencies for SQLite (CGO) and cross-compilation toolchains +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \ + apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libc6-dev \ + libsqlite3-dev \ + && if [ "$TARGETARCH" = "amd64" ]; then \ + apt-get install -y --no-install-recommends gcc-x86-64-linux-gnu libc6-dev-amd64-cross; \ + elif [ "$TARGETARCH" = "arm64" ]; then \ + apt-get install -y --no-install-recommends gcc-aarch64-linux-gnu libc6-dev-arm64-cross; \ + fi + +# Copy go mod files for dependencies (needed for go.mod replace directives) +COPY --from=sdk-core go.mod /sdk/core/ +COPY --from=common go.mod go.sum /common/ +COPY --from=gateway-controller go.mod go.sum /gateway/gateway-controller/ + +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download + +# Copy full source trees needed by replace directives +COPY --from=sdk-core . /sdk/core +COPY --from=common . /common +COPY --from=gateway-controller . /gateway/gateway-controller +COPY . ./ + +# Build with CGO (required for SQLite), set CC for cross-compilation +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + if [ "$TARGETARCH" = "amd64" ]; then \ + export CC=x86_64-linux-gnu-gcc; \ + elif [ "$TARGETARCH" = "arm64" ]; then \ + export CC=aarch64-linux-gnu-gcc; \ + else \ + export CC=gcc; \ + fi && \ + if [ "$ENABLE_COVERAGE" = "true" ]; then \ + COVER_FLAG="-cover"; \ + else \ + COVER_FLAG=""; \ + fi && \ + export CGO_ENABLED=1 && \ + export GOOS=linux && \ + export GOARCH=${TARGETARCH} && \ + BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) && \ + VERSION_PKG=github.com/wso2/api-platform/gateway/gateway-controller/pkg/version && \ + LDFLAGS="-X ${VERSION_PKG}.Version=${VERSION} -X ${VERSION_PKG}.GitCommit=${GIT_COMMIT} -X ${VERSION_PKG}.BuildDate=${BUILD_DATE}" && \ + if [ "$ENABLE_DEBUG" = "true" ]; then \ + go build $COVER_FLAG \ + -gcflags "all=-N -l" \ + -ldflags "$LDFLAGS" \ + -o event-gateway-controller ./cmd/main; \ + else \ + go build $COVER_FLAG \ + -ldflags "$LDFLAGS" \ + -o event-gateway-controller ./cmd/main; \ + fi + +# Stage: debug (select with --target debug; NOT part of the default build) +FROM golang:1.26.2-bookworm AS debug +WORKDIR /app + +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + go install github.com/go-delve/delve/cmd/dlv@v1.26.0 + +COPY --from=builder /build/event-gateway-controller . +COPY --from=target LICENSE /app/LICENSE + +RUN mkdir -p /app/data + +EXPOSE 2345 9090 18000 + +ENTRYPOINT ["/go/bin/dlv", "exec", "/app/event-gateway-controller", \ + "--listen=:2345", "--headless=true", \ + "--api-version=2", "--accept-multiclient", "--"] + +# Stage 2: Runtime image +FROM ubuntu:24.04 AS production + +ARG VERSION=0.0.1-SNAPSHOT +ARG ENABLE_COVERAGE=false + +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \ + apt-get update && \ + apt-get upgrade -y && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + wget && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=builder /build/event-gateway-controller . +COPY --from=target LICENSE /app/LICENSE + +RUN mkdir -p /app/data && \ + if [ "$ENABLE_COVERAGE" = "true" ]; then mkdir -p /coverage; fi + +RUN groupadd -r -g 10001 wso2 && \ + useradd -r -u 10001 -g wso2 -s /usr/sbin/nologin -c "WSO2 Application User" wso2 && \ + chown -R wso2:wso2 /app/data && \ + chmod -R 755 /app/data && \ + if [ "$ENABLE_COVERAGE" = "true" ]; then \ + chown -R wso2:wso2 /coverage && \ + chmod -R 755 /coverage; \ + fi + +ENV GOCOVERDIR=/coverage + +USER wso2 + +EXPOSE 9090 18000 + +LABEL org.opencontainers.image.title="API Platform Event Gateway Controller" +LABEL org.opencontainers.image.description="Event Gateway Controller with WebSub/WebBroker support and xDS control plane" +LABEL org.opencontainers.image.vendor="WSO2" +LABEL org.opencontainers.image.version="${VERSION}" + +ENTRYPOINT ["/app/event-gateway-controller"] diff --git a/event-gateway/gateway-controller/Makefile b/event-gateway/gateway-controller/Makefile new file mode 100644 index 0000000000..7b066b939c --- /dev/null +++ b/event-gateway/gateway-controller/Makefile @@ -0,0 +1,124 @@ +# -------------------------------------------------------------------- +# 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. +# -------------------------------------------------------------------- + +# Makefile for Event-Gateway Controller + +VERSION ?= $(shell cat ../VERSION 2>/dev/null || echo "0.0.1-SNAPSHOT") +GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +BUILD_DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") +VERSION_PKG := github.com/wso2/api-platform/gateway/gateway-controller/pkg/version +LDFLAGS := -X $(VERSION_PKG).Version=$(VERSION) -X $(VERSION_PKG).GitCommit=$(GIT_COMMIT) -X $(VERSION_PKG).BuildDate=$(BUILD_DATE) + +DOCKER_REGISTRY ?= ghcr.io/wso2/api-platform +IMAGE_NAME := $(DOCKER_REGISTRY)/event-gateway-controller + +.PHONY: help build build-local build-docker build-debug build-and-push-multiarch test clean + +help: ## Show this help message + @echo 'Event Gateway Controller Build System (Version: $(VERSION))' + @echo '' + @echo 'Build Targets:' + @echo ' make build - Build Docker image' + @echo ' make build-local - Build event-gateway-controller binary locally' + @echo ' make build-docker - Build Docker image' + @echo ' make build-debug - Build Docker image with Delve remote debugger (port 2345)' + @echo ' make build-and-push-multiarch - Build and push multi-arch Docker image' + @echo '' + @echo 'Test Targets:' + @echo ' make test - Run all tests' + @echo '' + @echo 'Clean Targets:' + @echo ' make clean - Clean build artifacts' + +.PHONY: build +build: build-docker ## Build Docker image + +.PHONY: build-local +build-local: ## Build event-gateway-controller binary locally + @echo "Building event-gateway-controller binary..." + @mkdir -p target + CGO_ENABLED=1 go build \ + -ldflags "$(LDFLAGS)" \ + -o target/event-gateway-controller \ + ./cmd/main + @echo "Binary: target/event-gateway-controller" + +.PHONY: build-docker +build-docker: ## Build Docker image using buildx + @echo "Building Docker image ($(IMAGE_NAME):$(VERSION))..." + @mkdir -p ../../target + @cp ../../LICENSE ../../target/ + docker buildx build -f Dockerfile \ + --build-context sdk-core=../../sdk/core \ + --build-context common=../../common \ + --build-context gateway-controller=../../gateway/gateway-controller \ + --build-context target=../../target \ + --build-arg VERSION=$(VERSION) \ + --build-arg GIT_COMMIT=$(GIT_COMMIT) \ + --target production \ + -t $(IMAGE_NAME):$(VERSION) \ + --load \ + . + @echo "Image built: $(IMAGE_NAME):$(VERSION)" + +.PHONY: build-debug +build-debug: ## Build Docker image with Delve debugger (port 2345) + @echo "Building debug Docker image ($(IMAGE_NAME):$(VERSION)-debug)..." + @mkdir -p ../../target + @cp ../../LICENSE ../../target/ + docker buildx build -f Dockerfile \ + --build-context sdk-core=../../sdk/core \ + --build-context common=../../common \ + --build-context gateway-controller=../../gateway/gateway-controller \ + --build-context target=../../target \ + --build-arg VERSION=$(VERSION) \ + --build-arg GIT_COMMIT=$(GIT_COMMIT) \ + --build-arg ENABLE_DEBUG=true \ + --target debug \ + -t $(IMAGE_NAME):$(VERSION)-debug \ + --load \ + . + +.PHONY: build-and-push-multiarch +build-and-push-multiarch: ## Build and push multi-arch Docker image to registry + @echo "Building and pushing multi-arch image ($(IMAGE_NAME):$(VERSION))..." + @mkdir -p ../../target + @cp ../../LICENSE ../../target/ + docker buildx build -f Dockerfile \ + --build-context sdk-core=../../sdk/core \ + --build-context common=../../common \ + --build-context gateway-controller=../../gateway/gateway-controller \ + --build-context target=../../target \ + --build-arg VERSION=$(VERSION) \ + --build-arg GIT_COMMIT=$(GIT_COMMIT) \ + --target production \ + --platform linux/amd64,linux/arm64 \ + -t $(IMAGE_NAME):$(VERSION) \ + --push \ + . + +.PHONY: test +test: ## Run all tests + @echo "Running tests..." + @go test -v ./... -cover -coverprofile=unit-test-coverage.txt + +.PHONY: clean +clean: ## Clean build artifacts + @rm -rf target/ + @rm -f unit-test-coverage.txt + @echo "Cleaned." diff --git a/event-gateway/gateway-controller/cmd/main/main.go b/event-gateway/gateway-controller/cmd/main/main.go new file mode 100644 index 0000000000..31516d0399 --- /dev/null +++ b/event-gateway/gateway-controller/cmd/main/main.go @@ -0,0 +1,41 @@ +/* + * 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 main + +import ( + "flag" + "fmt" + "os" + + "github.com/wso2/api-platform/event-gateway/gateway-controller/pkg/eventgateway" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/bootstrap" +) + +func main() { + configPath := flag.String("config", "", "Path to configuration file (required)") + flag.Parse() + + if *configPath == "" { + fmt.Fprintf(os.Stderr, "Error: -config flag is required\n") + fmt.Fprintf(os.Stderr, "Usage: %s -config \n", os.Args[0]) + os.Exit(1) + } + + bootstrap.Run(*configPath, eventgateway.NewExtension()) +} diff --git a/event-gateway/gateway-controller/go.mod b/event-gateway/gateway-controller/go.mod new file mode 100644 index 0000000000..99056140ba --- /dev/null +++ b/event-gateway/gateway-controller/go.mod @@ -0,0 +1,102 @@ +module github.com/wso2/api-platform/event-gateway/gateway-controller + +go 1.26.2 + +require ( + github.com/gin-gonic/gin v1.11.0 + github.com/wso2/api-platform/common v0.0.0 + github.com/wso2/api-platform/gateway/gateway-controller v0.0.0 +) + +require ( + cel.dev/expr v0.25.1 // indirect + github.com/MicahParks/jwkset v0.11.0 // indirect + github.com/MicahParks/keyfunc/v3 v3.7.0 // indirect + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.14.2 // indirect + github.com/bytedance/sonic/loader v0.4.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect + github.com/envoyproxy/go-control-plane v0.14.0 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect + github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/getkin/kin-openapi v0.133.0 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.29.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.1 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.9.2 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jmoiron/sqlx v1.4.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/knadh/koanf/parsers/toml/v2 v2.2.0 // indirect + github.com/knadh/koanf/providers/env v1.1.0 // indirect + github.com/knadh/koanf/providers/file v1.2.1 // indirect + github.com/knadh/koanf/v2 v2.3.2 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.41 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oapi-codegen/runtime v1.1.2 // indirect + github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect + github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.57.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + github.com/woodsbury/decimal128 v1.3.0 // indirect + github.com/wso2/api-platform/sdk/core v0.2.12 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/arch v0.23.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/time v0.12.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace ( + github.com/wso2/api-platform/common => ../../common + github.com/wso2/api-platform/gateway/gateway-controller => ../../gateway/gateway-controller +) diff --git a/event-gateway/gateway-controller/go.sum b/event-gateway/gateway-controller/go.sum new file mode 100644 index 0000000000..cb72422f12 --- /dev/null +++ b/event-gateway/gateway-controller/go.sum @@ -0,0 +1,251 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOhoyQ= +github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0= +github.com/MicahParks/keyfunc/v3 v3.7.0 h1:pdafUNyq+p3ZlvjJX1HWFP7MA3+cLpDtg69U3kITJGM= +github.com/MicahParks/keyfunc/v3 v3.7.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE= +github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980= +github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o= +github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= +github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.29.0 h1:lQlF5VNJWNlRbRZNeOIkWElR+1LL/OuHcc0Kp14w1xk= +github.com/go-playground/validator/v10 v10.29.0/go.mod h1:D6QxqeMlgIPuT02L66f2ccrZ7AGgHkzKmmTMZhk/Kc4= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE= +github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU= +github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= +github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/toml/v2 v2.2.0 h1:2nV7tHYJ5OZy2BynQ4mOJ6k5bDqbbCzRERLUKBytz3A= +github.com/knadh/koanf/parsers/toml/v2 v2.2.0/go.mod h1:JpjTeK1Ge1hVX0wbof5DMCuDBriR8bWgeQP98eeOZpI= +github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc= +github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY= +github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= +github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= +github.com/knadh/koanf/v2 v2.3.2 h1:Ee6tuzQYFwcZXQpc2MiVeC6qHMandf5SMUJJNoFp/c4= +github.com/knadh/koanf/v2 v2.3.2/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.41 h1:8p7Pwz5NHkEbWSqc/ygU4CBGubhFFkpgP9KwcdkAHNA= +github.com/mattn/go-sqlite3 v1.14.41/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= +github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10= +github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= +github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= +github.com/wso2/api-platform/sdk/core v0.2.12 h1:todO77VOlxw8bWniFK/GyEbuM1R5ELnULgR+37Xdrak= +github.com/wso2/api-platform/sdk/core v0.2.12/go.mod h1:vgNVzR16g9k5cun3VXZ7wDg8UGbPxsVU2TW8EbRCv0o= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= +golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/event-gateway/gateway-controller/pkg/eventgateway/extension.go b/event-gateway/gateway-controller/pkg/eventgateway/extension.go new file mode 100644 index 0000000000..dc39b14469 --- /dev/null +++ b/event-gateway/gateway-controller/pkg/eventgateway/extension.go @@ -0,0 +1,138 @@ +/* + * 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 eventgateway + +import ( + "context" + _ "embed" + "log/slog" + "time" + + "github.com/gin-gonic/gin" + "github.com/wso2/api-platform/common/webhooksecret" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/controllerext" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/subscriptionxds" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/webhooksecretxds" +) + +//go:embed sql/event-gateway-db.sql +var sqliteSchema string + +//go:embed sql/event-gateway-db.postgres.sql +var postgresSchema string + +// Extension implements controllerext.ControllerExtension and adds WebSub/WebBroker +// subscription management and webhook HMAC secret management to the base gateway-controller. +type Extension struct { + subscriptionSnapshotManager *subscriptionxds.SnapshotManager + webhookSecretStore *webhooksecret.WebhookSecretStore + webhookSecretSnapshotManager *webhooksecretxds.SnapshotManager + webhookSecretService *utils.WebhookSecretService +} + +// NewExtension creates a new EventGateway Extension. +func NewExtension() *Extension { + return &Extension{} +} + +func (e *Extension) Name() string { return "event-gateway" } + +// AdditionalSchemaSQL returns the event-gateway DDL for the given backend ("sqlite" or "postgres"). +func (e *Extension) AdditionalSchemaSQL(backend string) []string { + if backend == "postgres" { + return []string{postgresSchema} + } + return []string{sqliteSchema} +} + +// InitXDS creates the subscription and webhook-secret xDS managers and populates the +// EventGatewayWiring fields that the base controller's controlplane client and API server +// consume through nil-safe pointer checks. +func (e *Extension) InitXDS(ctx context.Context, deps controllerext.ExtensionDeps) (*controllerext.ExtensionXDS, error) { + e.subscriptionSnapshotManager = subscriptionxds.NewSnapshotManager(deps.DB, deps.Log) + + e.webhookSecretStore = webhooksecret.GetStoreInstance() + e.webhookSecretSnapshotManager = webhooksecretxds.NewSnapshotManager(e.webhookSecretStore, deps.Log) + + if deps.EncryptionProviderManager != nil { + e.webhookSecretService = utils.NewWebhookSecretService( + deps.DB, + deps.EncryptionProviderManager, + e.webhookSecretStore, + deps.EventHub, + deps.GatewayID, + deps.Log, + ) + } else { + deps.Log.Warn("No encryption providers configured; webhook secret service unavailable") + } + + return &controllerext.ExtensionXDS{ + ExtraCaches: []controllerext.NamedXDSCache{ + {Name: "subscription", Cache: e.subscriptionSnapshotManager.GetCache()}, + {Name: "webhook-secret", Cache: e.webhookSecretSnapshotManager.GetCache()}, + }, + EventGatewayWiring: controllerext.EventGatewayWiring{ + SubscriptionSnapshotUpdater: e.subscriptionSnapshotManager, + WebhookSecretStore: e.webhookSecretStore, + WebhookSecretSnapshotManager: e.webhookSecretSnapshotManager, + WebhookSecretService: e.webhookSecretService, + }, + }, nil +} + +// LoadOnStartup seeds in-memory state from the database. Webhook secrets are decrypted +// and loaded into the store; the subscription snapshot is built from active subscriptions. +func (e *Extension) LoadOnStartup(ctx context.Context, deps controllerext.ExtensionDeps, _ *controllerext.ExtensionXDS) error { + if deps.EncryptionProviderManager != nil { + if err := storage.LoadWebhookSecretsFromDatabase(deps.DB, deps.EncryptionProviderManager, e.webhookSecretStore); err != nil { + return err + } + if err := e.webhookSecretSnapshotManager.RefreshSnapshot(); err != nil { + deps.Log.Warn("Failed to build initial webhook secret xDS snapshot", slog.Any("error", err)) + } + } + + loadCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + return e.subscriptionSnapshotManager.UpdateSnapshot(loadCtx) +} + +// ExtraEventProcessors returns processors for subscription and webhook-secret events. +func (e *Extension) ExtraEventProcessors(deps controllerext.ExtensionDeps, _ *controllerext.ExtensionXDS) []controllerext.ExtraEventProcessor { + return []controllerext.ExtraEventProcessor{ + NewSubscriptionProcessor(e.subscriptionSnapshotManager, deps.Log), + NewWebhookSecretProcessor(deps.DB, e.webhookSecretStore, e.webhookSecretSnapshotManager, deps.EncryptionProviderManager, deps.Log), + } +} + +// RegisterRoutes is a no-op for now; event-gateway routes are already registered by the +// base controller's APIServer (which accepts nil-safe wiring for webhook secret operations). +func (e *Extension) RegisterRoutes(_ *gin.Engine, _ controllerext.ExtensionDeps, _ *controllerext.ExtensionXDS) error { + return nil +} + +// AdditionalResourceRoles returns nil because subscription/webhook-secret routes are +// already included in the base auth config. +func (e *Extension) AdditionalResourceRoles() map[string][]string { return nil } + +// Shutdown is a no-op; snapshot managers and stores do not hold external connections. +func (e *Extension) Shutdown(_ context.Context) {} diff --git a/event-gateway/gateway-controller/pkg/eventgateway/sql/event-gateway-db.postgres.sql b/event-gateway/gateway-controller/pkg/eventgateway/sql/event-gateway-db.postgres.sql new file mode 100644 index 0000000000..a09626bd51 --- /dev/null +++ b/event-gateway/gateway-controller/pkg/eventgateway/sql/event-gateway-db.postgres.sql @@ -0,0 +1,57 @@ +-- PostgreSQL Schema for Event-Gateway extension tables. +-- Applied after the base gateway-controller-db.postgres.sql schema. + +-- Subscription plans table (organization-scoped rate/billing plans) +CREATE TABLE IF NOT EXISTS subscription_plans ( + uuid TEXT NOT NULL, + gateway_id TEXT NOT NULL, + plan_name TEXT NOT NULL, + billing_plan TEXT, + stop_on_quota_reach BOOLEAN DEFAULT TRUE, + throttle_limit_count INTEGER, + throttle_limit_unit TEXT, + expiry_time TIMESTAMPTZ, + status TEXT NOT NULL CHECK(status IN ('ACTIVE', 'INACTIVE')) DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (gateway_id, uuid), + UNIQUE(gateway_id, plan_name) +); + +-- Subscriptions table (application-level subscriptions for APIs) +-- subscription_token_hash: for xDS validation and request validation (Platform-API stores original token) +CREATE TABLE IF NOT EXISTS subscriptions ( + uuid TEXT NOT NULL, + gateway_id TEXT NOT NULL, + api_id TEXT NOT NULL, + application_id TEXT, + subscription_token_hash TEXT NOT NULL, + subscription_plan_id TEXT, + billing_customer_id TEXT, + billing_subscription_id TEXT, + status TEXT NOT NULL CHECK(status IN ('ACTIVE', 'INACTIVE', 'REVOKED')) DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (gateway_id, uuid), + FOREIGN KEY (gateway_id, subscription_plan_id) REFERENCES subscription_plans(gateway_id, uuid), + UNIQUE(gateway_id, api_id, subscription_token_hash) +); +CREATE INDEX IF NOT EXISTS idx_subscriptions_application_id ON subscriptions(application_id); + +-- Per-API HMAC secrets for the websub-hmac-auth policy. +-- Ciphertext is AES-256-GCM encrypted; plaintext is never stored. +CREATE TABLE IF NOT EXISTS webhook_secrets ( + uuid TEXT NOT NULL, + gateway_id TEXT NOT NULL, + artifact_uuid TEXT NOT NULL, + name TEXT NOT NULL, + display_name TEXT NOT NULL, + ciphertext BYTEA NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'revoked')), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (gateway_id, uuid), + UNIQUE (gateway_id, artifact_uuid, name), + FOREIGN KEY (gateway_id, artifact_uuid) REFERENCES artifacts(gateway_id, uuid) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_webhook_secrets_artifact ON webhook_secrets(gateway_id, artifact_uuid); diff --git a/event-gateway/gateway-controller/pkg/eventgateway/sql/event-gateway-db.sql b/event-gateway/gateway-controller/pkg/eventgateway/sql/event-gateway-db.sql new file mode 100644 index 0000000000..05a1efe252 --- /dev/null +++ b/event-gateway/gateway-controller/pkg/eventgateway/sql/event-gateway-db.sql @@ -0,0 +1,57 @@ +-- SQLite Schema for Event-Gateway extension tables. +-- Applied after the base gateway-controller-db.sql schema. + +-- Subscription plans table (organization-scoped rate/billing plans) +CREATE TABLE IF NOT EXISTS subscription_plans ( + uuid TEXT NOT NULL, + gateway_id TEXT NOT NULL, + plan_name TEXT NOT NULL, + billing_plan TEXT, + stop_on_quota_reach INTEGER DEFAULT 1, + throttle_limit_count INTEGER, + throttle_limit_unit TEXT, + expiry_time TIMESTAMP, + status TEXT NOT NULL CHECK(status IN ('ACTIVE', 'INACTIVE')) DEFAULT 'ACTIVE', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (gateway_id, uuid), + UNIQUE(gateway_id, plan_name) +); + +-- Subscriptions table (application-level subscriptions for APIs) +-- subscription_token_hash: for xDS validation and request validation (Platform-API stores original token) +CREATE TABLE IF NOT EXISTS subscriptions ( + uuid TEXT NOT NULL, + gateway_id TEXT NOT NULL, + api_id TEXT NOT NULL, + application_id TEXT, + subscription_token_hash TEXT NOT NULL, + subscription_plan_id TEXT, + billing_customer_id TEXT, + billing_subscription_id TEXT, + status TEXT NOT NULL CHECK(status IN ('ACTIVE', 'INACTIVE', 'REVOKED')) DEFAULT 'ACTIVE', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (gateway_id, uuid), + FOREIGN KEY (gateway_id, subscription_plan_id) REFERENCES subscription_plans(gateway_id, uuid), + UNIQUE(gateway_id, api_id, subscription_token_hash) +); +CREATE INDEX IF NOT EXISTS idx_subscriptions_application_id ON subscriptions(application_id); + +-- Per-API HMAC secrets for the websub-hmac-auth policy. +-- Ciphertext is AES-256-GCM encrypted; plaintext is never stored. +CREATE TABLE IF NOT EXISTS webhook_secrets ( + uuid TEXT NOT NULL, + gateway_id TEXT NOT NULL, + artifact_uuid TEXT NOT NULL, + name TEXT NOT NULL, + display_name TEXT NOT NULL, + ciphertext BLOB NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'revoked')), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (gateway_id, uuid), + UNIQUE (gateway_id, artifact_uuid, name), + FOREIGN KEY (gateway_id, artifact_uuid) REFERENCES artifacts(gateway_id, uuid) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_webhook_secrets_artifact ON webhook_secrets(gateway_id, artifact_uuid); diff --git a/event-gateway/gateway-controller/pkg/eventgateway/subscription_processor.go b/event-gateway/gateway-controller/pkg/eventgateway/subscription_processor.go new file mode 100644 index 0000000000..abd3715f30 --- /dev/null +++ b/event-gateway/gateway-controller/pkg/eventgateway/subscription_processor.go @@ -0,0 +1,80 @@ +/* + * 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 eventgateway implements the event-gateway ControllerExtension for the +// gateway-controller, adding WebSub/WebBroker subscription management, subscription +// plan management, and webhook HMAC secret management on top of the base controller. +package eventgateway + +import ( + "context" + "log/slog" + "time" + + "github.com/wso2/api-platform/common/eventhub" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/subscriptionxds" +) + +// SubscriptionProcessor handles EventTypeSubscription and EventTypeSubscriptionPlan +// events by refreshing the local subscription xDS snapshot from the database. +// It implements controllerext.ExtraEventProcessor. +type SubscriptionProcessor struct { + snapshotManager *subscriptionxds.SnapshotManager + logger *slog.Logger +} + +// NewSubscriptionProcessor creates a SubscriptionProcessor backed by the given manager. +func NewSubscriptionProcessor(mgr *subscriptionxds.SnapshotManager, logger *slog.Logger) *SubscriptionProcessor { + return &SubscriptionProcessor{snapshotManager: mgr, logger: logger} +} + +// HandlesEventType reports whether this processor handles the given event type. +func (p *SubscriptionProcessor) HandlesEventType(t eventhub.EventType) bool { + return t == eventhub.EventTypeSubscription || t == eventhub.EventTypeSubscriptionPlan +} + +// Process dispatches the event to the appropriate handler. +func (p *SubscriptionProcessor) Process(_ context.Context, event eventhub.Event) { + switch event.EventType { + case eventhub.EventTypeSubscription: + p.refreshSnapshot("subscription", event) + case eventhub.EventTypeSubscriptionPlan: + p.refreshSnapshot("subscription_plan", event) + } +} + +func (p *SubscriptionProcessor) refreshSnapshot(resource string, event eventhub.Event) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := p.snapshotManager.UpdateSnapshot(ctx); err != nil { + p.logger.Error("Failed to refresh subscription snapshot from replica sync event", + slog.String("resource", resource), + slog.String("action", event.Action), + slog.String("entity_id", event.EntityID), + slog.String("event_id", event.EventID), + slog.Any("error", err)) + return + } + + p.logger.Info("Successfully refreshed subscription snapshot from replica sync event", + slog.String("resource", resource), + slog.String("action", event.Action), + slog.String("entity_id", event.EntityID), + slog.String("event_id", event.EventID)) +} diff --git a/event-gateway/gateway-controller/pkg/eventgateway/webhook_secret_processor.go b/event-gateway/gateway-controller/pkg/eventgateway/webhook_secret_processor.go new file mode 100644 index 0000000000..7bb3c02b46 --- /dev/null +++ b/event-gateway/gateway-controller/pkg/eventgateway/webhook_secret_processor.go @@ -0,0 +1,183 @@ +/* + * 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 eventgateway + +import ( + "context" + "log/slog" + + "github.com/wso2/api-platform/common/eventhub" + "github.com/wso2/api-platform/common/webhooksecret" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/encryption" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/webhooksecretxds" +) + +// WebhookSecretProcessor handles EventTypeWebhookSecret events by syncing the +// in-memory webhook secret store and refreshing the xDS snapshot. +// It implements controllerext.ExtraEventProcessor. +type WebhookSecretProcessor struct { + db storage.Storage + store *webhooksecret.WebhookSecretStore + snapshotManager *webhooksecretxds.SnapshotManager + encryptionManager *encryption.ProviderManager + logger *slog.Logger +} + +// NewWebhookSecretProcessor creates a WebhookSecretProcessor. +func NewWebhookSecretProcessor( + db storage.Storage, + store *webhooksecret.WebhookSecretStore, + snapshotManager *webhooksecretxds.SnapshotManager, + encryptionManager *encryption.ProviderManager, + logger *slog.Logger, +) *WebhookSecretProcessor { + return &WebhookSecretProcessor{ + db: db, + store: store, + snapshotManager: snapshotManager, + encryptionManager: encryptionManager, + logger: logger, + } +} + +// HandlesEventType reports whether this processor handles the given event type. +func (p *WebhookSecretProcessor) HandlesEventType(t eventhub.EventType) bool { + return t == eventhub.EventTypeWebhookSecret +} + +// Process dispatches the event to the appropriate handler. +func (p *WebhookSecretProcessor) Process(_ context.Context, event eventhub.Event) { + switch event.Action { + case "CREATE", "UPDATE": + p.handleUpsert(event) + case "DELETE": + p.handleDelete(event) + default: + p.logger.Warn("Unknown webhook secret event action", + slog.String("action", event.Action), + slog.String("entity_id", event.EntityID)) + } +} + +func (p *WebhookSecretProcessor) handleUpsert(event eventhub.Event) { + artifactUUID, secretUUID, _, err := webhooksecret.ParseWebhookSecretEntityID(event.EntityID) + if err != nil { + p.logger.Error("Failed to parse webhook secret event entity ID", + slog.String("action", event.Action), + slog.String("entity_id", event.EntityID), + slog.Any("error", err)) + return + } + + if p.store == nil || p.encryptionManager == nil { + p.logger.Warn("Webhook secret store or encryption manager not available, skipping upsert", + slog.String("secret_uuid", secretUUID)) + return + } + + ws, err := p.db.GetWebhookSecretByUUID(secretUUID) + if err != nil { + if storage.IsNotFoundError(err) { + p.logger.Warn("Webhook secret not found in database for upsert event", + slog.String("secret_uuid", secretUUID), + slog.String("event_id", event.EventID)) + return + } + p.logger.Error("Failed to fetch webhook secret from database", + slog.String("secret_uuid", secretUUID), + slog.Any("error", err)) + return + } + + payload, err := encryption.UnmarshalPayload(string(ws.Ciphertext)) + if err != nil { + p.logger.Error("Failed to unmarshal webhook secret ciphertext", + slog.String("secret_uuid", secretUUID), + slog.Any("error", err)) + return + } + + plaintext, err := p.encryptionManager.Decrypt(payload) + if err != nil { + p.logger.Error("Failed to decrypt webhook secret", + slog.String("secret_uuid", secretUUID), + slog.Any("error", err)) + return + } + + if err := p.store.Store(ws.ArtifactUUID, ws.Name, string(plaintext)); err != nil { + p.logger.Error("Failed to store webhook secret in memory store", + slog.String("secret_uuid", secretUUID), + slog.String("artifact_uuid", ws.ArtifactUUID), + slog.Any("error", err)) + return + } + + if p.snapshotManager != nil { + if err := p.snapshotManager.RefreshSnapshot(); err != nil { + p.logger.Error("Failed to refresh webhook secret xDS snapshot after upsert", + slog.String("artifact_uuid", ws.ArtifactUUID), + slog.Any("error", err)) + } + } + + p.logger.Info("Successfully processed webhook secret upsert event", + slog.String("action", event.Action), + slog.String("artifact_uuid", artifactUUID), + slog.String("secret_name", ws.Name), + slog.String("event_id", event.EventID)) +} + +func (p *WebhookSecretProcessor) handleDelete(event eventhub.Event) { + artifactUUID, secretUUID, secretName, err := webhooksecret.ParseWebhookSecretEntityID(event.EntityID) + if err != nil { + p.logger.Error("Failed to parse webhook secret delete event entity ID", + slog.String("entity_id", event.EntityID), + slog.Any("error", err)) + return + } + + if p.store == nil { + p.logger.Warn("Webhook secret store not available, skipping delete", + slog.String("secret_uuid", secretUUID)) + return + } + + if err := p.store.Remove(artifactUUID, secretName); err != nil && err != webhooksecret.ErrNotFound { + p.logger.Error("Failed to remove webhook secret from memory store", + slog.String("artifact_uuid", artifactUUID), + slog.String("secret_name", secretName), + slog.Any("error", err)) + return + } + + if p.snapshotManager != nil { + if err := p.snapshotManager.RefreshSnapshot(); err != nil { + p.logger.Error("Failed to refresh webhook secret xDS snapshot after delete", + slog.String("artifact_uuid", artifactUUID), + slog.Any("error", err)) + } + } + + p.logger.Info("Successfully processed webhook secret delete event", + slog.String("artifact_uuid", artifactUUID), + slog.String("secret_name", secretName), + slog.String("event_id", event.EventID)) +} diff --git a/gateway/gateway-controller/cmd/controller/main.go b/gateway/gateway-controller/cmd/controller/main.go index 43b0fc6dc1..d093fc6f5e 100644 --- a/gateway/gateway-controller/cmd/controller/main.go +++ b/gateway/gateway-controller/cmd/controller/main.go @@ -1,933 +1,41 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package main import ( - "context" - "errors" "flag" "fmt" - "log/slog" - "net/http" "os" - "os/signal" - "strings" - "syscall" - "time" - - "github.com/wso2/api-platform/common/eventhub" - "github.com/wso2/api-platform/common/webhooksecret" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/adminserver" - "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/eventlistener" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/lazyresourcexds" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/secrets" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/subscriptionxds" - - "github.com/gin-gonic/gin" - "github.com/wso2/api-platform/common/authenticators" - commonmodels "github.com/wso2/api-platform/common/models" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/handlers" - api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/middleware" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/controlplane" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/immutable" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/logger" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/metrics" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/policyxds" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/service/restapi" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/webhooksecretxds" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/transform" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/version" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/xds" -) -// API base paths for the gateway-controller HTTP surfaces. -// These must stay in sync with the `servers.url` values in the OpenAPI specs -// (api/management-openapi.yaml and api/admin-openapi.yaml). -const ( - managementAPIBasePath = "/api/management/v0.9" - adminAPIBasePath = "/api/admin/v0.9" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/bootstrap" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/controllerext" ) -func toBackendConfig(cfg *config.Config) storage.BackendConfig { - pg := cfg.Controller.Storage.Postgres - return storage.BackendConfig{ - Type: cfg.Controller.Storage.Type, - SQLitePath: cfg.Controller.Storage.SQLite.Path, - Postgres: storage.PostgresConnectionConfig{ - DSN: pg.DSN, - Host: pg.Host, - Port: pg.Port, - Database: pg.Database, - User: pg.User, - Password: pg.Password, - SSLMode: pg.SSLMode, - ConnectTimeout: pg.ConnectTimeout, - MaxOpenConns: pg.MaxOpenConns, - MaxIdleConns: pg.MaxIdleConns, - ConnMaxLifetime: pg.ConnMaxLifetime, - ConnMaxIdleTime: pg.ConnMaxIdleTime, - ApplicationName: pg.ApplicationName, - }, - GatewayID: cfg.Controller.Server.GatewayID, - } -} - func main() { - // Parse command-line flags configPath := flag.String("config", "", "Path to configuration file (required)") flag.Parse() - // Validate that config file is provided if *configPath == "" { fmt.Fprintf(os.Stderr, "Error: -config flag is required\n") fmt.Fprintf(os.Stderr, "Usage: %s -config \n", os.Args[0]) os.Exit(1) } - // Load configuration - cfg, err := config.LoadConfig(*configPath) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to load configuration from %s: %v\n", *configPath, err) - os.Exit(1) - } - - // Initialize metrics based on configuration - // This must be done before any metrics are used to ensure no-op behavior when disabled - metrics.SetEnabled(cfg.Controller.Metrics.Enabled) - metrics.Init() // Initialize metrics immediately so they're available throughout the codebase - - // Initialize logger with config - log := logger.NewLogger(logger.Config{ - Level: cfg.Controller.Logging.Level, - Format: cfg.Controller.Logging.Format, - }) - - log.Info("Starting Gateway-Controller", - slog.String("version", version.Version), - slog.String("git_commit", version.GitCommit), - slog.String("build_date", version.BuildDate), - slog.String("config_file", *configPath), - slog.String("storage_type", cfg.Controller.Storage.Type), - slog.Bool("access_logs_enabled", cfg.Router.AccessLogs.Enabled), - slog.String("control_plane_host", cfg.Controller.ControlPlane.Host), - slog.Bool("control_plane_token_configured", cfg.Controller.ControlPlane.Token != ""), - slog.Bool("skip_invalid_deployments_on_startup", cfg.Controller.Server.SkipInvalidDeploymentsOnStartup), - ) - - if !cfg.Controller.Auth.Basic.Enabled && !cfg.Controller.Auth.IDP.Enabled { - log.Warn("No authentication configured: both basic auth and IDP are disabled. Gateway Controller API will allow all requests without authentication") - } - - // In immutable mode, delete any stale SQLite files before opening the DB to - // guarantee a fresh, reproducible state on every boot. - if cfg.ImmutableGateway.Enabled { - log.Info("Immutable gateway mode enabled — removing existing SQLite files for fresh start", - slog.String("path", cfg.Controller.Storage.SQLite.Path)) - if err := immutable.ResetSQLiteFiles(cfg.Controller.Storage.SQLite.Path, log); err != nil { - log.Error("Failed to reset SQLite files for immutable mode", slog.Any("error", err)) - os.Exit(1) - } - } - - // Initialize storage based on type - var db storage.Storage - db, err = storage.NewStorage(toBackendConfig(cfg), log) - if err != nil { - if strings.EqualFold(cfg.Controller.Storage.Type, "sqlite") && errors.Is(err, storage.ErrDatabaseLocked) { - log.Error("Database is locked by another process", - slog.String("database_path", cfg.Controller.Storage.SQLite.Path), - slog.String("troubleshooting", "Check if another gateway-controller instance is running or remove stale WAL files")) - os.Exit(1) - } - log.Error("Failed to initialize database storage", - slog.String("type", cfg.Controller.Storage.Type), - slog.Any("error", err)) - os.Exit(1) - } - defer db.Close() - - // Initialize EventHub for multi-replica sync (requires persistent storage) - var eventHubInstance eventhub.EventHub - var eventHubStorage storage.Storage - // Create separate storage connection for EventHub (avoids SQLite lock contention) - ehBackendCfg := toBackendConfig(cfg) - ehBackendCfg.Postgres.MaxOpenConns = cfg.Controller.EventHub.Database.MaxOpenConns - ehBackendCfg.Postgres.MaxIdleConns = cfg.Controller.EventHub.Database.MaxIdleConns - ehBackendCfg.Postgres.ConnMaxLifetime = cfg.Controller.EventHub.Database.ConnMaxLifetime - ehBackendCfg.Postgres.ConnMaxIdleTime = cfg.Controller.EventHub.Database.ConnMaxIdleTime - eventHubStorage, err = storage.NewStorage(ehBackendCfg, log) - if err != nil { - log.Error("Failed to initialize EventHub storage", slog.Any("error", err)) - os.Exit(1) - } - eventHubDB := eventHubStorage.GetDB() - - gatewayID := strings.TrimSpace(cfg.Controller.Server.GatewayID) - if eventHubDB == nil { - log.Error("EventHub storage returned nil database handle") - os.Exit(1) - } - if gatewayID == "" { - log.Error("EventHub requires non-empty gateway ID") - os.Exit(1) - } - eventHubInstance = eventhub.New(eventHubDB, log, eventhub.Config{ - PollInterval: cfg.Controller.EventHub.PollInterval, - CleanupInterval: cfg.Controller.EventHub.CleanupInterval, - RetentionPeriod: cfg.Controller.EventHub.RetentionPeriod, - }) - if err := eventHubInstance.Initialize(); err != nil { - log.Error("Failed to initialize EventHub", slog.Any("error", err)) - os.Exit(1) - } - if err := eventHubInstance.RegisterGateway(gatewayID); err != nil { - log.Error("Failed to register gateway with EventHub", slog.Any("error", err)) - os.Exit(1) - } - log.Info("EventHub initialized for multi-replica sync", - slog.String("gateway_id", gatewayID)) - - // Initialize in-memory config store - configStore := storage.NewConfigStore() - - // Initialize in-memory API key store for xDS - apiKeyStore := storage.NewAPIKeyStore(log) - apiKeySnapshotManager := apikeyxds.NewAPIKeySnapshotManager(apiKeyStore, log) - apiKeyXDSManager := apikeyxds.NewAPIKeyStateManager(apiKeyStore, apiKeySnapshotManager, log) - - // Initialize in-memory webhook secret store (shared with the HMAC policy via the common package) - webhookSecretStore := webhooksecret.GetStoreInstance() - webhookSecretSnapshotManager := webhooksecretxds.NewSnapshotManager(webhookSecretStore, log) - - // Initialize in-memory lazy resource store and components for xDS - lazyResourceStore := storage.NewLazyResourceStore(log) - 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 - log.Info("Loading configurations from database") - if err := storage.LoadFromDatabase(db, configStore); err != nil { - log.Error("Failed to load configurations from database", slog.Any("error", err)) - os.Exit(1) - } - if err := storage.LoadLLMProviderTemplatesFromDatabase(db, configStore); err != nil { - log.Error("Failed to load llm provider template configurations from database", slog.Any("error", err)) - os.Exit(1) - } - log.Info("Loaded configurations", slog.Int("count", len(configStore.GetAll()))) - - // Load API keys from database into both in-memory stores - log.Info("Loading API keys from database") - if err := storage.LoadAPIKeysFromDatabase(db, configStore, apiKeyStore); err != nil { - log.Error("Failed to load API keys from database", slog.Any("error", err)) - os.Exit(1) - } - log.Info("Loaded API keys", slog.Int("count", apiKeyXDSManager.GetAPIKeyCount())) - - log.Info("Loading encryption providers") - if len(cfg.Controller.Encryption.Providers) > 0 { - log.Info("Initializing encryption providers", slog.Int("provider_count", len(cfg.Controller.Encryption.Providers))) - - // Initialize encryption providers - var providers []encryption.EncryptionProvider - for _, providerConfig := range cfg.Controller.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)) - os.Exit(1) - } - providers = append(providers, provider) - - default: - log.Error("Unsupported encryption provider type", slog.String("type", providerConfig.Type)) - os.Exit(1) - } - } - - // Create provider manager - encryptionProviderManager, err = encryption.NewProviderManager(providers, log) - if err != nil { - log.Error("Failed to initialize provider manager", slog.Any("error", err)) - os.Exit(1) - } - // Create secrets service - secretsService = secrets.NewSecretsService(db, encryptionProviderManager, log) - - // Load webhook secrets from database into the in-memory store - log.Info("Loading webhook secrets from database") - if err := storage.LoadWebhookSecretsFromDatabase(db, encryptionProviderManager, webhookSecretStore); err != nil { - log.Error("Failed to load webhook secrets from database", slog.Any("error", err)) - os.Exit(1) - } - log.Info("Loaded webhook secrets from database") - if err := webhookSecretSnapshotManager.RefreshSnapshot(); err != nil { - log.Warn("Failed to generate initial webhook secret xDS snapshot", slog.Any("error", err)) - } - } - log.Info("Loaded encryption providers") - - // Load policy definitions from files before any startup hydration or policy derivation. - policyLoader := utils.NewPolicyLoader(log) - policyDir := cfg.Controller.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))) - - // Detect custom policies from build-manifest.yaml. - localPolicies, err := policyLoader.GetCustomPolicyNames(cfg.Controller.Policies.BuildManifestPath) - if err != nil { - log.Warn("Could not read build-manifest.yaml, Custom policies will not be marked in the gateway manifest", - slog.String("path", cfg.Controller.Policies.BuildManifestPath), - slog.Any("error", err)) - } - for key, def := range policyDefinitions { - def.ManagedBy = "wso2" - if localPolicies[def.Name+"|"+def.Version] { - def.ManagedBy = "customer" - } - policyDefinitions[key] = def - } - - // MCP proxies and LLM artifacts are stored in source form and need to be - // rehydrated into their derived RestAPI representations before startup - // snapshot and policy work. - if err := hydrateStoredConfigsFromDatabaseOnStartup( - configStore, - db, - &cfg.Router, - policyDefinitions, - log, - cfg.Controller.Server.SkipInvalidDeploymentsOnStartup, - ); err != nil { - log.Error("Failed to hydrate stored configurations required for startup", slog.Any("error", err)) - os.Exit(1) - } - - // Initialize xDS snapshot manager with router config - snapshotManager := xds.NewSnapshotManager(configStore, log, &cfg.Router, db, cfg) - - // Initialize SDS secret manager if custom certificates are configured - var sdsSecretManager *xds.SDSSecretManager - translator := snapshotManager.GetTranslator() - if translator != nil && translator.GetCertStore() != nil { - // Use the same cache and node ID as the main xDS to ensure Envoy can fetch secrets - sdsSecretManager = xds.NewSDSSecretManager( - translator.GetCertStore(), - snapshotManager.GetCache(), - "router-node", // Same node ID as main xDS - log, - ) - // Update SDS secrets with current certificates - if err := sdsSecretManager.UpdateSecrets(); err != nil { - log.Warn("Failed to initialize SDS secrets", slog.Any("error", err)) - } else { - log.Info("SDS secret manager initialized successfully") - // Set the SDS secret manager in snapshot manager so secrets are included in snapshots - snapshotManager.SetSDSSecretManager(sdsSecretManager) - } - } - - // Generate initial xDS snapshot - log.Info("Generating initial xDS snapshot") - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - if err := snapshotManager.UpdateSnapshot(ctx, ""); err != nil { - log.Warn("Failed to generate initial xDS snapshot", slog.Any("error", err)) - } - cancel() - - // Create channels to detect when router and policy engine first connect - routerConnected := make(chan struct{}) - policyEngineConnected := make(chan struct{}) - - // Start xDS gRPC server with SDS support - xdsServer := xds.NewServer(snapshotManager, sdsSecretManager, cfg.Controller.Server.XDSPort, log, routerConnected) - go func() { - if err := xdsServer.Start(); err != nil { - log.Error("xDS server failed", slog.Any("error", err)) - os.Exit(1) - } - }() - - // Generate initial API key snapshot if API keys were loaded from database - if apiKeyXDSManager.GetAPIKeyCount() > 0 { - log.Info("Generating initial API key snapshot for policy engine", - slog.Int("api_key_count", apiKeyXDSManager.GetAPIKeyCount())) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - if err := apiKeySnapshotManager.UpdateSnapshot(ctx); err != nil { - log.Warn("Failed to generate initial API key snapshot", slog.Any("error", err)) - } else { - log.Info("Initial API key snapshot generated successfully") - } - cancel() - } - - // Initialize policy xDS server - log.Info("Initializing Policy xDS server", slog.Int("port", cfg.Controller.PolicyServer.Port)) - - // Initialize policy snapshot manager and runtime config store - policySnapshotManager := policyxds.NewSnapshotManager(log) - runtimeStore := storage.NewRuntimeConfigStore() - policySnapshotManager.SetRuntimeStore(runtimeStore) - policySnapshotManager.SetConfigStore(configStore) - - // Initialize subscription snapshot manager (driven by DB storage) - subscriptionSnapshotManager := subscriptionxds.NewSnapshotManager(db, log) - - // Initialize policy manager - policyManager := policyxds.NewPolicyManager(policySnapshotManager, log) - policyManager.SetRuntimeStore(runtimeStore) - - // Build transformer registry for StoredConfig → RuntimeDeployConfig conversion - policyVersionResolver := utils.NewLoadedPolicyVersionResolver(policyDefinitions) - restTransformer := transform.NewRestAPITransformer(&cfg.Router, cfg, policyDefinitions) - llmTransformer := transform.NewLLMTransformer(configStore, db, &cfg.Router, cfg, policyDefinitions, policyVersionResolver) - transformerRegistry := transform.NewRegistry(restTransformer, llmTransformer) - policyManager.SetTransformers(transformerRegistry) - - // Load runtime configs from existing API configurations on startup. - // We write directly to runtimeStore to avoid triggering N separate snapshot updates; - // the single UpdateSnapshot call below covers all of them. - log.Info("Loading runtime configs from existing API configurations") - loadedAPIs := configStore.GetAll() - loadedCount, err := loadRuntimeConfigsFromExistingAPIConfigurations( - loadedAPIs, - runtimeStore, - secretsService, - transformerRegistry, - log, - cfg.Controller.Server.SkipInvalidDeploymentsOnStartup, - ) - if err != nil { - log.Error("Failed to load runtime configs from API configurations", slog.Any("error", err)) - os.Exit(1) - } - log.Info("Loaded runtime configs from API configurations", - slog.Int("total_apis", len(loadedAPIs)), - slog.Int("configs_loaded", loadedCount)) - - // Generate initial policy snapshot - log.Info("Generating initial policy xDS snapshot") - ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second) - if err := policySnapshotManager.UpdateSnapshot(ctx); err != nil { - log.Warn("Failed to generate initial policy xDS snapshot", slog.Any("error", err)) - } - cancel() - - // Generate initial subscription snapshot - log.Info("Generating initial subscription xDS snapshot") - ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second) - if err := subscriptionSnapshotManager.UpdateSnapshot(ctx); err != nil { - log.Warn("Failed to generate initial subscription xDS snapshot", slog.Any("error", err)) - } - cancel() - - // Start policy xDS server in a separate goroutine - serverOpts := []policyxds.ServerOption{ - policyxds.WithOnFirstConnect(policyEngineConnected), - } - if cfg.Controller.PolicyServer.TLS.Enabled { - serverOpts = append(serverOpts, policyxds.WithTLS( - cfg.Controller.PolicyServer.TLS.CertFile, - cfg.Controller.PolicyServer.TLS.KeyFile, - )) - } - policyXDSServer := policyxds.NewServer(policySnapshotManager, apiKeySnapshotManager, lazyResourceSnapshotManager, subscriptionSnapshotManager, webhookSecretSnapshotManager, cfg.Controller.PolicyServer.Port, log, serverOpts...) - go func() { - if err := policyXDSServer.Start(); err != nil { - log.Error("Policy xDS server failed", slog.Any("error", err)) - os.Exit(1) - } - }() - - // Load llm provider templates from files - templateLoader := utils.NewLLMTemplateLoader(log) - templateDir := cfg.Controller.LLM.TemplateDefinitionsPath - log.Info("Loading llm provider templates from directory", slog.String("directory", templateDir)) - templateDefinitions, err := templateLoader.LoadTemplatesFromDirectory(templateDir) - if err != nil { - log.Error("Failed to load llm provider templates", slog.Any("error", err)) - os.Exit(1) - } - log.Info("Default llm provider templates loaded", slog.Int("count", len(templateDefinitions))) - - // Create validator with policy validation support - validator := config.NewAPIValidator() - policyValidator := config.NewPolicyValidator(policyDefinitions) - validator.SetPolicyValidator(policyValidator) - - apiSvc := utils.NewAPIDeploymentService(configStore, db, snapshotManager, validator, &cfg.Router, eventHubInstance, gatewayID, secretsService) - mcpSvc := utils.NewMCPDeploymentService(configStore, db, snapshotManager, policyManager, policyValidator, eventHubInstance, gatewayID, secretsService) - webhookSecretService := utils.NewWebhookSecretService(db, encryptionProviderManager, webhookSecretStore, eventHubInstance, gatewayID, log) - llmSvc := utils.NewLLMDeploymentService(configStore, db, snapshotManager, lazyResourceXDSManager, templateDefinitions, - apiSvc, &cfg.Router, policyVersionResolver, policyValidator) - - // Initialize and start control plane client with dependencies for API creation and API key management - cpClient := controlplane.NewClient( - cfg.Controller.ControlPlane, - log, configStore, - db, snapshotManager, - validator, - &cfg.Router, - apiKeyXDSManager, apiKeyStore, - &cfg.APIKey, - policyManager, - cfg, policyDefinitions, - lazyResourceXDSManager, - templateDefinitions, - subscriptionSnapshotManager, - eventHubInstance, - secretsService, - webhookSecretStore, - webhookSecretSnapshotManager, - ) - if err := cpClient.Start(); err != nil { - log.Error("Failed to start control plane client", slog.Any("error", err)) - // Don't fail startup - gateway can run in degraded mode without control plane - } - - restAPIService := restapi.NewRestAPIService( - configStore, db, snapshotManager, policyManager, - apiSvc, apiKeyXDSManager, - cpClient, &cfg.Router, cfg, - &http.Client{Timeout: 10 * time.Second}, config.NewParser(), validator, log, - eventHubInstance, secretsService, - ) - igw := immutable.NewImmutableGW(cfg.ImmutableGateway, restAPIService, llmSvc, mcpSvc) - - // Initialize Gin router - if os.Getenv("GIN_MODE") == "" { - gin.SetMode(gin.ReleaseMode) - } - router := gin.New() - - // Add middleware - // IMPORTANT: CorrelationIDMiddleware must be registered first to ensure - // correlation ID is available in context for subsequent middleware and handlers - router.Use(middleware.CorrelationIDMiddleware(log)) - router.Use(middleware.ErrorHandlingMiddleware(log)) - router.Use(middleware.LoggingMiddleware(log)) - // Add metrics middleware if metrics are enabled - if cfg.Controller.Metrics.Enabled { - router.Use(middleware.MetricsMiddleware()) - } - authConfig := generateAuthConfig(cfg) - authMiddleWare, err := authenticators.AuthMiddleware(authConfig, log) - if err != nil { - log.Error("Failed to create auth middleware", slog.Any("error", err)) - os.Exit(1) - } - router.Use(authMiddleWare) - router.Use(authenticators.AuthorizationMiddleware(authConfig, log)) - router.Use(gin.Recovery()) - - // Initialize EventListener for multi-replica sync (consumes EventHub events) - var evtListener *eventlistener.EventListener - evtListener = eventlistener.NewEventListener( - eventHubInstance, - configStore, - db, - snapshotManager, - subscriptionSnapshotManager, - apiKeyXDSManager, - lazyResourceXDSManager, - policyManager, - &cfg.Router, - log, - cfg, - policyDefinitions, - secretsService, - webhookSecretStore, - webhookSecretSnapshotManager, - encryptionProviderManager, - ) - if err := evtListener.Start(); err != nil { - log.Error("Failed to start event listener", slog.Any("error", err)) - os.Exit(1) - } - log.Info("EventListener started for multi-replica sync") - - // 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, - eventHubInstance, - subscriptionSnapshotManager, - secretsService, - restAPIService, - webhookSecretService, - ) - - // Load immutable gateway artifacts from the filesystem (no-op when immutable mode is disabled). - if err := igw.LoadArtifacts(log); err != nil { - log.Error("Failed to load immutable gateway artifacts", slog.Any("error", err)) - os.Exit(1) - } - - // Ensure initial lazy resource snapshot includes default templates loaded from files. - // At this point, the API server initialization has already persisted/published OOB templates. - if lazyResourceStore.Count() > 0 { - log.Info("Generating initial lazy resource snapshot for policy engine (including templates)", - slog.Int("lazy_resource_count", lazyResourceStore.Count())) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - if err := lazyResourceSnapshotManager.UpdateSnapshot(ctx); err != nil { - log.Warn("Failed to generate initial lazy resource snapshot", slog.Any("error", err)) - } else { - log.Info("Initial lazy resource snapshot generated successfully") - } - cancel() - } - - // Register immutable gateway middleware (passthrough when immutable mode is disabled). - router.Use(igw.Middleware()) - - // Register API routes under the versioned base path (includes certificate - // management endpoints from OpenAPI spec). - api.RegisterHandlersWithOptions(router, apiServer, api.GinServerOptions{ - BaseURL: managementAPIBasePath, - }) - - // Also register the same routes on the legacy unprefixed paths for - // backwards compatibility. These are deprecated; responses include - // RFC 8594 `Deprecation: true` and a `Link` header pointing to the new - // versioned path so clients can migrate. Remove once all known clients - // have switched to the versioned base path. - api.RegisterHandlersWithOptions(router, apiServer, api.GinServerOptions{ - Middlewares: []api.MiddlewareFunc{ - deprecatedManagementPathMiddleware(managementAPIBasePath), - }, - }) - - // Start controller admin server for debug endpoints if enabled. - var controllerAdminServer *adminserver.Server - if cfg.Controller.AdminServer.Enabled { - controllerAdminServer = adminserver.NewServer(&cfg.Controller.AdminServer, apiServer, log) - go func() { - if err := controllerAdminServer.Start(); err != nil { - log.Error("Controller admin server failed", slog.Any("error", err)) - os.Exit(1) - } - }() - } - - // Start metrics server if enabled - var metricsServer *metrics.Server - var metricsCtxCancel context.CancelFunc - if cfg.Controller.Metrics.Enabled { - log.Info("Starting metrics server", slog.Int("port", cfg.Controller.Metrics.Port)) - - // Set build info metric - metrics.Info.WithLabelValues(version.Version, cfg.Controller.Storage.Type, version.BuildDate).Set(1) - - metricsServer = metrics.NewServer(&cfg.Controller.Metrics, log) - if err := metricsServer.Start(); err != nil { - log.Error("Metrics server failed", slog.Any("error", err)) - os.Exit(1) - } - - // Start memory metrics updater with cancellable context - var metricsCtx context.Context - metricsCtx, metricsCtxCancel = context.WithCancel(context.Background()) - metrics.StartMemoryMetricsUpdater(metricsCtx, 15*time.Second) - } - - // Start REST API server - log.Info("Starting REST API server", slog.Int("port", cfg.Controller.Server.APIPort)) - - // Setup graceful shutdown - srv := &http.Server{ - Addr: fmt.Sprintf(":%d", cfg.Controller.Server.APIPort), - Handler: router, - ReadHeaderTimeout: 30 * time.Second, - } - - // Start server in a goroutine - go func() { - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Error("Failed to start REST API server", slog.Any("error", err)) - os.Exit(1) - } - }() - - log.Info("Gateway Controller started successfully") - - // Print banner when both router and policy engine have sent their first ACK, - // confirming they are fully initialized. One second delay lets Docker's log - // buffers drain so the banner is not interleaved with Envoy's startup output. - go func() { - <-routerConnected - <-policyEngineConnected - time.Sleep(1 * time.Second) - fmt.Print("\n\n" + - "========================================================================\n" + - "\n" + - "\n" + - " API Platform Gateway Started\n" + - "\n" + - "\n" + - "========================================================================\n" + - "\n\n") - }() - - // Wait for interrupt signal - quit := make(chan os.Signal, 1) - signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - <-quit - - log.Info("Shutting down Gateway-Controller") - - // Graceful shutdown with timeout - ctx, cancel = context.WithTimeout(context.Background(), cfg.Controller.Server.ShutdownTimeout) - defer cancel() - - // Stop event listener and EventHub first - if evtListener != nil { - evtListener.Stop() - } - if err := eventHubInstance.Close(); err != nil { - log.Warn("Failed to close EventHub cleanly", slog.Any("error", err)) - } - if eventHubStorage != nil { - if err := eventHubStorage.Close(); err != nil { - log.Warn("Failed to close EventHub storage cleanly", slog.Any("error", err)) - } - } - - // Stop control plane client - cpClient.Stop() - - if err := srv.Shutdown(ctx); err != nil { - log.Error("Server forced to shutdown", slog.Any("error", err)) - } - - xdsServer.Stop() - - // Stop policy xDS server if it was started - if policyXDSServer != nil { - policyXDSServer.Stop() - } - - // Stop metrics server if it was started - if metricsServer != nil { - // Cancel memory metrics updater context - if metricsCtxCancel != nil { - metricsCtxCancel() - } - if err := metricsServer.Stop(ctx); err != nil { - log.Error("Failed to stop metrics server", slog.Any("error", err)) - } - } - - if controllerAdminServer != nil { - if err := controllerAdminServer.Stop(ctx); err != nil { - log.Error("Failed to stop controller admin server", slog.Any("error", err)) - } - } - - log.Info("Gateway-Controller stopped") -} - -func generateAuthConfig(config *config.Config) commonmodels.AuthConfig { - // prefixed builds a resource key of the form " " - // matching the actual routes registered via RegisterHandlersWithOptions(BaseURL=managementAPIBasePath). - prefixed := func(methodAndPath string) string { - idx := strings.Index(methodAndPath, " ") - if idx < 0 { - return methodAndPath - } - return methodAndPath[:idx+1] + managementAPIBasePath + methodAndPath[idx+1:] - } - - relativeRoles := map[string][]string{ - "POST /rest-apis": {"admin", "developer"}, - "GET /rest-apis": {"admin", "developer"}, - "GET /rest-apis/:id": {"admin", "developer"}, - "PUT /rest-apis/:id": {"admin", "developer"}, - "DELETE /rest-apis/:id": {"admin", "developer"}, - - "POST /websub-apis": {"admin", "developer"}, - "GET /websub-apis": {"admin", "developer"}, - "GET /websub-apis/:id": {"admin", "developer"}, - "PUT /websub-apis/:id": {"admin", "developer"}, - "DELETE /websub-apis/:id": {"admin", "developer"}, - - "POST /webbroker-apis": {"admin", "developer"}, - "GET /webbroker-apis": {"admin", "developer"}, - "GET /webbroker-apis/:id": {"admin", "developer"}, - "DELETE /webbroker-apis/:id": {"admin", "developer"}, - - "GET /certificates": {"admin", "developer"}, - "POST /certificates": {"admin", "developer"}, - "DELETE /certificates/:id": {"admin"}, - "POST /certificates/reload": {"admin"}, - - "GET /policies": {"admin", "developer"}, - - "POST /mcp-proxies": {"admin", "developer"}, - "GET /mcp-proxies": {"admin", "developer"}, - "GET /mcp-proxies/:id": {"admin", "developer"}, - "PUT /mcp-proxies/:id": {"admin", "developer"}, - "DELETE /mcp-proxies/:id": {"admin", "developer"}, - - "POST /llm-provider-templates": {"admin"}, - "GET /llm-provider-templates": {"admin"}, - "GET /llm-provider-templates/:id": {"admin"}, - "PUT /llm-provider-templates/:id": {"admin"}, - "DELETE /llm-provider-templates/:id": {"admin"}, - - "POST /llm-providers": {"admin"}, - "GET /llm-providers": {"admin", "developer"}, - "GET /llm-providers/:id": {"admin", "developer"}, - "PUT /llm-providers/:id": {"admin"}, - "DELETE /llm-providers/:id": {"admin"}, - - "POST /llm-proxies": {"admin", "developer"}, - "GET /llm-proxies": {"admin", "developer"}, - "GET /llm-proxies/:id": {"admin", "developer"}, - "PUT /llm-proxies/:id": {"admin", "developer"}, - "DELETE /llm-proxies/:id": {"admin", "developer"}, - - "POST /rest-apis/:id/api-keys": {"admin", "consumer"}, - "GET /rest-apis/:id/api-keys": {"admin", "consumer"}, - "PUT /rest-apis/:id/api-keys/:apiKeyName": {"admin", "consumer"}, - "POST /rest-apis/:id/api-keys/:apiKeyName/regenerate": {"admin", "consumer"}, - "DELETE /rest-apis/:id/api-keys/:apiKeyName": {"admin", "consumer"}, - - "POST /llm-providers/:id/api-keys": {"admin", "consumer"}, - "GET /llm-providers/:id/api-keys": {"admin", "consumer"}, - "PUT /llm-providers/:id/api-keys/:apiKeyName": {"admin", "consumer"}, - "POST /llm-providers/:id/api-keys/:apiKeyName/regenerate": {"admin", "consumer"}, - "DELETE /llm-providers/:id/api-keys/:apiKeyName": {"admin", "consumer"}, - - "POST /llm-proxies/:id/api-keys": {"admin", "consumer"}, - "GET /llm-proxies/:id/api-keys": {"admin", "consumer"}, - "PUT /llm-proxies/:id/api-keys/:apiKeyName": {"admin", "consumer"}, - "POST /llm-proxies/:id/api-keys/:apiKeyName/regenerate": {"admin", "consumer"}, - "DELETE /llm-proxies/:id/api-keys/:apiKeyName": {"admin", "consumer"}, - - "POST /websub-apis/:id/api-keys": {"admin", "consumer"}, - "GET /websub-apis/:id/api-keys": {"admin", "consumer"}, - "PUT /websub-apis/:id/api-keys/:apiKeyName": {"admin", "consumer"}, - "POST /websub-apis/:id/api-keys/:apiKeyName/regenerate": {"admin", "consumer"}, - "DELETE /websub-apis/:id/api-keys/:apiKeyName": {"admin", "consumer"}, - - "POST /websub-apis/:id/secrets": {"admin", "consumer"}, - "GET /websub-apis/:id/secrets": {"admin", "consumer"}, - "DELETE /websub-apis/:id/secrets/:secretName": {"admin", "consumer"}, - "POST /websub-apis/:id/secrets/:secretName/regenerate": {"admin", "consumer"}, - - "POST /webbroker-apis/:id/api-keys": {"admin", "consumer"}, - "GET /webbroker-apis/:id/api-keys": {"admin", "consumer"}, - "PUT /webbroker-apis/:id/api-keys/:apiKeyName": {"admin", "consumer"}, - "POST /webbroker-apis/:id/api-keys/:apiKeyName/regenerate": {"admin", "consumer"}, - "DELETE /webbroker-apis/:id/api-keys/:apiKeyName": {"admin", "consumer"}, - - // Root-level subscription endpoints - "POST /subscriptions": {"admin", "developer"}, - "GET /subscriptions": {"admin", "developer"}, - "GET /subscriptions/:subscriptionId": {"admin", "developer"}, - "PUT /subscriptions/:subscriptionId": {"admin", "developer"}, - "DELETE /subscriptions/:subscriptionId": {"admin", "developer"}, - - // Subscription plan endpoints - "POST /subscription-plans": {"admin", "developer"}, - "GET /subscription-plans": {"admin", "developer"}, - "GET /subscription-plans/:planId": {"admin", "developer"}, - "PUT /subscription-plans/:planId": {"admin", "developer"}, - "DELETE /subscription-plans/:planId": {"admin", "developer"}, - - "POST /secrets": {"admin"}, - "GET /secrets": {"admin"}, - "GET /secrets/:id": {"admin"}, - "PUT /secrets/:id": {"admin"}, - "DELETE /secrets/:id": {"admin"}, - } - - // Populate both the versioned and legacy (unprefixed) keys so the auth - // middleware matches either route form. The legacy form is deprecated and - // will be removed in a future release. - DefaultResourceRoles := make(map[string][]string, len(relativeRoles)*2) - for methodAndPath, roles := range relativeRoles { - DefaultResourceRoles[prefixed(methodAndPath)] = roles - DefaultResourceRoles[methodAndPath] = roles - } - basicAuth := commonmodels.BasicAuth{Enabled: false} - idpAuth := commonmodels.IDPConfig{Enabled: false} - if config.Controller.Auth.Basic.Enabled { - users := make([]commonmodels.User, len(config.Controller.Auth.Basic.Users)) - for i, authUser := range config.Controller.Auth.Basic.Users { - users[i] = commonmodels.User{ - Username: authUser.Username, - Password: authUser.Password, - PasswordHashed: authUser.PasswordHashed, - Roles: authUser.Roles, - } - } - basicAuth = commonmodels.BasicAuth{Enabled: true, Users: users} - } - if config.Controller.Auth.IDP.Enabled { - idpAuth = commonmodels.IDPConfig{Enabled: true, IssuerURL: config.Controller.Auth.IDP.Issuer, - JWKSUrl: config.Controller.Auth.IDP.JWKSURL, - ScopeClaim: config.Controller.Auth.IDP.RolesClaim, - PermissionMapping: &config.Controller.Auth.IDP.RoleMapping, - } - } - authConfig := commonmodels.AuthConfig{BasicAuth: &basicAuth, - JWTConfig: &idpAuth, - ResourceRoles: DefaultResourceRoles, - } - return authConfig -} - -// deprecatedManagementPathMiddleware returns a Gin middleware that marks -// responses served on the legacy unprefixed management API paths as -// deprecated, following RFC 8594. It adds: -// - `Deprecation: true` -// - `Link: ; rel="successor-version"` -// - `Warning: 299 - "Deprecated API: use prefix"` -// -// The middleware is attached only to the second (legacy) registration of the -// management API routes; requests to the versioned base path bypass it. -func deprecatedManagementPathMiddleware(newBasePath string) api.MiddlewareFunc { - return func(c *gin.Context) { - successor := newBasePath + c.Request.URL.Path - c.Writer.Header().Set("Deprecation", "true") - c.Writer.Header().Set("Link", fmt.Sprintf("<%s>; rel=\"successor-version\"", successor)) - c.Writer.Header().Set("Warning", - fmt.Sprintf("299 - \"Deprecated API: migrate to %s prefix\"", newBasePath)) - c.Next() - } + bootstrap.Run(*configPath, controllerext.NoOpExtension{}) } diff --git a/gateway/gateway-controller/cmd/controller/main_test.go b/gateway/gateway-controller/cmd/controller/main_test.go index e6c6b61b62..8e3d210828 100644 --- a/gateway/gateway-controller/cmd/controller/main_test.go +++ b/gateway/gateway-controller/cmd/controller/main_test.go @@ -24,6 +24,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/bootstrap" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" policybuilder "github.com/wso2/api-platform/gateway/gateway-controller/pkg/policy" @@ -564,7 +565,7 @@ func TestGenerateAuthConfig(t *testing.T) { }, } - authConfig := generateAuthConfig(cfg) + authConfig := bootstrap.GenerateAuthConfig(cfg, bootstrap.ManagementAPIBasePath, nil) assert.False(t, authConfig.BasicAuth.Enabled) assert.False(t, authConfig.JWTConfig.Enabled) @@ -599,7 +600,7 @@ func TestGenerateAuthConfig(t *testing.T) { }, } - authConfig := generateAuthConfig(cfg) + authConfig := bootstrap.GenerateAuthConfig(cfg, bootstrap.ManagementAPIBasePath, nil) assert.True(t, authConfig.BasicAuth.Enabled) assert.Len(t, authConfig.BasicAuth.Users, 2) @@ -633,7 +634,7 @@ func TestGenerateAuthConfig(t *testing.T) { }, } - authConfig := generateAuthConfig(cfg) + authConfig := bootstrap.GenerateAuthConfig(cfg, bootstrap.ManagementAPIBasePath, nil) assert.False(t, authConfig.BasicAuth.Enabled) assert.True(t, authConfig.JWTConfig.Enabled) @@ -663,7 +664,7 @@ func TestGenerateAuthConfig(t *testing.T) { }, } - authConfig := generateAuthConfig(cfg) + authConfig := bootstrap.GenerateAuthConfig(cfg, bootstrap.ManagementAPIBasePath, nil) assert.True(t, authConfig.BasicAuth.Enabled) assert.True(t, authConfig.JWTConfig.Enabled) @@ -679,19 +680,19 @@ func TestGenerateAuthConfig(t *testing.T) { }, } - authConfig := generateAuthConfig(cfg) + authConfig := bootstrap.GenerateAuthConfig(cfg, bootstrap.ManagementAPIBasePath, nil) - // Check some expected resource roles (keys are prefixed with managementAPIBasePath) - assert.Contains(t, authConfig.ResourceRoles, "POST "+managementAPIBasePath+"/rest-apis") - assert.Contains(t, authConfig.ResourceRoles, "GET "+managementAPIBasePath+"/rest-apis") - assert.Contains(t, authConfig.ResourceRoles, "POST "+managementAPIBasePath+"/llm-providers/:id/api-keys") - assert.Contains(t, authConfig.ResourceRoles, "GET "+managementAPIBasePath+"/llm-providers/:id/api-keys") - assert.Contains(t, authConfig.ResourceRoles, "POST "+managementAPIBasePath+"/llm-proxies/:id/api-keys") - assert.Contains(t, authConfig.ResourceRoles, "GET "+managementAPIBasePath+"/llm-proxies/:id/api-keys") - assert.Contains(t, authConfig.ResourceRoles, "GET "+managementAPIBasePath+"/policies") + // Check some expected resource roles (keys are prefixed with bootstrap.ManagementAPIBasePath) + assert.Contains(t, authConfig.ResourceRoles, "POST "+bootstrap.ManagementAPIBasePath+"/rest-apis") + assert.Contains(t, authConfig.ResourceRoles, "GET "+bootstrap.ManagementAPIBasePath+"/rest-apis") + assert.Contains(t, authConfig.ResourceRoles, "POST "+bootstrap.ManagementAPIBasePath+"/llm-providers/:id/api-keys") + assert.Contains(t, authConfig.ResourceRoles, "GET "+bootstrap.ManagementAPIBasePath+"/llm-providers/:id/api-keys") + assert.Contains(t, authConfig.ResourceRoles, "POST "+bootstrap.ManagementAPIBasePath+"/llm-proxies/:id/api-keys") + assert.Contains(t, authConfig.ResourceRoles, "GET "+bootstrap.ManagementAPIBasePath+"/llm-proxies/:id/api-keys") + assert.Contains(t, authConfig.ResourceRoles, "GET "+bootstrap.ManagementAPIBasePath+"/policies") // Admin API paths are served separately and must not leak into management auth config. - assert.NotContains(t, authConfig.ResourceRoles, "GET "+managementAPIBasePath+"/config_dump") - assert.NotContains(t, authConfig.ResourceRoles, "GET "+managementAPIBasePath+"/xds_sync_status") + assert.NotContains(t, authConfig.ResourceRoles, "GET "+bootstrap.ManagementAPIBasePath+"/config_dump") + assert.NotContains(t, authConfig.ResourceRoles, "GET "+bootstrap.ManagementAPIBasePath+"/xds_sync_status") // Legacy unprefixed keys must also be present while the deprecated // routes continue to be supported. @@ -700,7 +701,7 @@ func TestGenerateAuthConfig(t *testing.T) { assert.Contains(t, authConfig.ResourceRoles, "GET /policies") // Check role assignments - postRestAPIs := "POST " + managementAPIBasePath + "/rest-apis" + postRestAPIs := "POST " + bootstrap.ManagementAPIBasePath + "/rest-apis" assert.Contains(t, authConfig.ResourceRoles[postRestAPIs], "admin") assert.Contains(t, authConfig.ResourceRoles[postRestAPIs], "developer") // Legacy key carries the same role assignments. diff --git a/gateway/gateway-controller/pkg/bootstrap/auth.go b/gateway/gateway-controller/pkg/bootstrap/auth.go new file mode 100644 index 0000000000..f3eab94a60 --- /dev/null +++ b/gateway/gateway-controller/pkg/bootstrap/auth.go @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package bootstrap + +import ( + "strings" + + commonmodels "github.com/wso2/api-platform/common/models" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" +) + +// GenerateAuthConfig produces the auth middleware configuration for the management API. +// managementAPIBasePath is the versioned prefix (e.g. "/api/management/v0.9"). +// additionalRoles is merged with the base role map; extensions use this to protect +// their own routes. Pass nil when no extra routes are needed. +func GenerateAuthConfig(cfg *config.Config, managementAPIBasePath string, additionalRoles map[string][]string) commonmodels.AuthConfig { + // prefixed builds a resource key of the form " " + // matching the actual routes registered via RegisterHandlersWithOptions(BaseURL=managementAPIBasePath). + prefixed := func(methodAndPath string) string { + idx := strings.Index(methodAndPath, " ") + if idx < 0 { + return methodAndPath + } + return methodAndPath[:idx+1] + managementAPIBasePath + methodAndPath[idx+1:] + } + + relativeRoles := map[string][]string{ + "POST /rest-apis": {"admin", "developer"}, + "GET /rest-apis": {"admin", "developer"}, + "GET /rest-apis/:id": {"admin", "developer"}, + "PUT /rest-apis/:id": {"admin", "developer"}, + "DELETE /rest-apis/:id": {"admin", "developer"}, + + "POST /websub-apis": {"admin", "developer"}, + "GET /websub-apis": {"admin", "developer"}, + "GET /websub-apis/:id": {"admin", "developer"}, + "PUT /websub-apis/:id": {"admin", "developer"}, + "DELETE /websub-apis/:id": {"admin", "developer"}, + + "POST /webbroker-apis": {"admin", "developer"}, + "GET /webbroker-apis": {"admin", "developer"}, + "GET /webbroker-apis/:id": {"admin", "developer"}, + "DELETE /webbroker-apis/:id": {"admin", "developer"}, + + "GET /certificates": {"admin", "developer"}, + "POST /certificates": {"admin", "developer"}, + "DELETE /certificates/:id": {"admin"}, + "POST /certificates/reload": {"admin"}, + + "GET /policies": {"admin", "developer"}, + + "POST /mcp-proxies": {"admin", "developer"}, + "GET /mcp-proxies": {"admin", "developer"}, + "GET /mcp-proxies/:id": {"admin", "developer"}, + "PUT /mcp-proxies/:id": {"admin", "developer"}, + "DELETE /mcp-proxies/:id": {"admin", "developer"}, + + "POST /llm-provider-templates": {"admin"}, + "GET /llm-provider-templates": {"admin"}, + "GET /llm-provider-templates/:id": {"admin"}, + "PUT /llm-provider-templates/:id": {"admin"}, + "DELETE /llm-provider-templates/:id": {"admin"}, + + "POST /llm-providers": {"admin"}, + "GET /llm-providers": {"admin", "developer"}, + "GET /llm-providers/:id": {"admin", "developer"}, + "PUT /llm-providers/:id": {"admin"}, + "DELETE /llm-providers/:id": {"admin"}, + + "POST /llm-proxies": {"admin", "developer"}, + "GET /llm-proxies": {"admin", "developer"}, + "GET /llm-proxies/:id": {"admin", "developer"}, + "PUT /llm-proxies/:id": {"admin", "developer"}, + "DELETE /llm-proxies/:id": {"admin", "developer"}, + + "POST /rest-apis/:id/api-keys": {"admin", "consumer"}, + "GET /rest-apis/:id/api-keys": {"admin", "consumer"}, + "PUT /rest-apis/:id/api-keys/:apiKeyName": {"admin", "consumer"}, + "POST /rest-apis/:id/api-keys/:apiKeyName/regenerate": {"admin", "consumer"}, + "DELETE /rest-apis/:id/api-keys/:apiKeyName": {"admin", "consumer"}, + + "POST /llm-providers/:id/api-keys": {"admin", "consumer"}, + "GET /llm-providers/:id/api-keys": {"admin", "consumer"}, + "PUT /llm-providers/:id/api-keys/:apiKeyName": {"admin", "consumer"}, + "POST /llm-providers/:id/api-keys/:apiKeyName/regenerate": {"admin", "consumer"}, + "DELETE /llm-providers/:id/api-keys/:apiKeyName": {"admin", "consumer"}, + + "POST /llm-proxies/:id/api-keys": {"admin", "consumer"}, + "GET /llm-proxies/:id/api-keys": {"admin", "consumer"}, + "PUT /llm-proxies/:id/api-keys/:apiKeyName": {"admin", "consumer"}, + "POST /llm-proxies/:id/api-keys/:apiKeyName/regenerate": {"admin", "consumer"}, + "DELETE /llm-proxies/:id/api-keys/:apiKeyName": {"admin", "consumer"}, + + "POST /websub-apis/:id/api-keys": {"admin", "consumer"}, + "GET /websub-apis/:id/api-keys": {"admin", "consumer"}, + "PUT /websub-apis/:id/api-keys/:apiKeyName": {"admin", "consumer"}, + "POST /websub-apis/:id/api-keys/:apiKeyName/regenerate": {"admin", "consumer"}, + "DELETE /websub-apis/:id/api-keys/:apiKeyName": {"admin", "consumer"}, + + "POST /websub-apis/:id/secrets": {"admin", "consumer"}, + "GET /websub-apis/:id/secrets": {"admin", "consumer"}, + "DELETE /websub-apis/:id/secrets/:secretName": {"admin", "consumer"}, + "POST /websub-apis/:id/secrets/:secretName/regenerate": {"admin", "consumer"}, + + "POST /webbroker-apis/:id/api-keys": {"admin", "consumer"}, + "GET /webbroker-apis/:id/api-keys": {"admin", "consumer"}, + "PUT /webbroker-apis/:id/api-keys/:apiKeyName": {"admin", "consumer"}, + "POST /webbroker-apis/:id/api-keys/:apiKeyName/regenerate": {"admin", "consumer"}, + "DELETE /webbroker-apis/:id/api-keys/:apiKeyName": {"admin", "consumer"}, + + // Root-level subscription endpoints + "POST /subscriptions": {"admin", "developer"}, + "GET /subscriptions": {"admin", "developer"}, + "GET /subscriptions/:subscriptionId": {"admin", "developer"}, + "PUT /subscriptions/:subscriptionId": {"admin", "developer"}, + "DELETE /subscriptions/:subscriptionId": {"admin", "developer"}, + + // Subscription plan endpoints + "POST /subscription-plans": {"admin", "developer"}, + "GET /subscription-plans": {"admin", "developer"}, + "GET /subscription-plans/:planId": {"admin", "developer"}, + "PUT /subscription-plans/:planId": {"admin", "developer"}, + "DELETE /subscription-plans/:planId": {"admin", "developer"}, + + "POST /secrets": {"admin"}, + "GET /secrets": {"admin"}, + "GET /secrets/:id": {"admin"}, + "PUT /secrets/:id": {"admin"}, + "DELETE /secrets/:id": {"admin"}, + } + + // Merge extension-provided roles before building the DefaultResourceRoles map. + for methodAndPath, roles := range additionalRoles { + relativeRoles[methodAndPath] = roles + } + + // Populate both the versioned and legacy (unprefixed) keys so the auth + // middleware matches either route form. The legacy form is deprecated and + // will be removed in a future release. + DefaultResourceRoles := make(map[string][]string, len(relativeRoles)*2) + for methodAndPath, roles := range relativeRoles { + DefaultResourceRoles[prefixed(methodAndPath)] = roles + DefaultResourceRoles[methodAndPath] = roles + } + + basicAuth := commonmodels.BasicAuth{Enabled: false} + idpAuth := commonmodels.IDPConfig{Enabled: false} + if cfg.Controller.Auth.Basic.Enabled { + users := make([]commonmodels.User, len(cfg.Controller.Auth.Basic.Users)) + for i, authUser := range cfg.Controller.Auth.Basic.Users { + users[i] = commonmodels.User{ + Username: authUser.Username, + Password: authUser.Password, + PasswordHashed: authUser.PasswordHashed, + Roles: authUser.Roles, + } + } + basicAuth = commonmodels.BasicAuth{Enabled: true, Users: users} + } + if cfg.Controller.Auth.IDP.Enabled { + idpAuth = commonmodels.IDPConfig{ + Enabled: true, + IssuerURL: cfg.Controller.Auth.IDP.Issuer, + JWKSUrl: cfg.Controller.Auth.IDP.JWKSURL, + ScopeClaim: cfg.Controller.Auth.IDP.RolesClaim, + PermissionMapping: &cfg.Controller.Auth.IDP.RoleMapping, + } + } + return commonmodels.AuthConfig{ + BasicAuth: &basicAuth, + JWTConfig: &idpAuth, + ResourceRoles: DefaultResourceRoles, + } +} diff --git a/gateway/gateway-controller/pkg/bootstrap/run.go b/gateway/gateway-controller/pkg/bootstrap/run.go new file mode 100644 index 0000000000..d7bccf111b --- /dev/null +++ b/gateway/gateway-controller/pkg/bootstrap/run.go @@ -0,0 +1,757 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package bootstrap + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/gin-gonic/gin" + "github.com/wso2/api-platform/common/authenticators" + "github.com/wso2/api-platform/common/eventhub" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/adminserver" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/handlers" + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/middleware" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/apikeyxds" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/controllerext" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/controlplane" + "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/eventlistener" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/immutable" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/lazyresourcexds" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/logger" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/metrics" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/policyxds" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/secrets" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/service/restapi" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/transform" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/version" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/xds" +) + +// API base paths for the gateway-controller HTTP surfaces. +// These must stay in sync with the `servers.url` values in the OpenAPI specs +// (api/management-openapi.yaml and api/admin-openapi.yaml). +const ( + ManagementAPIBasePath = "/api/management/v0.9" +) + +// ToBackendConfig converts a top-level config into a storage backend config. +func ToBackendConfig(cfg *config.Config) storage.BackendConfig { + pg := cfg.Controller.Storage.Postgres + return storage.BackendConfig{ + Type: cfg.Controller.Storage.Type, + SQLitePath: cfg.Controller.Storage.SQLite.Path, + Postgres: storage.PostgresConnectionConfig{ + DSN: pg.DSN, + Host: pg.Host, + Port: pg.Port, + Database: pg.Database, + User: pg.User, + Password: pg.Password, + SSLMode: pg.SSLMode, + ConnectTimeout: pg.ConnectTimeout, + MaxOpenConns: pg.MaxOpenConns, + MaxIdleConns: pg.MaxIdleConns, + ConnMaxLifetime: pg.ConnMaxLifetime, + ConnMaxIdleTime: pg.ConnMaxIdleTime, + ApplicationName: pg.ApplicationName, + }, + GatewayID: cfg.Controller.Server.GatewayID, + } +} + +// DeprecatedManagementPathMiddleware returns a Gin middleware that marks +// responses served on the legacy unprefixed management API paths as +// deprecated, following RFC 8594. +func DeprecatedManagementPathMiddleware(newBasePath string) api.MiddlewareFunc { + return func(c *gin.Context) { + successor := newBasePath + c.Request.URL.Path + c.Writer.Header().Set("Deprecation", "true") + c.Writer.Header().Set("Link", fmt.Sprintf("<%s>; rel=\"successor-version\"", successor)) + c.Writer.Header().Set("Warning", + fmt.Sprintf("299 - \"Deprecated API: migrate to %s prefix\"", newBasePath)) + c.Next() + } +} + +// Run wires all gateway-controller components and starts the server. +// It blocks until a SIGINT/SIGTERM is received, then performs graceful shutdown. +// The ext argument is used to inject extension-specific components (e.g. event-gateway). +// Pass controllerext.NoOpExtension{} for the base controller. +func Run(configPath string, ext controllerext.ControllerExtension) { + // Load configuration + cfg, err := config.LoadConfig(configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to load configuration from %s: %v\n", configPath, err) + os.Exit(1) + } + + // Initialize metrics based on configuration + metrics.SetEnabled(cfg.Controller.Metrics.Enabled) + metrics.Init() + + // Initialize logger with config + log := logger.NewLogger(logger.Config{ + Level: cfg.Controller.Logging.Level, + Format: cfg.Controller.Logging.Format, + }) + + log.Info("Starting Gateway-Controller", + slog.String("version", version.Version), + slog.String("git_commit", version.GitCommit), + slog.String("build_date", version.BuildDate), + slog.String("config_file", configPath), + slog.String("storage_type", cfg.Controller.Storage.Type), + slog.Bool("access_logs_enabled", cfg.Router.AccessLogs.Enabled), + slog.String("control_plane_host", cfg.Controller.ControlPlane.Host), + slog.Bool("control_plane_token_configured", cfg.Controller.ControlPlane.Token != ""), + slog.Bool("skip_invalid_deployments_on_startup", cfg.Controller.Server.SkipInvalidDeploymentsOnStartup), + slog.String("extension", ext.Name()), + ) + + if !cfg.Controller.Auth.Basic.Enabled && !cfg.Controller.Auth.IDP.Enabled { + log.Warn("No authentication configured: both basic auth and IDP are disabled. Gateway Controller API will allow all requests without authentication") + } + + // In immutable mode, delete any stale SQLite files before opening the DB to + // guarantee a fresh, reproducible state on every boot. + if cfg.ImmutableGateway.Enabled { + log.Info("Immutable gateway mode enabled — removing existing SQLite files for fresh start", + slog.String("path", cfg.Controller.Storage.SQLite.Path)) + if err := immutable.ResetSQLiteFiles(cfg.Controller.Storage.SQLite.Path, log); err != nil { + log.Error("Failed to reset SQLite files for immutable mode", slog.Any("error", err)) + os.Exit(1) + } + } + + // Initialize storage; apply base schema then any extension-specific schema SQL. + backendCfg := ToBackendConfig(cfg) + var db storage.Storage + db, err = storage.NewStorage(backendCfg, log, ext.AdditionalSchemaSQL(backendCfg.Type)...) + if err != nil { + if strings.EqualFold(cfg.Controller.Storage.Type, "sqlite") && errors.Is(err, storage.ErrDatabaseLocked) { + log.Error("Database is locked by another process", + slog.String("database_path", cfg.Controller.Storage.SQLite.Path), + slog.String("troubleshooting", "Check if another gateway-controller instance is running or remove stale WAL files")) + os.Exit(1) + } + log.Error("Failed to initialize database storage", + slog.String("type", cfg.Controller.Storage.Type), + slog.Any("error", err)) + os.Exit(1) + } + defer db.Close() + + // Initialize EventHub for multi-replica sync (requires persistent storage) + var eventHubInstance eventhub.EventHub + var eventHubStorage storage.Storage + ehBackendCfg := ToBackendConfig(cfg) + ehBackendCfg.Postgres.MaxOpenConns = cfg.Controller.EventHub.Database.MaxOpenConns + ehBackendCfg.Postgres.MaxIdleConns = cfg.Controller.EventHub.Database.MaxIdleConns + ehBackendCfg.Postgres.ConnMaxLifetime = cfg.Controller.EventHub.Database.ConnMaxLifetime + ehBackendCfg.Postgres.ConnMaxIdleTime = cfg.Controller.EventHub.Database.ConnMaxIdleTime + eventHubStorage, err = storage.NewStorage(ehBackendCfg, log) + if err != nil { + log.Error("Failed to initialize EventHub storage", slog.Any("error", err)) + os.Exit(1) + } + eventHubDB := eventHubStorage.GetDB() + + gatewayID := strings.TrimSpace(cfg.Controller.Server.GatewayID) + if eventHubDB == nil { + log.Error("EventHub storage returned nil database handle") + os.Exit(1) + } + if gatewayID == "" { + log.Error("EventHub requires non-empty gateway ID") + os.Exit(1) + } + eventHubInstance = eventhub.New(eventHubDB, log, eventhub.Config{ + PollInterval: cfg.Controller.EventHub.PollInterval, + CleanupInterval: cfg.Controller.EventHub.CleanupInterval, + RetentionPeriod: cfg.Controller.EventHub.RetentionPeriod, + }) + if err := eventHubInstance.Initialize(); err != nil { + log.Error("Failed to initialize EventHub", slog.Any("error", err)) + os.Exit(1) + } + if err := eventHubInstance.RegisterGateway(gatewayID); err != nil { + log.Error("Failed to register gateway with EventHub", slog.Any("error", err)) + os.Exit(1) + } + log.Info("EventHub initialized for multi-replica sync", + slog.String("gateway_id", gatewayID)) + + // Initialize in-memory config store + configStore := storage.NewConfigStore() + + // Initialize in-memory API key store for xDS + apiKeyStore := storage.NewAPIKeyStore(log) + apiKeySnapshotManager := apikeyxds.NewAPIKeySnapshotManager(apiKeyStore, log) + apiKeyXDSManager := apikeyxds.NewAPIKeyStateManager(apiKeyStore, apiKeySnapshotManager, log) + + // Initialize in-memory lazy resource store and components for xDS + lazyResourceStore := storage.NewLazyResourceStore(log) + lazyResourceSnapshotManager := lazyresourcexds.NewLazyResourceSnapshotManager(lazyResourceStore, log) + lazyResourceXDSManager := lazyresourcexds.NewLazyResourceStateManager(lazyResourceStore, lazyResourceSnapshotManager, log) + + // Build ExtensionDeps for extension hooks (used throughout startup) + deps := controllerext.ExtensionDeps{ + DB: db, + ConfigStore: configStore, + Log: log, + Config: cfg, + EventHub: eventHubInstance, + GatewayID: gatewayID, + } + + // Initialize the extension's xDS managers immediately after storage is ready. + // EncryptionProviderManager is not yet available at this point; LoadOnStartup + // (called after encryption init) handles any decryption-dependent work. + initCtx, initCancel := context.WithTimeout(context.Background(), 30*time.Second) + extXDS, err := ext.InitXDS(initCtx, deps) + initCancel() + if err != nil { + log.Error("Extension InitXDS failed", slog.String("extension", ext.Name()), slog.Any("error", err)) + os.Exit(1) + } + + // Initialize encryption providers for secret management + var encryptionProviderManager *encryption.ProviderManager + var secretsService *secrets.SecretService + + // Load configurations from database on startup + log.Info("Loading configurations from database") + if err := storage.LoadFromDatabase(db, configStore); err != nil { + log.Error("Failed to load configurations from database", slog.Any("error", err)) + os.Exit(1) + } + if err := storage.LoadLLMProviderTemplatesFromDatabase(db, configStore); err != nil { + log.Error("Failed to load llm provider template configurations from database", slog.Any("error", err)) + os.Exit(1) + } + log.Info("Loaded configurations", slog.Int("count", len(configStore.GetAll()))) + + // Load API keys from database into both in-memory stores + log.Info("Loading API keys from database") + if err := storage.LoadAPIKeysFromDatabase(db, configStore, apiKeyStore); err != nil { + log.Error("Failed to load API keys from database", slog.Any("error", err)) + os.Exit(1) + } + log.Info("Loaded API keys", slog.Int("count", apiKeyXDSManager.GetAPIKeyCount())) + + log.Info("Loading encryption providers") + if len(cfg.Controller.Encryption.Providers) > 0 { + log.Info("Initializing encryption providers", slog.Int("provider_count", len(cfg.Controller.Encryption.Providers))) + + var providers []encryption.EncryptionProvider + for _, providerConfig := range cfg.Controller.Encryption.Providers { + switch providerConfig.Type { + case "aesgcm": + 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)) + os.Exit(1) + } + providers = append(providers, provider) + default: + log.Error("Unsupported encryption provider type", slog.String("type", providerConfig.Type)) + os.Exit(1) + } + } + + encryptionProviderManager, err = encryption.NewProviderManager(providers, log) + if err != nil { + log.Error("Failed to initialize provider manager", slog.Any("error", err)) + os.Exit(1) + } + secretsService = secrets.NewSecretsService(db, encryptionProviderManager, log) + } + log.Info("Loaded encryption providers") + + // Update deps with now-available encryption provider + deps.EncryptionProviderManager = encryptionProviderManager + + // Extension startup hydration: load secrets, seed in-memory state, etc. + loadCtx, loadCancel := context.WithTimeout(context.Background(), 60*time.Second) + if err := ext.LoadOnStartup(loadCtx, deps, extXDS); err != nil { + log.Error("Extension LoadOnStartup failed", slog.String("extension", ext.Name()), slog.Any("error", err)) + os.Exit(1) + } + loadCancel() + + // Load policy definitions from files before any startup hydration or policy derivation. + policyLoader := utils.NewPolicyLoader(log) + policyDir := cfg.Controller.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))) + + // Detect custom policies from build-manifest.yaml. + localPolicies, err := policyLoader.GetCustomPolicyNames(cfg.Controller.Policies.BuildManifestPath) + if err != nil { + log.Warn("Could not read build-manifest.yaml, Custom policies will not be marked in the gateway manifest", + slog.String("path", cfg.Controller.Policies.BuildManifestPath), + slog.Any("error", err)) + } + for key, def := range policyDefinitions { + def.ManagedBy = "wso2" + if localPolicies[def.Name+"|"+def.Version] { + def.ManagedBy = "customer" + } + policyDefinitions[key] = def + } + + // MCP proxies and LLM artifacts are stored in source form and need to be + // rehydrated into their derived RestAPI representations before startup + // snapshot and policy work. + if err := hydrateStoredConfigsFromDatabaseOnStartup( + configStore, + db, + &cfg.Router, + policyDefinitions, + log, + cfg.Controller.Server.SkipInvalidDeploymentsOnStartup, + ); err != nil { + log.Error("Failed to hydrate stored configurations required for startup", slog.Any("error", err)) + os.Exit(1) + } + + // Initialize xDS snapshot manager with router config + snapshotManager := xds.NewSnapshotManager(configStore, log, &cfg.Router, db, cfg) + + // Initialize SDS secret manager if custom certificates are configured + var sdsSecretManager *xds.SDSSecretManager + translator := snapshotManager.GetTranslator() + if translator != nil && translator.GetCertStore() != nil { + sdsSecretManager = xds.NewSDSSecretManager( + translator.GetCertStore(), + snapshotManager.GetCache(), + "router-node", + log, + ) + if err := sdsSecretManager.UpdateSecrets(); err != nil { + log.Warn("Failed to initialize SDS secrets", slog.Any("error", err)) + } else { + log.Info("SDS secret manager initialized successfully") + snapshotManager.SetSDSSecretManager(sdsSecretManager) + } + } + + // Generate initial xDS snapshot + log.Info("Generating initial xDS snapshot") + snapCtx, snapCancel := context.WithTimeout(context.Background(), 10*time.Second) + if err := snapshotManager.UpdateSnapshot(snapCtx, ""); err != nil { + log.Warn("Failed to generate initial xDS snapshot", slog.Any("error", err)) + } + snapCancel() + + // Create channels to detect when router and policy engine first connect + routerConnected := make(chan struct{}) + policyEngineConnected := make(chan struct{}) + + // Start xDS gRPC server with SDS support + xdsServer := xds.NewServer(snapshotManager, sdsSecretManager, cfg.Controller.Server.XDSPort, log, routerConnected) + go func() { + if err := xdsServer.Start(); err != nil { + log.Error("xDS server failed", slog.Any("error", err)) + os.Exit(1) + } + }() + + // Generate initial API key snapshot if API keys were loaded from database + if apiKeyXDSManager.GetAPIKeyCount() > 0 { + log.Info("Generating initial API key snapshot for policy engine", + slog.Int("api_key_count", apiKeyXDSManager.GetAPIKeyCount())) + akCtx, akCancel := context.WithTimeout(context.Background(), 10*time.Second) + if err := apiKeySnapshotManager.UpdateSnapshot(akCtx); err != nil { + log.Warn("Failed to generate initial API key snapshot", slog.Any("error", err)) + } else { + log.Info("Initial API key snapshot generated successfully") + } + akCancel() + } + + // Initialize policy xDS server + log.Info("Initializing Policy xDS server", slog.Int("port", cfg.Controller.PolicyServer.Port)) + + // Initialize policy snapshot manager and runtime config store + policySnapshotManager := policyxds.NewSnapshotManager(log) + runtimeStore := storage.NewRuntimeConfigStore() + policySnapshotManager.SetRuntimeStore(runtimeStore) + policySnapshotManager.SetConfigStore(configStore) + + // Initialize policy manager + policyManager := policyxds.NewPolicyManager(policySnapshotManager, log) + policyManager.SetRuntimeStore(runtimeStore) + + // Build transformer registry for StoredConfig → RuntimeDeployConfig conversion + policyVersionResolver := utils.NewLoadedPolicyVersionResolver(policyDefinitions) + restTransformer := transform.NewRestAPITransformer(&cfg.Router, cfg, policyDefinitions) + llmTransformer := transform.NewLLMTransformer(configStore, db, &cfg.Router, cfg, policyDefinitions, policyVersionResolver) + transformerRegistry := transform.NewRegistry(restTransformer, llmTransformer) + policyManager.SetTransformers(transformerRegistry) + + // Load runtime configs from existing API configurations on startup. + log.Info("Loading runtime configs from existing API configurations") + loadedAPIs := configStore.GetAll() + loadedCount, err := loadRuntimeConfigsFromExistingAPIConfigurations( + loadedAPIs, + runtimeStore, + secretsService, + transformerRegistry, + log, + cfg.Controller.Server.SkipInvalidDeploymentsOnStartup, + ) + if err != nil { + log.Error("Failed to load runtime configs from API configurations", slog.Any("error", err)) + os.Exit(1) + } + log.Info("Loaded runtime configs from API configurations", + slog.Int("total_apis", len(loadedAPIs)), + slog.Int("configs_loaded", loadedCount)) + + // Generate initial policy snapshot + log.Info("Generating initial policy xDS snapshot") + polCtx, polCancel := context.WithTimeout(context.Background(), 10*time.Second) + if err := policySnapshotManager.UpdateSnapshot(polCtx); err != nil { + log.Warn("Failed to generate initial policy xDS snapshot", slog.Any("error", err)) + } + polCancel() + + // Build policyxds server options: TLS + first-connect notification + extension caches. + serverOpts := []policyxds.ServerOption{ + policyxds.WithOnFirstConnect(policyEngineConnected), + } + if cfg.Controller.PolicyServer.TLS.Enabled { + serverOpts = append(serverOpts, policyxds.WithTLS( + cfg.Controller.PolicyServer.TLS.CertFile, + cfg.Controller.PolicyServer.TLS.KeyFile, + )) + } + for _, nc := range extXDS.ExtraCaches { + serverOpts = append(serverOpts, policyxds.WithExtraCache(nc.Name, nc.Cache)) + } + + policyXDSServer := policyxds.NewServer(policySnapshotManager, apiKeySnapshotManager, lazyResourceSnapshotManager, cfg.Controller.PolicyServer.Port, log, serverOpts...) + go func() { + if err := policyXDSServer.Start(); err != nil { + log.Error("Policy xDS server failed", slog.Any("error", err)) + os.Exit(1) + } + }() + + // Load llm provider templates from files + templateLoader := utils.NewLLMTemplateLoader(log) + templateDir := cfg.Controller.LLM.TemplateDefinitionsPath + log.Info("Loading llm provider templates from directory", slog.String("directory", templateDir)) + templateDefinitions, err := templateLoader.LoadTemplatesFromDirectory(templateDir) + if err != nil { + log.Error("Failed to load llm provider templates", slog.Any("error", err)) + os.Exit(1) + } + log.Info("Default llm provider templates loaded", slog.Int("count", len(templateDefinitions))) + + // Create validator with policy validation support + validator := config.NewAPIValidator() + policyValidator := config.NewPolicyValidator(policyDefinitions) + validator.SetPolicyValidator(policyValidator) + + apiSvc := utils.NewAPIDeploymentService(configStore, db, snapshotManager, validator, &cfg.Router, eventHubInstance, gatewayID, secretsService) + mcpSvc := utils.NewMCPDeploymentService(configStore, db, snapshotManager, policyManager, policyValidator, eventHubInstance, gatewayID, secretsService) + llmSvc := utils.NewLLMDeploymentService(configStore, db, snapshotManager, lazyResourceXDSManager, templateDefinitions, + apiSvc, &cfg.Router, policyVersionResolver, policyValidator) + + // Wire event-gateway components (nil when NoOpExtension is used). + wiring := extXDS.EventGatewayWiring + + // Initialize and start control plane client + cpClient := controlplane.NewClient( + cfg.Controller.ControlPlane, + log, configStore, + db, snapshotManager, + validator, + &cfg.Router, + apiKeyXDSManager, apiKeyStore, + &cfg.APIKey, + policyManager, + cfg, policyDefinitions, + lazyResourceXDSManager, + templateDefinitions, + wiring.SubscriptionSnapshotUpdater, + eventHubInstance, + secretsService, + wiring.WebhookSecretStore, + wiring.WebhookSecretSnapshotManager, + ) + if err := cpClient.Start(); err != nil { + log.Error("Failed to start control plane client", slog.Any("error", err)) + } + + restAPIService := restapi.NewRestAPIService( + configStore, db, snapshotManager, policyManager, + apiSvc, apiKeyXDSManager, + cpClient, &cfg.Router, cfg, + &http.Client{Timeout: 10 * time.Second}, config.NewParser(), validator, log, + eventHubInstance, secretsService, + ) + igw := immutable.NewImmutableGW(cfg.ImmutableGateway, restAPIService, llmSvc, mcpSvc) + + // Initialize Gin router + if os.Getenv("GIN_MODE") == "" { + gin.SetMode(gin.ReleaseMode) + } + router := gin.New() + + // IMPORTANT: CorrelationIDMiddleware must be registered first. + router.Use(middleware.CorrelationIDMiddleware(log)) + router.Use(middleware.ErrorHandlingMiddleware(log)) + router.Use(middleware.LoggingMiddleware(log)) + if cfg.Controller.Metrics.Enabled { + router.Use(middleware.MetricsMiddleware()) + } + authConfig := GenerateAuthConfig(cfg, ManagementAPIBasePath, ext.AdditionalResourceRoles()) + authMiddleWare, err := authenticators.AuthMiddleware(authConfig, log) + if err != nil { + log.Error("Failed to create auth middleware", slog.Any("error", err)) + os.Exit(1) + } + router.Use(authMiddleWare) + router.Use(authenticators.AuthorizationMiddleware(authConfig, log)) + router.Use(gin.Recovery()) + + // Initialize EventListener for multi-replica sync. + evtListener := eventlistener.NewEventListener( + eventHubInstance, + configStore, + db, + snapshotManager, + apiKeyXDSManager, + lazyResourceXDSManager, + policyManager, + &cfg.Router, + log, + cfg, + policyDefinitions, + secretsService, + eventlistener.WithExtraProcessors(ext.ExtraEventProcessors(deps, extXDS)...), + ) + if err := evtListener.Start(); err != nil { + log.Error("Failed to start event listener", slog.Any("error", err)) + os.Exit(1) + } + log.Info("EventListener started for multi-replica sync") + + // Initialize API server. + apiServer := handlers.NewAPIServer( + configStore, + db, + snapshotManager, + policyManager, + lazyResourceXDSManager, + log, + cpClient, + policyDefinitions, + templateDefinitions, + validator, + apiKeyXDSManager, + cfg, + eventHubInstance, + wiring.SubscriptionSnapshotUpdater, + secretsService, + restAPIService, + wiring.WebhookSecretService, + ) + + // Load immutable gateway artifacts from the filesystem. + if err := igw.LoadArtifacts(log); err != nil { + log.Error("Failed to load immutable gateway artifacts", slog.Any("error", err)) + os.Exit(1) + } + + // Ensure initial lazy resource snapshot includes default templates. + if lazyResourceStore.Count() > 0 { + log.Info("Generating initial lazy resource snapshot for policy engine (including templates)", + slog.Int("lazy_resource_count", lazyResourceStore.Count())) + lzCtx, lzCancel := context.WithTimeout(context.Background(), 10*time.Second) + if err := lazyResourceSnapshotManager.UpdateSnapshot(lzCtx); err != nil { + log.Warn("Failed to generate initial lazy resource snapshot", slog.Any("error", err)) + } else { + log.Info("Initial lazy resource snapshot generated successfully") + } + lzCancel() + } + + // Register immutable gateway middleware. + router.Use(igw.Middleware()) + + // Register base management API routes (versioned + legacy deprecated paths). + api.RegisterHandlersWithOptions(router, apiServer, api.GinServerOptions{ + BaseURL: ManagementAPIBasePath, + }) + api.RegisterHandlersWithOptions(router, apiServer, api.GinServerOptions{ + Middlewares: []api.MiddlewareFunc{ + DeprecatedManagementPathMiddleware(ManagementAPIBasePath), + }, + }) + + // Mount extension-owned routes (e.g. event-gateway REST endpoints). + if err := ext.RegisterRoutes(router, deps, extXDS); err != nil { + log.Error("Extension RegisterRoutes failed", slog.String("extension", ext.Name()), slog.Any("error", err)) + os.Exit(1) + } + + // Start controller admin server for debug endpoints if enabled. + var controllerAdminServer *adminserver.Server + if cfg.Controller.AdminServer.Enabled { + controllerAdminServer = adminserver.NewServer(&cfg.Controller.AdminServer, apiServer, log) + go func() { + if err := controllerAdminServer.Start(); err != nil { + log.Error("Controller admin server failed", slog.Any("error", err)) + os.Exit(1) + } + }() + } + + // Start metrics server if enabled + var metricsServer *metrics.Server + var metricsCtxCancel context.CancelFunc + if cfg.Controller.Metrics.Enabled { + log.Info("Starting metrics server", slog.Int("port", cfg.Controller.Metrics.Port)) + metrics.Info.WithLabelValues(version.Version, cfg.Controller.Storage.Type, version.BuildDate).Set(1) + metricsServer = metrics.NewServer(&cfg.Controller.Metrics, log) + if err := metricsServer.Start(); err != nil { + log.Error("Metrics server failed", slog.Any("error", err)) + os.Exit(1) + } + var metricsCtx context.Context + metricsCtx, metricsCtxCancel = context.WithCancel(context.Background()) + metrics.StartMemoryMetricsUpdater(metricsCtx, 15*time.Second) + } + + // Start REST API server + log.Info("Starting REST API server", slog.Int("port", cfg.Controller.Server.APIPort)) + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Controller.Server.APIPort), + Handler: router, + ReadHeaderTimeout: 30 * time.Second, + } + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Error("Failed to start REST API server", slog.Any("error", err)) + os.Exit(1) + } + }() + + log.Info("Gateway Controller started successfully") + + // Print banner when both router and policy engine have sent their first ACK. + go func() { + <-routerConnected + <-policyEngineConnected + time.Sleep(1 * time.Second) + fmt.Print("\n\n" + + "========================================================================\n" + + "\n" + + "\n" + + " API Platform Gateway Started\n" + + "\n" + + "\n" + + "========================================================================\n" + + "\n\n") + }() + + // Wait for interrupt signal + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Info("Shutting down Gateway-Controller") + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), cfg.Controller.Server.ShutdownTimeout) + defer shutdownCancel() + + // Stop extension first so it can flush its state + ext.Shutdown(shutdownCtx) + + // Stop event listener and EventHub first + evtListener.Stop() + if err := eventHubInstance.Close(); err != nil { + log.Warn("Failed to close EventHub cleanly", slog.Any("error", err)) + } + if eventHubStorage != nil { + if err := eventHubStorage.Close(); err != nil { + log.Warn("Failed to close EventHub storage cleanly", slog.Any("error", err)) + } + } + + cpClient.Stop() + + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Error("Server forced to shutdown", slog.Any("error", err)) + } + + xdsServer.Stop() + + if policyXDSServer != nil { + policyXDSServer.Stop() + } + + if metricsServer != nil { + if metricsCtxCancel != nil { + metricsCtxCancel() + } + if err := metricsServer.Stop(shutdownCtx); err != nil { + log.Error("Failed to stop metrics server", slog.Any("error", err)) + } + } + + if controllerAdminServer != nil { + if err := controllerAdminServer.Stop(shutdownCtx); err != nil { + log.Error("Failed to stop controller admin server", slog.Any("error", err)) + } + } + + log.Info("Gateway-Controller stopped") +} diff --git a/gateway/gateway-controller/cmd/controller/runtime_bootstrap.go b/gateway/gateway-controller/pkg/bootstrap/runtime.go similarity index 82% rename from gateway/gateway-controller/cmd/controller/runtime_bootstrap.go rename to gateway/gateway-controller/pkg/bootstrap/runtime.go index 5d7de3fed8..cfc3d595bb 100644 --- a/gateway/gateway-controller/cmd/controller/runtime_bootstrap.go +++ b/gateway/gateway-controller/pkg/bootstrap/runtime.go @@ -1,4 +1,27 @@ -package main +/* + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package bootstrap provides the Run function that wires all gateway-controller +// components together and starts the server. Extracting it from package main +// allows alternative entry points (e.g. event-gateway-controller) to reuse +// the same startup sequence while injecting extension-specific components via +// the controllerext.ControllerExtension interface. +package bootstrap import ( "fmt" diff --git a/gateway/gateway-controller/cmd/controller/runtime_bootstrap_test.go b/gateway/gateway-controller/pkg/bootstrap/runtime_test.go similarity index 91% rename from gateway/gateway-controller/cmd/controller/runtime_bootstrap_test.go rename to gateway/gateway-controller/pkg/bootstrap/runtime_test.go index 54b1f5dc1e..9ad84472e8 100644 --- a/gateway/gateway-controller/cmd/controller/runtime_bootstrap_test.go +++ b/gateway/gateway-controller/pkg/bootstrap/runtime_test.go @@ -1,4 +1,22 @@ -package main +/* + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package bootstrap import ( "fmt" diff --git a/gateway/gateway-controller/pkg/controllerext/extension.go b/gateway/gateway-controller/pkg/controllerext/extension.go new file mode 100644 index 0000000000..dcd53dadf0 --- /dev/null +++ b/gateway/gateway-controller/pkg/controllerext/extension.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 controllerext defines the extensibility seam between the base gateway-controller +// and optional feature modules (such as the event-gateway-controller). +package controllerext + +import ( + "context" + "log/slog" + + "github.com/envoyproxy/go-control-plane/pkg/cache/v3" + "github.com/gin-gonic/gin" + "github.com/wso2/api-platform/common/eventhub" + "github.com/wso2/api-platform/common/webhooksecret" + "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/storage" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/webhooksecretxds" +) + +// ExtensionDeps holds the core components passed to every extension hook. +// All fields are populated by the base controller before calling extension methods. +type ExtensionDeps struct { + DB storage.Storage + ConfigStore *storage.ConfigStore + Log *slog.Logger + Config *config.Config + EncryptionProviderManager *encryption.ProviderManager // nil when no encryption providers configured + EventHub eventhub.EventHub + GatewayID string +} + +// NamedXDSCache pairs a debug label with an xDS cache contributed by an extension. +// The policyxds server adds all entries to its CombinedCache without needing to know +// what kind of resources the cache contains. +type NamedXDSCache struct { + Name string + Cache cache.Cache +} + +// EventGatewayWiring carries the event-gateway-specific managers created by an +// extension's InitXDS. The base controller (pkg/bootstrap) passes these values +// to controlplane.NewClient and handlers.NewAPIServer, which accept nil-safe pointers +// and interfaces for these fields. NoOpExtension leaves all fields as zero (nil). +type EventGatewayWiring struct { + // SubscriptionSnapshotUpdater is used by the control plane client and API server + // to refresh the subscription xDS snapshot after subscription changes. + SubscriptionSnapshotUpdater utils.SubscriptionSnapshotUpdater + // WebhookSecretStore is the in-memory store for decrypted webhook secrets. + WebhookSecretStore *webhooksecret.WebhookSecretStore + // WebhookSecretSnapshotManager pushes webhook secret updates to xDS clients. + WebhookSecretSnapshotManager *webhooksecretxds.SnapshotManager + // WebhookSecretService exposes webhook secret CRUD for the REST API. + WebhookSecretService *utils.WebhookSecretService +} + +// ExtensionXDS holds the optional xDS caches produced by an extension during InitXDS, +// plus optional wiring components for base controller call sites. +// The base controller and this package have no knowledge of what the caches contain; +// only the extension that creates them knows their semantics. +type ExtensionXDS struct { + ExtraCaches []NamedXDSCache + EventGatewayWiring EventGatewayWiring +} + +// ExtraEventProcessor handles one or more eventhub.EventType values that the base +// EventListener does not cover. Extensions register processors via ExtraEventProcessors. +type ExtraEventProcessor interface { + HandlesEventType(t eventhub.EventType) bool + Process(ctx context.Context, event eventhub.Event) +} + +// ControllerExtension is the extensibility seam between the base controller and +// optional feature sets. Implement this interface in a separate Go module and pass +// the implementation to RunController to add new capabilities without modifying +// the base controller source. +type ControllerExtension interface { + // Name returns a short identifier used in logs. + Name() string + + // AdditionalSchemaSQL returns zero or more SQL strings to execute against the + // database after the base schema has been applied. Use this to create + // extension-specific tables. backend is "sqlite" or "postgres". Return nil when + // no extra schema is needed. + AdditionalSchemaSQL(backend string) []string + + // InitXDS creates extension-owned xDS managers. Called once, after storage is + // initialised and before startup hydration. The returned ExtensionXDS caches + // are registered with the policyxds server via WithExtraCache options. + InitXDS(ctx context.Context, deps ExtensionDeps) (*ExtensionXDS, error) + + // LoadOnStartup seeds extension in-memory state from the database. Called after + // encryption providers are initialised so secrets can be decrypted at startup. + LoadOnStartup(ctx context.Context, deps ExtensionDeps, xds *ExtensionXDS) error + + // ExtraEventProcessors returns event processors to register with the EventListener + // for event types the base listener does not handle. + ExtraEventProcessors(deps ExtensionDeps, xds *ExtensionXDS) []ExtraEventProcessor + + // RegisterRoutes mounts extension-owned HTTP handlers onto the Gin router. + // Called after the base routes are registered. + RegisterRoutes(router *gin.Engine, deps ExtensionDeps, xds *ExtensionXDS) error + + // AdditionalResourceRoles returns extra method+path → role mappings to merge into + // the auth middleware configuration. Use this to protect extension-owned routes + // with the same role system as the base controller. Return nil when unused. + AdditionalResourceRoles() map[string][]string + + // Shutdown is called during graceful shutdown so the extension can release resources. + Shutdown(ctx context.Context) +} + +// NoOpExtension satisfies ControllerExtension with empty implementations. +// The base gateway-controller uses this when no extension is registered. +type NoOpExtension struct{} + +func (NoOpExtension) Name() string { return "noop" } +func (NoOpExtension) AdditionalSchemaSQL(_ string) []string { return nil } +func (NoOpExtension) InitXDS(_ context.Context, _ ExtensionDeps) (*ExtensionXDS, error) { + return &ExtensionXDS{}, nil +} +func (NoOpExtension) LoadOnStartup(_ context.Context, _ ExtensionDeps, _ *ExtensionXDS) error { + return nil +} +func (NoOpExtension) ExtraEventProcessors(_ ExtensionDeps, _ *ExtensionXDS) []ExtraEventProcessor { + return nil +} +func (NoOpExtension) RegisterRoutes(_ *gin.Engine, _ ExtensionDeps, _ *ExtensionXDS) error { + return nil +} +func (NoOpExtension) AdditionalResourceRoles() map[string][]string { return nil } +func (NoOpExtension) Shutdown(_ context.Context) {} diff --git a/gateway/gateway-controller/pkg/eventlistener/api_processor.go b/gateway/gateway-controller/pkg/eventlistener/api_processor.go index 2905caad63..fc6c468325 100644 --- a/gateway/gateway-controller/pkg/eventlistener/api_processor.go +++ b/gateway/gateway-controller/pkg/eventlistener/api_processor.go @@ -152,26 +152,6 @@ func (l *EventListener) handleAPIDelete(event eventhub.Event) { slog.Any("error", err)) } - // Remove subscriptions for this API from the DB (subscriptions.api_id is not a FK). - // Guard nil to keep unit tests (that construct EventListener without NewEventListener) from panicking. - if l.db != nil { - if err := l.db.DeleteSubscriptionsForAPINotIn(entityID, nil); err != nil { - l.logger.Warn("Failed to delete subscriptions from database after API deletion", - slog.String("api_id", entityID), - slog.Any("error", err)) - } else if l.subscriptionManager != nil { - // Refresh subscription xDS so policy engine drops tokens immediately. - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if err := l.subscriptionManager.UpdateSnapshot(ctx); err != nil { - l.logger.Warn("Failed to refresh subscription snapshot after API deletion", - slog.String("api_id", entityID), - slog.String("event_id", event.EventID), - slog.Any("error", err)) - } - } - } - if existingConfig != nil && l.apiKeyXDSManager != nil { apiName, apiVersion := extractAPINameVersion(existingConfig) if apiName != "" { diff --git a/gateway/gateway-controller/pkg/eventlistener/subscription_processor.go b/gateway/gateway-controller/pkg/eventlistener/application_processor.go similarity index 78% rename from gateway/gateway-controller/pkg/eventlistener/subscription_processor.go rename to gateway/gateway-controller/pkg/eventlistener/application_processor.go index 5e553bfdf1..a6f7c4b943 100644 --- a/gateway/gateway-controller/pkg/eventlistener/subscription_processor.go +++ b/gateway/gateway-controller/pkg/eventlistener/application_processor.go @@ -19,41 +19,15 @@ package eventlistener import ( - "context" "encoding/json" "log/slog" "sort" "strings" - "time" "github.com/wso2/api-platform/common/eventhub" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" ) -// processSubscriptionEvent refreshes replica-local subscription xDS state after subscription changes. -func (l *EventListener) processSubscriptionEvent(event eventhub.Event) { - switch event.Action { - case "CREATE", "UPDATE", "DELETE": - l.refreshSubscriptionState("subscription", event) - default: - l.logger.Warn("Unknown subscription event action", - slog.String("action", event.Action), - slog.String("entity_id", event.EntityID)) - } -} - -// processSubscriptionPlanEvent refreshes replica-local subscription xDS state after plan changes. -func (l *EventListener) processSubscriptionPlanEvent(event eventhub.Event) { - switch event.Action { - case "CREATE", "UPDATE", "DELETE": - l.refreshSubscriptionState("subscription_plan", event) - default: - l.logger.Warn("Unknown subscription plan event action", - slog.String("action", event.Action), - slog.String("entity_id", event.EntityID)) - } -} - // processApplicationEvent synchronizes replica-local API key/application state from canonical DB state. func (l *EventListener) processApplicationEvent(event eventhub.Event) { switch event.Action { @@ -240,32 +214,3 @@ func (l *EventListener) resolveAffectedApplicationAPIKeys(applicationUUID string return affectedKeys, nil } - -func (l *EventListener) refreshSubscriptionState(resource string, event eventhub.Event) { - if l.subscriptionManager == nil { - l.logger.Warn("Subscription snapshot manager not available for replica sync", - slog.String("resource", resource), - slog.String("entity_id", event.EntityID), - slog.String("event_id", event.EventID)) - return - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - if err := l.subscriptionManager.UpdateSnapshot(ctx); err != nil { - l.logger.Error("Failed to refresh subscription snapshot from replica sync event", - slog.String("resource", resource), - slog.String("action", event.Action), - slog.String("entity_id", event.EntityID), - slog.String("event_id", event.EventID), - slog.Any("error", err)) - return - } - - l.logger.Info("Successfully refreshed subscription snapshot from replica sync event", - slog.String("resource", resource), - slog.String("action", event.Action), - slog.String("entity_id", event.EntityID), - slog.String("event_id", event.EventID)) -} diff --git a/gateway/gateway-controller/pkg/eventlistener/listener.go b/gateway/gateway-controller/pkg/eventlistener/listener.go index bc0f255c13..2f5b043f2d 100644 --- a/gateway/gateway-controller/pkg/eventlistener/listener.go +++ b/gateway/gateway-controller/pkg/eventlistener/listener.go @@ -25,15 +25,13 @@ import ( "strings" "github.com/wso2/api-platform/common/eventhub" - "github.com/wso2/api-platform/common/webhooksecret" "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/controllerext" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/lazyresourcexds" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/policyxds" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/templateengine/funcs" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/webhooksecretxds" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/xds" ) @@ -44,9 +42,16 @@ type APIKeyXDSManager interface { RemoveAPIKeysByAPI(apiId, apiName, apiVersion, correlationID string) error } -// SubscriptionSnapshotUpdater defines the subscription xDS refresh surface used by the listener. -type SubscriptionSnapshotUpdater interface { - UpdateSnapshot(ctx context.Context) error +// EventListenerOption is a functional option for EventListener. +type EventListenerOption func(*EventListener) + +// WithExtraProcessors registers additional event processors that handle event types +// not covered by the base EventListener (e.g. subscription and webhook-secret events +// added by the event-gateway extension). +func WithExtraProcessors(procs ...controllerext.ExtraEventProcessor) EventListenerOption { + return func(l *EventListener) { + l.extraProcessors = append(l.extraProcessors, procs...) + } } // EventListener listens for events from EventHub and processes them @@ -56,7 +61,6 @@ type EventListener struct { store *storage.ConfigStore db storage.Storage snapshotManager *xds.SnapshotManager - subscriptionManager SubscriptionSnapshotUpdater apiKeyXDSManager APIKeyXDSManager lazyResourceManager *lazyresourcexds.LazyResourceStateManager policyManager *policyxds.PolicyManager @@ -64,10 +68,8 @@ type EventListener struct { logger *slog.Logger systemConfig *config.Config policyDefinitions map[string]models.PolicyDefinition - secretResolver funcs.SecretResolver - webhookSecretStore *webhooksecret.WebhookSecretStore - webhookSecretSnapshotManager *webhooksecretxds.SnapshotManager - providerManager *encryption.ProviderManager + secretResolver funcs.SecretResolver + extraProcessors []controllerext.ExtraEventProcessor // registered by extensions eventCh <-chan eventhub.Event ctx context.Context @@ -80,7 +82,6 @@ func NewEventListener( store *storage.ConfigStore, db storage.Storage, snapshotManager *xds.SnapshotManager, - subscriptionManager SubscriptionSnapshotUpdater, apiKeyXDSManager APIKeyXDSManager, lazyResourceManager *lazyresourcexds.LazyResourceStateManager, policyManager *policyxds.PolicyManager, @@ -89,9 +90,7 @@ func NewEventListener( systemConfig *config.Config, policyDefinitions map[string]models.PolicyDefinition, secretResolver funcs.SecretResolver, - webhookSecretStore *webhooksecret.WebhookSecretStore, - webhookSecretSnapshotManager *webhooksecretxds.SnapshotManager, - providerManager *encryption.ProviderManager, + opts ...EventListenerOption, ) *EventListener { if eventHub == nil { panic("event listener requires non-nil EventHub") @@ -111,12 +110,11 @@ func NewEventListener( } ctx, cancel := context.WithCancel(context.Background()) - return &EventListener{ + l := &EventListener{ eventHub: eventHub, store: store, db: db, snapshotManager: snapshotManager, - subscriptionManager: subscriptionManager, apiKeyXDSManager: apiKeyXDSManager, lazyResourceManager: lazyResourceManager, policyManager: policyManager, @@ -124,13 +122,14 @@ func NewEventListener( logger: logger, systemConfig: systemConfig, policyDefinitions: policyDefinitions, - secretResolver: secretResolver, - webhookSecretStore: webhookSecretStore, - webhookSecretSnapshotManager: webhookSecretSnapshotManager, - providerManager: providerManager, + secretResolver: secretResolver, ctx: ctx, cancel: cancel, } + for _, opt := range opts { + opt(l) + } + return l } // Start begins listening for events @@ -216,12 +215,6 @@ func (l *EventListener) handleEvent(event eventhub.Event) { case eventhub.EventTypeCertificate: l.logger.Info("Certificate event received (processing not yet implemented)", slog.String("entity_id", event.EntityID)) - case eventhub.EventTypeSubscription: - l.processSubscriptionEvent(event) - case eventhub.EventTypeSubscriptionPlan: - l.processSubscriptionPlanEvent(event) - case eventhub.EventTypeApplication: - l.processApplicationEvent(event) case eventhub.EventTypeLLMProvider: l.processLLMProviderEvent(event) case eventhub.EventTypeLLMProxy: @@ -230,9 +223,16 @@ func (l *EventListener) handleEvent(event eventhub.Event) { l.processLLMTemplateEvent(event) case eventhub.EventTypeMCPProxy: l.processMCPProxyEvent(event) - case eventhub.EventTypeWebhookSecret: - l.processWebhookSecretEvent(event) + case eventhub.EventTypeApplication: + l.processApplicationEvent(event) default: + // Delegate to extension-provided processors (e.g. event-gateway subscription/webhook events). + for _, p := range l.extraProcessors { + if p.HandlesEventType(event.EventType) { + p.Process(l.ctx, event) + return + } + } l.logger.Warn("Unknown event type received", slog.String("event_type", string(event.EventType)), slog.String("entity_id", event.EntityID)) diff --git a/gateway/gateway-controller/pkg/eventlistener/listener_test.go b/gateway/gateway-controller/pkg/eventlistener/listener_test.go index 46178a8196..559759efdc 100644 --- a/gateway/gateway-controller/pkg/eventlistener/listener_test.go +++ b/gateway/gateway-controller/pkg/eventlistener/listener_test.go @@ -86,15 +86,6 @@ type mockAPIKeyXDSManager struct { removeCalls []removeCall } -type mockSubscriptionSnapshotUpdater struct { - callCount int -} - -func (m *mockSubscriptionSnapshotUpdater) UpdateSnapshot(context.Context) error { - m.callCount++ - return nil -} - type storeCall struct { apiID string apiName string @@ -246,19 +237,10 @@ func TestNewEventListener_RequiresSystemConfig(t *testing.T) { &mockEventHub{subscribeCh: make(chan eventhub.Event)}, storage.NewConfigStore(), setupSQLiteDBForEventListenerTests(t), - nil, - nil, - nil, - nil, - nil, - nil, + nil, nil, nil, nil, nil, newTestLogger(), - nil, - nil, - nil, - nil, - nil, - nil, + nil, // systemConfig = nil → panic + nil, nil, ) }) } @@ -269,19 +251,10 @@ func TestNewEventListener_RequiresGatewayID(t *testing.T) { &mockEventHub{subscribeCh: make(chan eventhub.Event)}, storage.NewConfigStore(), setupSQLiteDBForEventListenerTests(t), - nil, - nil, - nil, - nil, - nil, - nil, + nil, nil, nil, nil, nil, newTestLogger(), - &config.Config{Controller: config.Controller{}}, - nil, - nil, - nil, - nil, - nil, + &config.Config{Controller: config.Controller{}}, // empty GatewayID → panic + nil, nil, ) }) } @@ -292,12 +265,7 @@ func TestStart_SubscribesWithTrimmedGatewayID(t *testing.T) { hub, storage.NewConfigStore(), setupSQLiteDBForEventListenerTests(t), - nil, - nil, - nil, - nil, - nil, - nil, + nil, nil, nil, nil, nil, newTestLogger(), &config.Config{ Controller: config.Controller{ @@ -306,11 +274,7 @@ func TestStart_SubscribesWithTrimmedGatewayID(t *testing.T) { }, }, }, - nil, - nil, - nil, - nil, - nil, + nil, nil, ) require.NoError(t, listener.Start()) @@ -335,12 +299,6 @@ func TestHandleEvent_AcceptsKnownTypesAndUnknown(t *testing.T) { EventType: eventhub.EventTypeLLMTemplate, EntityID: "tmpl-1", }) - listener.handleEvent(eventhub.Event{ - EventType: eventhub.EventTypeApplication, - Action: "UPDATE", - EntityID: "app-1", - EventID: "corr-app-1", - }) listener.handleEvent(eventhub.Event{ EventType: eventhub.EventType("UNKNOWN"), EntityID: "mystery-1", @@ -348,27 +306,10 @@ func TestHandleEvent_AcceptsKnownTypesAndUnknown(t *testing.T) { logs := logBuf.String() assert.Contains(t, logs, "Certificate event received") - assert.Contains(t, logs, "Successfully processed application replica sync event") assert.Contains(t, logs, "Unknown LLM template event action") assert.Contains(t, logs, "Unknown event type received") } -func TestHandleEvent_SubscriptionPlanRefreshesSnapshot(t *testing.T) { - updater := &mockSubscriptionSnapshotUpdater{} - listener := &EventListener{ - logger: newTestLogger(), - subscriptionManager: updater, - } - - listener.handleEvent(eventhub.Event{ - EventType: eventhub.EventTypeSubscriptionPlan, - Action: "UPDATE", - EntityID: "plan-1", - EventID: "corr-plan-update", - }) - - assert.Equal(t, 1, updater.callCount) -} func TestProcessEvents_RecoversFromPanicAndContinues(t *testing.T) { var logBuf bytes.Buffer diff --git a/gateway/gateway-controller/pkg/eventlistener/subscription_processor_test.go b/gateway/gateway-controller/pkg/eventlistener/subscription_processor_test.go deleted file mode 100644 index f483e70559..0000000000 --- a/gateway/gateway-controller/pkg/eventlistener/subscription_processor_test.go +++ /dev/null @@ -1,202 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package eventlistener - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/wso2/api-platform/common/eventhub" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" -) - -func TestHandleEvent_ApplicationUpdate_SyncsMemoryAndXDSFromDB(t *testing.T) { - store := storage.NewConfigStore() - db := setupSQLiteDBForEventListenerTests(t) - xdsManager := &mockAPIKeyXDSManager{} - - cfgA := testRestStoredConfig("test-api-a", "test-api-a", "Test API A", "v1.0.0", models.StateDeployed) - cfgB := testRestStoredConfig("test-api-b", "test-api-b", "Test API B", "v2.0.0", models.StateDeployed) - keyA := testAPIKey("api-key-a", "key-a", cfgA.UUID) - keyB := testAPIKey("api-key-b", "key-b", cfgB.UUID) - - require.NoError(t, store.Add(cfgA)) - require.NoError(t, store.Add(cfgB)) - - staleKeyA := *keyA - staleKeyA.ApplicationID = "app-uuid-1" - staleKeyA.ApplicationName = "Old App Name" - staleKeyB := *keyB - - require.NoError(t, store.StoreAPIKey(&staleKeyA)) - require.NoError(t, store.StoreAPIKey(&staleKeyB)) - - require.NoError(t, db.SaveConfig(cfgA)) - require.NoError(t, db.SaveConfig(cfgB)) - require.NoError(t, db.SaveAPIKey(keyA)) - require.NoError(t, db.SaveAPIKey(keyB)) - - _, err := db.ReplaceApplicationAPIKeyMappings( - &models.StoredApplication{ - ApplicationID: "app-id-1", - ApplicationUUID: "app-uuid-1", - ApplicationName: "New App Name", - ApplicationType: "genai", - }, - []*models.ApplicationAPIKeyMapping{{ - ApplicationUUID: "app-uuid-1", - APIKeyID: keyB.UUID, - }}, - ) - require.NoError(t, err) - - listener := &EventListener{ - store: store, - db: db, - apiKeyXDSManager: xdsManager, - logger: newTestLogger(), - } - - listener.handleEvent(eventhub.Event{ - EventType: eventhub.EventTypeApplication, - Action: "UPDATE", - EntityID: "app-uuid-1", - EventID: "corr-app-sync", - }) - - storedKeyA, err := store.GetAPIKeyByID(cfgA.UUID, keyA.UUID) - require.NoError(t, err) - assert.Empty(t, storedKeyA.ApplicationID) - assert.Empty(t, storedKeyA.ApplicationName) - - storedKeyB, err := store.GetAPIKeyByID(cfgB.UUID, keyB.UUID) - require.NoError(t, err) - assert.Equal(t, "app-uuid-1", storedKeyB.ApplicationID) - assert.Equal(t, "New App Name", storedKeyB.ApplicationName) - - if assert.Len(t, xdsManager.storeCalls, 2) { - assert.ElementsMatch(t, []string{keyA.UUID, keyB.UUID}, []string{xdsManager.storeCalls[0].apiKeyID, xdsManager.storeCalls[1].apiKeyID}) - } - assert.Empty(t, xdsManager.revokeCalls) - assert.Empty(t, xdsManager.removeCalls) -} - -func TestHandleEvent_ApplicationUpdate_ReloadedKeyKeepsCurrentApplicationMapping(t *testing.T) { - store := storage.NewConfigStore() - db := setupSQLiteDBForEventListenerTests(t) - xdsManager := &mockAPIKeyXDSManager{} - - cfg := testRestStoredConfig("test-api-a", "test-api-a", "Test API A", "v1.0.0", models.StateDeployed) - key := testAPIKey("api-key-a", "key-a", cfg.UUID) - - require.NoError(t, store.Add(cfg)) - - staleKey := *key - staleKey.ApplicationID = "app-uuid-old" - staleKey.ApplicationName = "Old App Name" - require.NoError(t, store.StoreAPIKey(&staleKey)) - - require.NoError(t, db.SaveConfig(cfg)) - require.NoError(t, db.SaveAPIKey(key)) - _, err := db.ReplaceApplicationAPIKeyMappings( - &models.StoredApplication{ - ApplicationID: "app-id-new", - ApplicationUUID: "app-uuid-new", - ApplicationName: "New App Name", - ApplicationType: "genai", - }, - []*models.ApplicationAPIKeyMapping{{ - ApplicationUUID: "app-uuid-new", - APIKeyID: key.UUID, - }}, - ) - require.NoError(t, err) - - listener := &EventListener{ - store: store, - db: db, - apiKeyXDSManager: xdsManager, - logger: newTestLogger(), - } - - listener.handleEvent(eventhub.Event{ - EventType: eventhub.EventTypeApplication, - Action: "UPDATE", - EntityID: "app-uuid-old", - EventID: "corr-app-reassign", - }) - - storedKey, err := store.GetAPIKeyByID(cfg.UUID, key.UUID) - require.NoError(t, err) - assert.Equal(t, "app-uuid-new", storedKey.ApplicationID) - assert.Equal(t, "New App Name", storedKey.ApplicationName) - - if assert.Len(t, xdsManager.storeCalls, 1) { - assert.Equal(t, key.UUID, xdsManager.storeCalls[0].apiKeyID) - } - assert.Empty(t, xdsManager.revokeCalls) - assert.Empty(t, xdsManager.removeCalls) -} - -func TestHandleEvent_ApplicationUpdate_RemovedKeysFromEventDataAreAlsoSynced(t *testing.T) { - store := storage.NewConfigStore() - db := setupSQLiteDBForEventListenerTests(t) - xdsManager := &mockAPIKeyXDSManager{} - - cfg := testRestStoredConfig("test-api-a", "test-api-a", "Test API A", "v1.0.0", models.StateDeployed) - key := testAPIKey("api-key-a", "key-a", cfg.UUID) - - require.NoError(t, store.Add(cfg)) - require.NoError(t, db.SaveConfig(cfg)) - require.NoError(t, db.SaveAPIKey(key)) - - eventData, err := json.Marshal(models.ApplicationEventData{ - RemovedAPIKeyIDs: []string{key.UUID}, - }) - require.NoError(t, err) - - listener := &EventListener{ - store: store, - db: db, - apiKeyXDSManager: xdsManager, - logger: newTestLogger(), - } - - listener.handleEvent(eventhub.Event{ - EventType: eventhub.EventTypeApplication, - Action: "UPDATE", - EntityID: "app-uuid-removed", - EventID: "corr-app-removed", - EventData: string(eventData), - }) - - storedKey, err := store.GetAPIKeyByID(cfg.UUID, key.UUID) - require.NoError(t, err) - assert.Empty(t, storedKey.ApplicationID) - assert.Empty(t, storedKey.ApplicationName) - - if assert.Len(t, xdsManager.storeCalls, 1) { - assert.Equal(t, key.UUID, xdsManager.storeCalls[0].apiKeyID) - } - assert.Empty(t, xdsManager.revokeCalls) - assert.Empty(t, xdsManager.removeCalls) -} diff --git a/gateway/gateway-controller/pkg/eventlistener/webhook_secret_processor.go b/gateway/gateway-controller/pkg/eventlistener/webhook_secret_processor.go deleted file mode 100644 index e768bc7db0..0000000000 --- a/gateway/gateway-controller/pkg/eventlistener/webhook_secret_processor.go +++ /dev/null @@ -1,171 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package eventlistener - -import ( - "log/slog" - - "github.com/wso2/api-platform/common/eventhub" - "github.com/wso2/api-platform/common/webhooksecret" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/encryption" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" -) - -// processWebhookSecretEvent dispatches webhook secret events by action. -func (l *EventListener) processWebhookSecretEvent(event eventhub.Event) { - switch event.Action { - case "CREATE", "UPDATE": - l.handleWebhookSecretUpsert(event) - case "DELETE": - l.handleWebhookSecretDelete(event) - default: - l.logger.Warn("Unknown webhook secret event action", - slog.String("action", event.Action), - slog.String("entity_id", event.EntityID)) - } -} - -// handleWebhookSecretUpsert handles webhook secret create/regenerate events. -// It fetches the secret from the DB by UUID, decrypts it, and updates the in-memory store. -func (l *EventListener) handleWebhookSecretUpsert(event eventhub.Event) { - artifactUUID, secretUUID, secretName, err := webhooksecret.ParseWebhookSecretEntityID(event.EntityID) - if err != nil { - l.logger.Error("Failed to parse webhook secret event entity ID", - slog.String("action", event.Action), - slog.String("entity_id", event.EntityID), - slog.Any("error", err)) - return - } - - l.logger.Info("Processing webhook secret upsert event", - slog.String("action", event.Action), - slog.String("artifact_uuid", artifactUUID), - slog.String("secret_uuid", secretUUID), - slog.String("secret_name", secretName), - slog.String("event_id", event.EventID)) - - if l.webhookSecretStore == nil { - l.logger.Warn("Webhook secret store not available, skipping upsert event", - slog.String("secret_uuid", secretUUID)) - return - } - - if l.providerManager == nil { - l.logger.Warn("Encryption provider manager not available, skipping webhook secret upsert event", - slog.String("secret_uuid", secretUUID)) - return - } - - ws, err := l.db.GetWebhookSecretByUUID(secretUUID) - if err != nil { - if storage.IsNotFoundError(err) { - l.logger.Warn("Webhook secret not found in database for upsert event", - slog.String("secret_uuid", secretUUID), - slog.String("event_id", event.EventID)) - return - } - l.logger.Error("Failed to fetch webhook secret from database", - slog.String("secret_uuid", secretUUID), - slog.Any("error", err)) - return - } - - payload, err := encryption.UnmarshalPayload(string(ws.Ciphertext)) - if err != nil { - l.logger.Error("Failed to unmarshal webhook secret ciphertext", - slog.String("secret_uuid", secretUUID), - slog.Any("error", err)) - return - } - - plaintext, err := l.providerManager.Decrypt(payload) - if err != nil { - l.logger.Error("Failed to decrypt webhook secret", - slog.String("secret_uuid", secretUUID), - slog.Any("error", err)) - return - } - - if err := l.webhookSecretStore.Store(ws.ArtifactUUID, ws.Name, string(plaintext)); err != nil { - l.logger.Error("Failed to store webhook secret in memory store", - slog.String("secret_uuid", secretUUID), - slog.String("artifact_uuid", ws.ArtifactUUID), - slog.Any("error", err)) - return - } - - if l.webhookSecretSnapshotManager != nil { - if err := l.webhookSecretSnapshotManager.RefreshSnapshot(); err != nil { - l.logger.Error("Failed to refresh webhook secret xDS snapshot after upsert", - slog.String("artifact_uuid", ws.ArtifactUUID), - slog.Any("error", err)) - } - } - - l.logger.Info("Successfully processed webhook secret upsert event", - slog.String("action", event.Action), - slog.String("artifact_uuid", ws.ArtifactUUID), - slog.String("secret_name", ws.Name), - slog.String("event_id", event.EventID)) -} - -// handleWebhookSecretDelete handles webhook secret delete events. -// The entity ID carries artifactUUID, secretUUID, and secretName to avoid a DB round-trip. -func (l *EventListener) handleWebhookSecretDelete(event eventhub.Event) { - artifactUUID, secretUUID, secretName, err := webhooksecret.ParseWebhookSecretEntityID(event.EntityID) - if err != nil { - l.logger.Error("Failed to parse webhook secret delete event entity ID", - slog.String("entity_id", event.EntityID), - slog.Any("error", err)) - return - } - - l.logger.Info("Processing webhook secret delete event", - slog.String("artifact_uuid", artifactUUID), - slog.String("secret_uuid", secretUUID), - slog.String("secret_name", secretName), - slog.String("event_id", event.EventID)) - - if l.webhookSecretStore == nil { - l.logger.Warn("Webhook secret store not available, skipping delete event", - slog.String("secret_uuid", secretUUID)) - return - } - - if err := l.webhookSecretStore.Remove(artifactUUID, secretName); err != nil && err != webhooksecret.ErrNotFound { - l.logger.Error("Failed to remove webhook secret from memory store", - slog.String("artifact_uuid", artifactUUID), - slog.String("secret_name", secretName), - slog.Any("error", err)) - return - } - - if l.webhookSecretSnapshotManager != nil { - if err := l.webhookSecretSnapshotManager.RefreshSnapshot(); err != nil { - l.logger.Error("Failed to refresh webhook secret xDS snapshot after delete", - slog.String("artifact_uuid", artifactUUID), - slog.Any("error", err)) - } - } - - l.logger.Info("Successfully processed webhook secret delete event", - slog.String("artifact_uuid", artifactUUID), - slog.String("secret_name", secretName), - slog.String("event_id", event.EventID)) -} diff --git a/gateway/gateway-controller/pkg/policyxds/combined_cache.go b/gateway/gateway-controller/pkg/policyxds/combined_cache.go index ffd2487aaa..609ff7c687 100644 --- a/gateway/gateway-controller/pkg/policyxds/combined_cache.go +++ b/gateway/gateway-controller/pkg/policyxds/combined_cache.go @@ -28,8 +28,14 @@ import ( "github.com/envoyproxy/go-control-plane/pkg/cache/v3" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/apikeyxds" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/lazyresourcexds" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/subscriptionxds" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/webhooksecretxds" +) + +// Type URL constants for extension-provided xDS caches. +// These match the values defined in the subscriptionxds and webhooksecretxds packages +// but are inlined here so the policyxds package does not import those packages directly. +const ( + subscriptionStateTypeURL = "api-platform.wso2.org/v1.SubscriptionState" + webhookSecretStateTypeURL = "api-platform.wso2.org/v1.WebhookSecretState" ) // CombinedCache combines policy, API key, lazy resource, subscription, route config, event channel, @@ -71,8 +77,8 @@ type combinedWatcher struct { // subscription, route config, event channel, and webhook secret caches. // Returns a cache.Cache interface implementation. func NewCombinedCache(policyCache cache.Cache, apiKeyCache cache.Cache, lazyResourceCache cache.Cache, subscriptionCache cache.Cache, routeConfigCache cache.Cache, eventChannelCache cache.Cache, webhookSecretCache cache.Cache, logger *slog.Logger) cache.Cache { - if policyCache == nil || apiKeyCache == nil || lazyResourceCache == nil || subscriptionCache == nil { - panic("policyCache, apiKeyCache, lazyResourceCache, and subscriptionCache must not be nil") + if policyCache == nil || apiKeyCache == nil || lazyResourceCache == nil { + panic("policyCache, apiKeyCache, and lazyResourceCache must not be nil") } if logger == nil { logger = slog.Default() @@ -160,7 +166,11 @@ func (c *CombinedCache) CreateWatch(request *cache.Request, subscription cache.S delete(c.watchers, watcherID) return nil, fmt.Errorf("create lazy resource watch: %w", err) } - case subscriptionxds.SubscriptionStateTypeURL: + case subscriptionStateTypeURL: + if c.subscriptionCache == nil { + delete(c.watchers, watcherID) + return nil, fmt.Errorf("subscription cache is not configured for type %s", request.TypeUrl) + } subscriptionResponseChan = make(chan cache.Response, 1) watcher.subscriptionCancel, err = c.subscriptionCache.CreateWatch(request, subscription, subscriptionResponseChan) if err != nil { @@ -178,7 +188,7 @@ func (c *CombinedCache) CreateWatch(request *cache.Request, subscription cache.S delete(c.watchers, watcherID) return nil, fmt.Errorf("create event channel watch: %w", err) } - case webhooksecretxds.WebhookSecretStateTypeURL: + case webhookSecretStateTypeURL: if c.webhookSecretCache == nil { delete(c.watchers, watcherID) return nil, fmt.Errorf("webhook secret cache is not configured for type %s", request.TypeUrl) @@ -552,7 +562,7 @@ func (c *CombinedCache) CreateDeltaWatch(request *cache.DeltaRequest, subscripti return nil, fmt.Errorf("create lazy resource delta watch: %w", err) } } - case subscriptionxds.SubscriptionStateTypeURL: + case subscriptionStateTypeURL: if deltaWatcher, ok := c.subscriptionCache.(interface { CreateDeltaWatch(*cache.DeltaRequest, cache.Subscription, chan cache.DeltaResponse) (func(), error) }); ok { @@ -572,7 +582,7 @@ func (c *CombinedCache) CreateDeltaWatch(request *cache.DeltaRequest, subscripti } } } - case webhooksecretxds.WebhookSecretStateTypeURL: + case webhookSecretStateTypeURL: if c.webhookSecretCache != nil { if deltaWatcher, ok := c.webhookSecretCache.(interface { CreateDeltaWatch(*cache.DeltaRequest, cache.Subscription, chan cache.DeltaResponse) (func(), error) diff --git a/gateway/gateway-controller/pkg/policyxds/combined_cache_test.go b/gateway/gateway-controller/pkg/policyxds/combined_cache_test.go index a6377d7432..38393758bc 100644 --- a/gateway/gateway-controller/pkg/policyxds/combined_cache_test.go +++ b/gateway/gateway-controller/pkg/policyxds/combined_cache_test.go @@ -178,10 +178,9 @@ func TestNewCombinedCache(t *testing.T) { }) }) - t.Run("panics with nil subscription cache", func(t *testing.T) { - assert.Panics(t, func() { - NewCombinedCache(policyCache, apiKeyCache, lazyResourceCache, nil, nil, nil, nil, logger) - }) + t.Run("allows nil subscription cache (optional extension)", func(t *testing.T) { + cc := NewCombinedCache(policyCache, apiKeyCache, lazyResourceCache, nil, nil, nil, nil, logger) + assert.NotNil(t, cc) }) t.Run("uses default logger when nil", func(t *testing.T) { diff --git a/gateway/gateway-controller/pkg/policyxds/server.go b/gateway/gateway-controller/pkg/policyxds/server.go index 5049328ddf..2cb6bd51ba 100644 --- a/gateway/gateway-controller/pkg/policyxds/server.go +++ b/gateway/gateway-controller/pkg/policyxds/server.go @@ -29,8 +29,6 @@ import ( "github.com/envoyproxy/go-control-plane/pkg/cache/v3" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/apikeyxds" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/lazyresourcexds" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/subscriptionxds" - "github.com/wso2/api-platform/gateway/gateway-controller/pkg/webhooksecretxds" core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" discoverygrpc "github.com/envoyproxy/go-control-plane/envoy/service/discovery/v3" @@ -40,19 +38,24 @@ import ( "google.golang.org/grpc/keepalive" ) +// namedCache pairs a type URL with an xDS cache contributed by an extension. +type namedCache struct { + typeURL string + c cache.Cache +} + // Server is the policy xDS gRPC server type Server struct { - grpcServer *grpc.Server - xdsServer server.Server - snapshotManager *SnapshotManager - apiKeySnapshotMgr *apikeyxds.APIKeySnapshotManager - lazyResourceSnapshotMgr *lazyresourcexds.LazyResourceSnapshotManager - subscriptionSnapshotMgr *subscriptionxds.SnapshotManager - webhookSecretSnapshotMgr *webhooksecretxds.SnapshotManager - port int - tlsConfig *TLSConfig - onFirstConnect chan struct{} - logger *slog.Logger + grpcServer *grpc.Server + xdsServer server.Server + snapshotManager *SnapshotManager + apiKeySnapshotMgr *apikeyxds.APIKeySnapshotManager + lazyResourceSnapshotMgr *lazyresourcexds.LazyResourceSnapshotManager + extraCaches []namedCache // extension-provided caches (e.g. subscription, webhook-secret) + port int + tlsConfig *TLSConfig + onFirstConnect chan struct{} + logger *slog.Logger } // TLSConfig holds TLS configuration for the server @@ -83,17 +86,24 @@ func WithOnFirstConnect(ch chan struct{}) ServerOption { } } +// WithExtraCache registers an additional xDS cache keyed by its type URL. +// Extensions (e.g. event-gateway) use this to plug in subscription and webhook-secret caches. +// The name is used for debug logging only. +func WithExtraCache(typeURL string, c cache.Cache) ServerOption { + return func(s *Server) { + s.extraCaches = append(s.extraCaches, namedCache{typeURL: typeURL, c: c}) + } +} + // NewServer creates a new policy xDS server -func NewServer(snapshotManager *SnapshotManager, apiKeySnapshotMgr *apikeyxds.APIKeySnapshotManager, lazyResourceSnapshotMgr *lazyresourcexds.LazyResourceSnapshotManager, subscriptionSnapshotMgr *subscriptionxds.SnapshotManager, webhookSecretSnapshotMgr *webhooksecretxds.SnapshotManager, port int, logger *slog.Logger, opts ...ServerOption) *Server { +func NewServer(snapshotManager *SnapshotManager, apiKeySnapshotMgr *apikeyxds.APIKeySnapshotManager, lazyResourceSnapshotMgr *lazyresourcexds.LazyResourceSnapshotManager, port int, logger *slog.Logger, opts ...ServerOption) *Server { s := &Server{ - snapshotManager: snapshotManager, - apiKeySnapshotMgr: apiKeySnapshotMgr, - lazyResourceSnapshotMgr: lazyResourceSnapshotMgr, - subscriptionSnapshotMgr: subscriptionSnapshotMgr, - webhookSecretSnapshotMgr: webhookSecretSnapshotMgr, - port: port, - logger: logger, - tlsConfig: &TLSConfig{Enabled: false}, + snapshotManager: snapshotManager, + apiKeySnapshotMgr: apiKeySnapshotMgr, + lazyResourceSnapshotMgr: lazyResourceSnapshotMgr, + port: port, + logger: logger, + tlsConfig: &TLSConfig{Enabled: false}, } // Apply options @@ -128,17 +138,24 @@ func NewServer(snapshotManager *SnapshotManager, apiKeySnapshotMgr *apikeyxds.AP grpcServer := grpc.NewServer(grpcOpts...) - // Create combined cache that handles policy chains, route configs, API key state, lazy resources, subscription state, event channel configs, and webhook secrets + // Extract extra caches by type URL so extensions can plug in subscription/webhook-secret caches. + var subscriptionCache, webhookSecretCache cache.Cache + for _, nc := range s.extraCaches { + switch nc.typeURL { + case subscriptionStateTypeURL: + subscriptionCache = nc.c + case webhookSecretStateTypeURL: + webhookSecretCache = nc.c + } + } + + // Create combined cache that handles policy chains, route configs, API key state, + // lazy resources, subscription state, event channel configs, and webhook secrets. policyCache := snapshotManager.GetPolicyCache() routeConfigCache := snapshotManager.GetRouteCache() eventChannelCache := snapshotManager.GetEventChannelCache() apiKeyCache := apiKeySnapshotMgr.GetCache() lazyResourceCache := lazyResourceSnapshotMgr.GetCache() - subscriptionCache := subscriptionSnapshotMgr.GetCache() - var webhookSecretCache cache.Cache - if s.webhookSecretSnapshotMgr != nil { - webhookSecretCache = s.webhookSecretSnapshotMgr.GetCache() - } combinedCache := NewCombinedCache(policyCache, apiKeyCache, lazyResourceCache, subscriptionCache, routeConfigCache, eventChannelCache, webhookSecretCache, logger) callbacks := &serverCallbacks{ diff --git a/gateway/gateway-controller/pkg/storage/event-gateway-db.postgres.sql b/gateway/gateway-controller/pkg/storage/event-gateway-db.postgres.sql new file mode 100644 index 0000000000..a09626bd51 --- /dev/null +++ b/gateway/gateway-controller/pkg/storage/event-gateway-db.postgres.sql @@ -0,0 +1,57 @@ +-- PostgreSQL Schema for Event-Gateway extension tables. +-- Applied after the base gateway-controller-db.postgres.sql schema. + +-- Subscription plans table (organization-scoped rate/billing plans) +CREATE TABLE IF NOT EXISTS subscription_plans ( + uuid TEXT NOT NULL, + gateway_id TEXT NOT NULL, + plan_name TEXT NOT NULL, + billing_plan TEXT, + stop_on_quota_reach BOOLEAN DEFAULT TRUE, + throttle_limit_count INTEGER, + throttle_limit_unit TEXT, + expiry_time TIMESTAMPTZ, + status TEXT NOT NULL CHECK(status IN ('ACTIVE', 'INACTIVE')) DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (gateway_id, uuid), + UNIQUE(gateway_id, plan_name) +); + +-- Subscriptions table (application-level subscriptions for APIs) +-- subscription_token_hash: for xDS validation and request validation (Platform-API stores original token) +CREATE TABLE IF NOT EXISTS subscriptions ( + uuid TEXT NOT NULL, + gateway_id TEXT NOT NULL, + api_id TEXT NOT NULL, + application_id TEXT, + subscription_token_hash TEXT NOT NULL, + subscription_plan_id TEXT, + billing_customer_id TEXT, + billing_subscription_id TEXT, + status TEXT NOT NULL CHECK(status IN ('ACTIVE', 'INACTIVE', 'REVOKED')) DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (gateway_id, uuid), + FOREIGN KEY (gateway_id, subscription_plan_id) REFERENCES subscription_plans(gateway_id, uuid), + UNIQUE(gateway_id, api_id, subscription_token_hash) +); +CREATE INDEX IF NOT EXISTS idx_subscriptions_application_id ON subscriptions(application_id); + +-- Per-API HMAC secrets for the websub-hmac-auth policy. +-- Ciphertext is AES-256-GCM encrypted; plaintext is never stored. +CREATE TABLE IF NOT EXISTS webhook_secrets ( + uuid TEXT NOT NULL, + gateway_id TEXT NOT NULL, + artifact_uuid TEXT NOT NULL, + name TEXT NOT NULL, + display_name TEXT NOT NULL, + ciphertext BYTEA NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'revoked')), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (gateway_id, uuid), + UNIQUE (gateway_id, artifact_uuid, name), + FOREIGN KEY (gateway_id, artifact_uuid) REFERENCES artifacts(gateway_id, uuid) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_webhook_secrets_artifact ON webhook_secrets(gateway_id, artifact_uuid); diff --git a/gateway/gateway-controller/pkg/storage/event-gateway-db.sql b/gateway/gateway-controller/pkg/storage/event-gateway-db.sql new file mode 100644 index 0000000000..05a1efe252 --- /dev/null +++ b/gateway/gateway-controller/pkg/storage/event-gateway-db.sql @@ -0,0 +1,57 @@ +-- SQLite Schema for Event-Gateway extension tables. +-- Applied after the base gateway-controller-db.sql schema. + +-- Subscription plans table (organization-scoped rate/billing plans) +CREATE TABLE IF NOT EXISTS subscription_plans ( + uuid TEXT NOT NULL, + gateway_id TEXT NOT NULL, + plan_name TEXT NOT NULL, + billing_plan TEXT, + stop_on_quota_reach INTEGER DEFAULT 1, + throttle_limit_count INTEGER, + throttle_limit_unit TEXT, + expiry_time TIMESTAMP, + status TEXT NOT NULL CHECK(status IN ('ACTIVE', 'INACTIVE')) DEFAULT 'ACTIVE', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (gateway_id, uuid), + UNIQUE(gateway_id, plan_name) +); + +-- Subscriptions table (application-level subscriptions for APIs) +-- subscription_token_hash: for xDS validation and request validation (Platform-API stores original token) +CREATE TABLE IF NOT EXISTS subscriptions ( + uuid TEXT NOT NULL, + gateway_id TEXT NOT NULL, + api_id TEXT NOT NULL, + application_id TEXT, + subscription_token_hash TEXT NOT NULL, + subscription_plan_id TEXT, + billing_customer_id TEXT, + billing_subscription_id TEXT, + status TEXT NOT NULL CHECK(status IN ('ACTIVE', 'INACTIVE', 'REVOKED')) DEFAULT 'ACTIVE', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (gateway_id, uuid), + FOREIGN KEY (gateway_id, subscription_plan_id) REFERENCES subscription_plans(gateway_id, uuid), + UNIQUE(gateway_id, api_id, subscription_token_hash) +); +CREATE INDEX IF NOT EXISTS idx_subscriptions_application_id ON subscriptions(application_id); + +-- Per-API HMAC secrets for the websub-hmac-auth policy. +-- Ciphertext is AES-256-GCM encrypted; plaintext is never stored. +CREATE TABLE IF NOT EXISTS webhook_secrets ( + uuid TEXT NOT NULL, + gateway_id TEXT NOT NULL, + artifact_uuid TEXT NOT NULL, + name TEXT NOT NULL, + display_name TEXT NOT NULL, + ciphertext BLOB NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'revoked')), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (gateway_id, uuid), + UNIQUE (gateway_id, artifact_uuid, name), + FOREIGN KEY (gateway_id, artifact_uuid) REFERENCES artifacts(gateway_id, uuid) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_webhook_secrets_artifact ON webhook_secrets(gateway_id, artifact_uuid); diff --git a/gateway/gateway-controller/pkg/storage/factory.go b/gateway/gateway-controller/pkg/storage/factory.go index 8de20c95d3..5f2dbb7008 100644 --- a/gateway/gateway-controller/pkg/storage/factory.go +++ b/gateway/gateway-controller/pkg/storage/factory.go @@ -35,10 +35,11 @@ type BackendConfig struct { } // NewStorage creates the configured persistent storage backend. -func NewStorage(cfg BackendConfig, logger *slog.Logger) (Storage, error) { +// extraSchemaSQL contains additional DDL statements applied after the base schema (e.g. from extensions). +func NewStorage(cfg BackendConfig, logger *slog.Logger, extraSchemaSQL ...string) (Storage, error) { switch cfg.Type { case "sqlite": - backend, err := newSQLiteStorage(cfg.SQLitePath, logger) + backend, err := newSQLiteStorage(cfg.SQLitePath, logger, extraSchemaSQL...) if err != nil { if strings.Contains(err.Error(), "database is locked") { return nil, fmt.Errorf("%w: %w", ErrDatabaseLocked, err) @@ -52,7 +53,7 @@ func NewStorage(cfg BackendConfig, logger *slog.Logger) (Storage, error) { return store, nil case "postgres": - backend, err := newPostgresStorage(cfg.Postgres, logger) + backend, err := newPostgresStorage(cfg.Postgres, logger, extraSchemaSQL...) if err != nil { return nil, err } diff --git a/gateway/gateway-controller/pkg/storage/gateway-controller-db.postgres.sql b/gateway/gateway-controller/pkg/storage/gateway-controller-db.postgres.sql index 522a091d2e..3032e43645 100644 --- a/gateway/gateway-controller/pkg/storage/gateway-controller-db.postgres.sql +++ b/gateway/gateway-controller/pkg/storage/gateway-controller-db.postgres.sql @@ -44,6 +44,14 @@ CREATE TABLE IF NOT EXISTS websub_apis ( FOREIGN KEY(gateway_id, uuid) REFERENCES artifacts(gateway_id, uuid) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS webbroker_apis ( + uuid TEXT NOT NULL, + gateway_id TEXT NOT NULL, + configuration TEXT NOT NULL, + PRIMARY KEY (gateway_id, uuid), + FOREIGN KEY(gateway_id, uuid) REFERENCES artifacts(gateway_id, uuid) ON DELETE CASCADE +); + CREATE TABLE IF NOT EXISTS llm_providers ( uuid TEXT NOT NULL, gateway_id TEXT NOT NULL, @@ -123,45 +131,6 @@ CREATE TABLE IF NOT EXISTS api_keys ( CREATE INDEX IF NOT EXISTS idx_api_key_status ON api_keys(status); CREATE INDEX IF NOT EXISTS idx_created_by ON api_keys(created_by); --- Subscription plans table (organization-scoped rate/billing plans) -CREATE TABLE IF NOT EXISTS subscription_plans ( - uuid TEXT NOT NULL, - gateway_id TEXT NOT NULL, - plan_name TEXT NOT NULL, - billing_plan TEXT, - stop_on_quota_reach BOOLEAN DEFAULT TRUE, - throttle_limit_count INTEGER, - throttle_limit_unit TEXT, - expiry_time TIMESTAMPTZ, - status TEXT NOT NULL CHECK(status IN ('ACTIVE', 'INACTIVE')) DEFAULT 'ACTIVE', - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (gateway_id, uuid), - UNIQUE(gateway_id, plan_name) -); - --- Subscriptions table (application-level subscriptions for REST APIs, even before deployment) --- subscription_token_hash: for xDS validation and request validation (Platform-API stores original token) -CREATE TABLE IF NOT EXISTS subscriptions ( - uuid TEXT NOT NULL, - gateway_id TEXT NOT NULL, - api_id TEXT NOT NULL, - application_id TEXT, - subscription_token_hash TEXT NOT NULL, - subscription_plan_id TEXT, - -- NEW COLUMNS: billing_customer_id and billing_subscription_id must be added - -- to existing deployments via ALTER TABLE migration. - billing_customer_id TEXT, - billing_subscription_id TEXT, - status TEXT NOT NULL CHECK(status IN ('ACTIVE', 'INACTIVE', 'REVOKED')) DEFAULT 'ACTIVE', - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (gateway_id, uuid), - FOREIGN KEY (gateway_id, subscription_plan_id) REFERENCES subscription_plans(gateway_id, uuid), - UNIQUE(gateway_id, api_id, subscription_token_hash) -); -CREATE INDEX IF NOT EXISTS idx_subscriptions_application_id ON subscriptions(application_id); - -- Table for gateway states (used by eventhub for multi-replica sync) CREATE TABLE IF NOT EXISTS gateway_states ( gateway_id TEXT PRIMARY KEY, @@ -222,21 +191,3 @@ CREATE TABLE IF NOT EXISTS secrets ( PRIMARY KEY (gateway_id, handle) ); --- Per-API HMAC secrets for the websub-hmac-auth policy. --- Ciphertext is AES-256-GCM encrypted; plaintext is never stored. -CREATE TABLE IF NOT EXISTS webhook_secrets ( - uuid TEXT NOT NULL, - gateway_id TEXT NOT NULL, - artifact_uuid TEXT NOT NULL, - name TEXT NOT NULL, - display_name TEXT NOT NULL, - ciphertext BYTEA NOT NULL, - status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'revoked')), - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (gateway_id, uuid), - UNIQUE (gateway_id, artifact_uuid, name), - FOREIGN KEY (gateway_id, artifact_uuid) REFERENCES artifacts(gateway_id, uuid) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS idx_webhook_secrets_artifact ON webhook_secrets(gateway_id, artifact_uuid); diff --git a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql index 2afd2a6194..690a8c2e0f 100644 --- a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql +++ b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql @@ -189,45 +189,6 @@ CREATE TABLE IF NOT EXISTS api_keys ( CREATE INDEX IF NOT EXISTS idx_api_key_status ON api_keys(status); CREATE INDEX IF NOT EXISTS idx_created_by ON api_keys(created_by); --- Subscription plans table (organization-scoped rate/billing plans) -CREATE TABLE IF NOT EXISTS subscription_plans ( - uuid TEXT NOT NULL, - gateway_id TEXT NOT NULL, - plan_name TEXT NOT NULL, - billing_plan TEXT, - stop_on_quota_reach INTEGER DEFAULT 1, - throttle_limit_count INTEGER, - throttle_limit_unit TEXT, - expiry_time TIMESTAMP, - status TEXT NOT NULL CHECK(status IN ('ACTIVE', 'INACTIVE')) DEFAULT 'ACTIVE', - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (gateway_id, uuid), - UNIQUE(gateway_id, plan_name) -); - --- Subscriptions table (application-level subscriptions for REST APIs, even before deployment) --- subscription_token_hash: for xDS validation and request validation (Platform-API stores original token) -CREATE TABLE IF NOT EXISTS subscriptions ( - uuid TEXT NOT NULL, - gateway_id TEXT NOT NULL, - api_id TEXT NOT NULL, - application_id TEXT, - subscription_token_hash TEXT NOT NULL, - subscription_plan_id TEXT, - -- NEW COLUMNS: billing_customer_id and billing_subscription_id must be added - -- to existing deployments via ALTER TABLE migration. - billing_customer_id TEXT, - billing_subscription_id TEXT, - status TEXT NOT NULL CHECK(status IN ('ACTIVE', 'INACTIVE', 'REVOKED')) DEFAULT 'ACTIVE', - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (gateway_id, uuid), - FOREIGN KEY (gateway_id, subscription_plan_id) REFERENCES subscription_plans(gateway_id, uuid), - UNIQUE(gateway_id, api_id, subscription_token_hash) -); -CREATE INDEX IF NOT EXISTS idx_subscriptions_application_id ON subscriptions(application_id); - -- Table for gateway states (used by eventhub for multi-replica sync) CREATE TABLE IF NOT EXISTS gateway_states ( gateway_id TEXT PRIMARY KEY, @@ -288,23 +249,4 @@ CREATE TABLE IF NOT EXISTS secrets ( PRIMARY KEY (gateway_id, handle) ); --- Per-API HMAC secrets for the websub-hmac-auth policy. --- Ciphertext is AES-256-GCM encrypted; plaintext is never stored. -CREATE TABLE IF NOT EXISTS webhook_secrets ( - uuid TEXT NOT NULL, - gateway_id TEXT NOT NULL, - artifact_uuid TEXT NOT NULL, - name TEXT NOT NULL, - display_name TEXT NOT NULL, - ciphertext BLOB NOT NULL, - status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'revoked')), - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (gateway_id, uuid), - UNIQUE (gateway_id, artifact_uuid, name), - FOREIGN KEY (gateway_id, artifact_uuid) REFERENCES artifacts(gateway_id, uuid) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS idx_webhook_secrets_artifact ON webhook_secrets(gateway_id, artifact_uuid); - PRAGMA user_version = 3; diff --git a/gateway/gateway-controller/pkg/storage/postgres.go b/gateway/gateway-controller/pkg/storage/postgres.go index 649a049273..ccd87f56cf 100644 --- a/gateway/gateway-controller/pkg/storage/postgres.go +++ b/gateway/gateway-controller/pkg/storage/postgres.go @@ -62,12 +62,13 @@ type PostgresConnectionConfig struct { // PostgresStorage implements the Storage interface using PostgreSQL. type PostgresStorage struct { - db *sql.DB - logger *slog.Logger + db *sql.DB + logger *slog.Logger + extraSchemas []string } // newPostgresStorage creates a new PostgreSQL storage instance. -func newPostgresStorage(cfg PostgresConnectionConfig, logger *slog.Logger) (*PostgresStorage, error) { +func newPostgresStorage(cfg PostgresConnectionConfig, logger *slog.Logger, extraSchemaSQL ...string) (*PostgresStorage, error) { cfg = withDefaultPostgresConfig(cfg) dsn, err := buildPostgresDSN(cfg) if err != nil { @@ -85,8 +86,9 @@ func newPostgresStorage(cfg PostgresConnectionConfig, logger *slog.Logger) (*Pos db.SetConnMaxIdleTime(cfg.ConnMaxIdleTime) storage := &PostgresStorage{ - db: db, - logger: logger, + db: db, + logger: logger, + extraSchemas: extraSchemaSQL, } pingTimeout := cfg.ConnectTimeout @@ -155,6 +157,12 @@ func (s *PostgresStorage) initSchema() (retErr error) { return fmt.Errorf("failed to execute postgres schema: %w", err) } + for _, extra := range s.extraSchemas { + if err := s.execSchemaStatements(ctx, conn, extra); err != nil { + return fmt.Errorf("failed to apply extension schema: %w", err) + } + } + s.logger.Info("PostgreSQL schema initialized") return nil } diff --git a/gateway/gateway-controller/pkg/storage/sql_store.go b/gateway/gateway-controller/pkg/storage/sql_store.go index 00b3e947d5..e5343da040 100644 --- a/gateway/gateway-controller/pkg/storage/sql_store.go +++ b/gateway/gateway-controller/pkg/storage/sql_store.go @@ -589,12 +589,6 @@ func (s *sqlStore) DeleteConfig(id string) error { } }() - if _, err := tx.ExecQ(`DELETE FROM subscriptions WHERE gateway_id = ? AND api_id = ?`, s.gatewayId, id); err != nil { - metrics.DatabaseOperationsTotal.WithLabelValues("delete", table, "error").Inc() - metrics.StorageErrorsTotal.WithLabelValues("delete", "cleanup_subscriptions_error").Inc() - return fmt.Errorf("failed to delete subscriptions for configuration: %w", err) - } - if _, err := tx.ExecQ(`DELETE FROM api_keys WHERE gateway_id = ? AND artifact_uuid = ?`, s.gatewayId, id); err != nil { metrics.DatabaseOperationsTotal.WithLabelValues("delete", table, "error").Inc() metrics.StorageErrorsTotal.WithLabelValues("delete", "cleanup_api_keys_error").Inc() diff --git a/gateway/gateway-controller/pkg/storage/sqlite.go b/gateway/gateway-controller/pkg/storage/sqlite.go index 96f8dc2e39..2fe197b482 100644 --- a/gateway/gateway-controller/pkg/storage/sqlite.go +++ b/gateway/gateway-controller/pkg/storage/sqlite.go @@ -34,12 +34,13 @@ var schemaSQL string // SQLiteStorage implements the Storage interface using SQLite type SQLiteStorage struct { - db *sql.DB - logger *slog.Logger + db *sql.DB + logger *slog.Logger + extraSchemas []string } // newSQLiteStorage creates a new SQLite storage instance. -func newSQLiteStorage(dbPath string, logger *slog.Logger) (*SQLiteStorage, error) { +func newSQLiteStorage(dbPath string, logger *slog.Logger, extraSchemaSQL ...string) (*SQLiteStorage, error) { // Build connection string with SQLite pragmas for optimal performance dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_busy_timeout=5000&_synchronous=NORMAL&_cache_size=2000&_foreign_keys=ON", dbPath) @@ -54,8 +55,9 @@ func newSQLiteStorage(dbPath string, logger *slog.Logger) (*SQLiteStorage, error db.SetConnMaxLifetime(0) storage := &SQLiteStorage{ - db: db, - logger: logger, + db: db, + logger: logger, + extraSchemas: extraSchemaSQL, } // Initialize schema if needed @@ -93,6 +95,12 @@ func (s *SQLiteStorage) initSchema() error { return fmt.Errorf("unsupported schema version %d, expected %d; delete the database to recreate", version, currentSchemaVersion) } + for _, extra := range s.extraSchemas { + if _, err := s.db.Exec(extra); err != nil { + return fmt.Errorf("failed to apply extension schema: %w", err) + } + } + s.logger.Info("Database schema up to date", slog.Int("version", currentSchemaVersion)) return nil } diff --git a/gateway/gateway-controller/pkg/storage/sqlite_test.go b/gateway/gateway-controller/pkg/storage/sqlite_test.go index 7f52cb3197..ae1b30d5ee 100644 --- a/gateway/gateway-controller/pkg/storage/sqlite_test.go +++ b/gateway/gateway-controller/pkg/storage/sqlite_test.go @@ -35,10 +35,9 @@ import ( ) var ( - configCounter int - llmTemplateCounter int - apiKeyCounter int - subscriptionCounter int + configCounter int + llmTemplateCounter int + apiKeyCounter int ) func TestNewSQLiteStorage_Success(t *testing.T) { @@ -80,19 +79,19 @@ func TestSQLiteStorage_SchemaInitialization(t *testing.T) { assert.NilError(t, err) assert.Equal(t, version, 3) // Current schema version - // Verify tables exist + // Verify tables exist. Event-gateway business tables (subscriptions, subscription_plans, + // webhook_secrets) are NOT in the base schema — they are created by the event-gateway extension. tables := []string{ "artifacts", "rest_apis", "websub_apis", + "webbroker_apis", "llm_providers", "llm_proxies", "mcp_proxies", "certificates", "llm_provider_templates", "api_keys", - "subscriptions", - "subscription_plans", "events", "gateway_states", "applications", @@ -170,12 +169,6 @@ func TestSQLiteStorage_DeleteConfig_RemovesRelaxedChildren(t *testing.T) { err := storage.SaveAPIKey(apiKey) assert.NilError(t, err) - subscription := createTestSubscription() - subscription.ID = "delete-subscription" - subscription.APIID = apiID - err = storage.SaveSubscription(subscription) - assert.NilError(t, err) - config := createTestStoredConfig() config.UUID = apiID config.Handle = "delete-api-handle" @@ -191,15 +184,6 @@ func TestSQLiteStorage_DeleteConfig_RemovesRelaxedChildren(t *testing.T) { assert.NilError(t, err) assert.Equal(t, apiKeyCount, 1) - var subscriptionCount int - err = storage.db.QueryRow(` - SELECT COUNT(*) - FROM subscriptions - WHERE gateway_id = ? AND api_id = ? - `, storage.gatewayId, apiID).Scan(&subscriptionCount) - assert.NilError(t, err) - assert.Equal(t, subscriptionCount, 1) - err = storage.DeleteConfig(apiID) assert.NilError(t, err) @@ -211,14 +195,6 @@ func TestSQLiteStorage_DeleteConfig_RemovesRelaxedChildren(t *testing.T) { assert.NilError(t, err) assert.Equal(t, apiKeyCount, 0) - err = storage.db.QueryRow(` - SELECT COUNT(*) - FROM subscriptions - WHERE gateway_id = ? AND api_id = ? - `, storage.gatewayId, apiID).Scan(&subscriptionCount) - assert.NilError(t, err) - assert.Equal(t, subscriptionCount, 0) - _, err = storage.GetConfig(apiID) assert.Assert(t, errors.Is(err, ErrNotFound)) } @@ -920,24 +896,6 @@ func TestSQLiteStorage_CountActiveAPIKeysByUserAndAPI_Success(t *testing.T) { assert.Equal(t, count, 1) } -func TestSQLiteStorage_SaveSubscription_AllowsUndeployedAPI(t *testing.T) { - storage := setupTestStorage(t) - defer storage.db.Close() - - subscription := createTestSubscription() - subscription.ID = "undeployed-subscription" - subscription.APIID = "undeployed-api" - - err := storage.SaveSubscription(subscription) - assert.NilError(t, err) - - retrieved, err := storage.GetSubscriptionByID(subscription.ID, storage.gatewayId) - assert.NilError(t, err) - assert.Equal(t, retrieved.ID, subscription.ID) - assert.Equal(t, retrieved.APIID, subscription.APIID) - assert.Assert(t, retrieved.SubscriptionTokenHash != "") -} - func TestSQLiteStorage_ReplaceApplicationAPIKeyMappings_Success(t *testing.T) { storage := setupTestStorage(t) defer storage.db.Close() @@ -1216,19 +1174,6 @@ func createTestAPIKey() *models.APIKey { } } -func createTestSubscription() *models.Subscription { - subscriptionCounter++ - applicationID := fmt.Sprintf("test-application-%d", subscriptionCounter) - return &models.Subscription{ - ID: fmt.Sprintf("test-subscription-%d", subscriptionCounter), - APIID: fmt.Sprintf("test-api-%d", subscriptionCounter), - ApplicationID: &applicationID, - SubscriptionToken: fmt.Sprintf("subscription-token-%d-%d", subscriptionCounter, time.Now().UnixNano()), - Status: models.SubscriptionStatusActive, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } -} func TestSQLiteStorage_UpsertConfig(t *testing.T) { storage := setupTestStorage(t) diff --git a/gateway/gateway-controller/tests/integration/schema_test.go b/gateway/gateway-controller/tests/integration/schema_test.go index 068744aa89..7d5aa5707d 100644 --- a/gateway/gateway-controller/tests/integration/schema_test.go +++ b/gateway/gateway-controller/tests/integration/schema_test.go @@ -219,38 +219,6 @@ func TestSchemaInitialization(t *testing.T) { assert.NoError(t, rows.Err()) }) - t.Run("SubscriptionsForeignKeys", func(t *testing.T) { - rows, err := rawDB.Query("PRAGMA foreign_key_list(subscriptions)") - require.NoError(t, err) - defer rows.Close() - - planFKCols := map[int]map[string]string{} - for rows.Next() { - var id, seq int - var tableName, fromColumn, toColumn, onUpdate, onDelete, match string - err := rows.Scan(&id, &seq, &tableName, &fromColumn, &toColumn, &onUpdate, &onDelete, &match) - require.NoError(t, err) - assert.NotEqual(t, "rest_apis", tableName, "subscriptions should not retain a REST API foreign key") - assert.NotEqual(t, "api_id", fromColumn, "subscriptions.api_id should not participate in a foreign key") - if tableName == "subscription_plans" { - if planFKCols[id] == nil { - planFKCols[id] = map[string]string{} - } - planFKCols[id][fromColumn] = toColumn - } - } - - assert.NoError(t, rows.Err()) - hasScopedPlanFK := false - for _, cols := range planFKCols { - if cols["gateway_id"] == "gateway_id" && cols["subscription_plan_id"] == "uuid" { - hasScopedPlanFK = true - break - } - } - assert.True(t, hasScopedPlanFK, "subscriptions should retain the gateway-scoped subscription plan foreign key") - }) - // Verify UNIQUE constraint on artifacts t.Run("UniqueConstraint", func(t *testing.T) { var sqlStr string diff --git a/go.work b/go.work index 1958e111a1..dabe4e96b2 100644 --- a/go.work +++ b/go.work @@ -5,6 +5,7 @@ use ( ./cli/src ./common ./event-gateway/gateway-builder + ./event-gateway/gateway-controller ./event-gateway/webhook-listener ./event-gateway/gateway-runtime ./event-gateway/it