diff --git a/gateway/gateway-controller/pkg/api/handlers/handlers_test.go b/gateway/gateway-controller/pkg/api/handlers/handlers_test.go index 7e24480bb..6cacbf025 100644 --- a/gateway/gateway-controller/pkg/api/handlers/handlers_test.go +++ b/gateway/gateway-controller/pkg/api/handlers/handlers_test.go @@ -401,6 +401,19 @@ func (m *MockStorage) GetAllAPIKeys() ([]*models.APIKey, error) { return result, nil } +func (m *MockStorage) GetAPIKeysByApplicationUUID(applicationUUID string) ([]*models.APIKey, error) { + if m.getErr != nil { + return nil, m.getErr + } + result := make([]*models.APIKey, 0) + for _, key := range m.apiKeys { + if key.ApplicationID == applicationUUID && key.Status == models.APIKeyStatusActive { + result = append(result, cloneAPIKey(key)) + } + } + return result, nil +} + func (m *MockStorage) GetAPIKeysByAPIAndName(apiId, name string) (*models.APIKey, error) { if m.getErr != nil { return nil, m.getErr @@ -708,11 +721,11 @@ func (m *MockStorage) DeleteSubscriptionsForAPINotIn(apiID string, ids []string) return nil } -func (m *MockStorage) ReplaceApplicationAPIKeyMappings(application *models.StoredApplication, mappings []*models.ApplicationAPIKeyMapping) error { +func (m *MockStorage) ReplaceApplicationAPIKeyMappings(application *models.StoredApplication, mappings []*models.ApplicationAPIKeyMapping) ([]string, error) { if m.updateErr != nil { - return m.updateErr + return nil, m.updateErr } - return nil + return nil, nil } func (m *MockStorage) SaveCertificate(cert *models.StoredCertificate) error { diff --git a/gateway/gateway-controller/pkg/controlplane/api_deleted_test.go b/gateway/gateway-controller/pkg/controlplane/api_deleted_test.go index 165623e57..8a67d5833 100644 --- a/gateway/gateway-controller/pkg/controlplane/api_deleted_test.go +++ b/gateway/gateway-controller/pkg/controlplane/api_deleted_test.go @@ -58,6 +58,31 @@ type mockStorageForDeletion struct { upsertCallCount int } +type recordingControlPlaneXDSManager struct { + storeCallCount int + revokeCallCount int + removeCallCount int +} + +func (m *recordingControlPlaneXDSManager) StoreAPIKey(string, string, string, *models.APIKey, string) error { + m.storeCallCount++ + return nil +} + +func (m *recordingControlPlaneXDSManager) RevokeAPIKey(string, string, string, string, string) error { + m.revokeCallCount++ + return nil +} + +func (m *recordingControlPlaneXDSManager) RemoveAPIKeysByAPI(string, string, string, string) error { + m.removeCallCount++ + return nil +} + +func (m *recordingControlPlaneXDSManager) RefreshSnapshot() error { + return nil +} + func newMockStorageForDeletion() *mockStorageForDeletion { return &mockStorageForDeletion{ configs: make(map[string]*models.StoredConfig), @@ -351,6 +376,10 @@ func (m *mockStorageForDeletion) GetAllAPIKeys() ([]*models.APIKey, error) { return nil, nil } +func (m *mockStorageForDeletion) GetAPIKeysByApplicationUUID(applicationUUID string) ([]*models.APIKey, error) { + return nil, nil +} + func (m *mockStorageForDeletion) GetAPIKeysByAPIAndName(apiID, name string) (*models.APIKey, error) { return nil, storage.ErrNotFound } @@ -371,9 +400,9 @@ func (m *mockStorageForDeletion) CountActiveAPIKeysByUserAndAPI(userID, apiID st return 0, nil } -func (m *mockStorageForDeletion) ReplaceApplicationAPIKeyMappings(application *models.StoredApplication, mappings []*models.ApplicationAPIKeyMapping) error { +func (m *mockStorageForDeletion) ReplaceApplicationAPIKeyMappings(application *models.StoredApplication, mappings []*models.ApplicationAPIKeyMapping) ([]string, error) { if m.replaceErr != nil { - return m.replaceErr + return nil, m.replaceErr } if application != nil { m.replacedAppID = application.ApplicationID @@ -382,7 +411,7 @@ func (m *mockStorageForDeletion) ReplaceApplicationAPIKeyMappings(application *m m.replacedAppType = application.ApplicationType } m.replacedMappings = append([]*models.ApplicationAPIKeyMapping(nil), mappings...) - return nil + return nil, nil } // Certificate methods (not used in deletion tests but required by interface) @@ -1073,6 +1102,59 @@ func TestClient_handleApplicationUpdatedEvent_ContinuesOnInvalidMappingEntries(t } } +func TestClient_handleApplicationUpdatedEvent_DoesNotRefreshXDSInline(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError})) + db := newMockStorageForDeletion() + hub := &mockControlPlaneEventHub{} + xdsManager := &recordingControlPlaneXDSManager{} + + db.apiKeysByUUID["key-uuid-found"] = &models.APIKey{ + UUID: "key-uuid-found", + ArtifactUUID: "api-uuid-1", + } + + client := &Client{ + logger: logger, + db: db, + eventHub: hub, + apiKeyXDSManager: xdsManager, + gatewayID: "test-gateway", + } + + event := map[string]interface{}{ + "type": "application.updated", + "payload": map[string]interface{}{ + "applicationId": "app-789", + "applicationUuid": "app-uuid-789", + "applicationName": "Inventory App", + "applicationType": "genai", + "mappings": []map[string]interface{}{ + {"apiKeyUuid": "key-uuid-found"}, + }, + }, + "timestamp": time.Now().Format(time.RFC3339), + "correlationId": "corr-app-update-no-inline-xds", + } + + client.handleApplicationUpdatedEvent(event) + + if db.replacedAppUUID != "app-uuid-789" { + t.Fatalf("expected mappings to be replaced for app-uuid-789, got %q", db.replacedAppUUID) + } + if len(hub.publishedEvents) != 1 { + t.Fatalf("expected one application event, got %d", len(hub.publishedEvents)) + } + if xdsManager.storeCallCount != 0 { + t.Fatalf("expected no inline xDS store calls, got %d", xdsManager.storeCallCount) + } + if xdsManager.revokeCallCount != 0 { + t.Fatalf("expected no inline xDS revoke calls, got %d", xdsManager.revokeCallCount) + } + if xdsManager.removeCallCount != 0 { + t.Fatalf("expected no inline xDS remove calls, got %d", xdsManager.removeCallCount) + } +} + func TestClient_handleSubscriptionCreatedEvent_PublishesReplicaSyncEvent(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError})) db := newMockStorageForDeletion() diff --git a/gateway/gateway-controller/pkg/controlplane/client.go b/gateway/gateway-controller/pkg/controlplane/client.go index cae7b1712..262aebb13 100644 --- a/gateway/gateway-controller/pkg/controlplane/client.go +++ b/gateway/gateway-controller/pkg/controlplane/client.go @@ -3406,26 +3406,6 @@ func (c *Client) handleApplicationUpdatedEvent(event map[string]interface{}) { slog.String("application_type", evt.Payload.ApplicationType), ) - affectedAPIKeyUUIDs := make(map[string]struct{}) - apiKeysByUUID := make(map[string]*models.APIKey) - if c.apiKeyXDSManager != nil { - apiKeys, err := c.db.GetAllAPIKeys() - if err != nil { - logger.Error("Failed to load API keys for xDS refresh after application mapping update", slog.Any("error", err)) - } else { - for _, apiKey := range apiKeys { - if apiKey == nil || apiKey.UUID == "" { - continue - } - - apiKeysByUUID[apiKey.UUID] = apiKey - if apiKey.ApplicationID == evt.Payload.ApplicationUuid { - affectedAPIKeyUUIDs[apiKey.UUID] = struct{}{} - } - } - } - } - resolvedMappings := make([]*models.ApplicationAPIKeyMapping, 0, len(evt.Payload.Mappings)) for _, mapping := range evt.Payload.Mappings { @@ -3456,7 +3436,6 @@ func (c *Client) handleApplicationUpdatedEvent(event map[string]interface{}) { ApplicationUUID: evt.Payload.ApplicationUuid, APIKeyID: apiKey.UUID, }) - affectedAPIKeyUUIDs[apiKey.UUID] = struct{}{} } application := &models.StoredApplication{ @@ -3471,52 +3450,6 @@ func (c *Client) handleApplicationUpdatedEvent(event map[string]interface{}) { return } - if c.apiKeyXDSManager != nil { - cfgByArtifactUUID := make(map[string]*models.StoredConfig) - missingCfgArtifactUUIDs := make(map[string]error) - - for apiKeyUUID := range affectedAPIKeyUUIDs { - apiKey := apiKeysByUUID[apiKeyUUID] - if apiKey == nil { - continue - } - - cfg := cfgByArtifactUUID[apiKey.ArtifactUUID] - if cfg == nil { - if cfgErr, missing := missingCfgArtifactUUIDs[apiKey.ArtifactUUID]; missing { - logger.Debug("Skipping API key xDS refresh due to missing API config", - slog.String("api_key_uuid", apiKey.UUID), - slog.String("artifact_uuid", apiKey.ArtifactUUID), - slog.Any("error", cfgErr), - ) - continue - } - - cfgLoaded, cfgErr := c.db.GetConfig(apiKey.ArtifactUUID) - if cfgErr != nil { - missingCfgArtifactUUIDs[apiKey.ArtifactUUID] = cfgErr - logger.Debug("Skipping API key xDS refresh due to missing API config", - slog.String("api_key_uuid", apiKey.UUID), - slog.String("artifact_uuid", apiKey.ArtifactUUID), - slog.Any("error", cfgErr), - ) - continue - } - - cfg = cfgLoaded - cfgByArtifactUUID[apiKey.ArtifactUUID] = cfgLoaded - } - - if err := c.apiKeyXDSManager.StoreAPIKey(apiKey.ArtifactUUID, cfg.DisplayName, cfg.Version, apiKey, evt.CorrelationID); err != nil { - logger.Error("Failed to refresh API key xDS state after application mapping update", - slog.String("api_key_uuid", apiKey.UUID), - slog.String("artifact_uuid", apiKey.ArtifactUUID), - slog.Any("error", err), - ) - } - } - } - logger.Info("Successfully processed application updated event", slog.Int("mapping_count", len(resolvedMappings))) } diff --git a/gateway/gateway-controller/pkg/eventlistener/listener_test.go b/gateway/gateway-controller/pkg/eventlistener/listener_test.go index a5e70d6dc..bbbe52f97 100644 --- a/gateway/gateway-controller/pkg/eventlistener/listener_test.go +++ b/gateway/gateway-controller/pkg/eventlistener/listener_test.go @@ -314,6 +314,8 @@ func TestHandleEvent_AcceptsKnownTypesAndUnknown(t *testing.T) { var logBuf bytes.Buffer listener := &EventListener{ logger: slog.New(slog.NewTextHandler(&logBuf, nil)), + store: storage.NewConfigStore(), + db: setupSQLiteDBForEventListenerTests(t), } listener.handleEvent(eventhub.Event{ @@ -328,6 +330,7 @@ func TestHandleEvent_AcceptsKnownTypesAndUnknown(t *testing.T) { EventType: eventhub.EventTypeApplication, Action: "UPDATE", EntityID: "app-1", + EventID: "corr-app-1", }) listener.handleEvent(eventhub.Event{ EventType: eventhub.EventType("UNKNOWN"), @@ -336,7 +339,7 @@ func TestHandleEvent_AcceptsKnownTypesAndUnknown(t *testing.T) { logs := logBuf.String() assert.Contains(t, logs, "Certificate event received") - assert.Contains(t, logs, "Processed application replica sync event") + 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") } diff --git a/gateway/gateway-controller/pkg/eventlistener/subscription_processor.go b/gateway/gateway-controller/pkg/eventlistener/subscription_processor.go index 0ba7be173..5e553bfdf 100644 --- a/gateway/gateway-controller/pkg/eventlistener/subscription_processor.go +++ b/gateway/gateway-controller/pkg/eventlistener/subscription_processor.go @@ -20,10 +20,14 @@ 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. @@ -50,14 +54,84 @@ func (l *EventListener) processSubscriptionPlanEvent(event eventhub.Event) { } } -// processApplicationEvent acknowledges application metadata changes. The canonical state is DB-backed only today. +// processApplicationEvent synchronizes replica-local API key/application state from canonical DB state. func (l *EventListener) processApplicationEvent(event eventhub.Event) { switch event.Action { case "CREATE", "UPDATE", "DELETE": - l.logger.Info("Processed application replica sync event", + currentMappedKeys, err := l.loadApplicationAPIKeysFromDB(event.EntityID) + if err != nil { + l.logger.Error("Failed to load application API keys from database for replica sync", + slog.String("action", event.Action), + slog.String("application_uuid", event.EntityID), + slog.String("event_id", event.EventID), + slog.Any("error", err)) + return + } + + removedAPIKeyIDs, err := parseRemovedApplicationAPIKeyIDs(event.EventData) + if err != nil { + l.logger.Error("Failed to parse application event data for replica sync", + slog.String("action", event.Action), + slog.String("application_uuid", event.EntityID), + slog.String("event_id", event.EventID), + slog.Any("error", err)) + } + + affectedKeys, err := l.resolveAffectedApplicationAPIKeys(event.EntityID, currentMappedKeys, removedAPIKeyIDs) + if err != nil { + l.logger.Error("Failed to resolve affected API keys for application replica sync", + slog.String("action", event.Action), + slog.String("application_uuid", event.EntityID), + slog.String("event_id", event.EventID), + slog.Any("error", err)) + return + } + + for _, apiKey := range affectedKeys { + if err := l.store.StoreAPIKey(apiKey); err != nil { + l.logger.Error("Failed to store API key in memory during application replica sync", + slog.String("action", event.Action), + slog.String("application_uuid", event.EntityID), + slog.String("api_key_id", apiKey.UUID), + slog.String("api_id", apiKey.ArtifactUUID), + slog.String("event_id", event.EventID), + slog.Any("error", err)) + continue + } + + if l.apiKeyXDSManager == nil { + continue + } + + cfg, err := l.syncAPIConfigForAPIKeyEvent(apiKey.ArtifactUUID) + if err != nil { + l.logger.Warn("Skipping API key xDS refresh during application replica sync due to missing API config", + slog.String("action", event.Action), + slog.String("application_uuid", event.EntityID), + slog.String("api_key_id", apiKey.UUID), + slog.String("artifact_uuid", apiKey.ArtifactUUID), + slog.String("event_id", event.EventID), + slog.Any("error", err)) + continue + } + + apiName, apiVersion := extractAPINameVersion(cfg) + if err := l.apiKeyXDSManager.StoreAPIKey(cfg.UUID, apiName, apiVersion, apiKey, event.EventID); err != nil { + l.logger.Error("Failed to refresh API key xDS state during application replica sync", + slog.String("action", event.Action), + slog.String("application_uuid", event.EntityID), + slog.String("api_key_id", apiKey.UUID), + slog.String("artifact_uuid", apiKey.ArtifactUUID), + slog.String("event_id", event.EventID), + slog.Any("error", err)) + } + } + + l.logger.Info("Successfully processed application replica sync event", slog.String("action", event.Action), - slog.String("application_id", event.EntityID), - slog.String("event_id", event.EventID)) + slog.String("application_uuid", event.EntityID), + slog.String("event_id", event.EventID), + slog.Int("affected_api_key_count", len(affectedKeys))) default: l.logger.Warn("Unknown application event action", slog.String("action", event.Action), @@ -65,6 +139,108 @@ func (l *EventListener) processApplicationEvent(event eventhub.Event) { } } +func (l *EventListener) loadApplicationAPIKeysFromDB(applicationUUID string) (map[string]*models.APIKey, error) { + apiKeys, err := l.db.GetAPIKeysByApplicationUUID(applicationUUID) + if err != nil { + return nil, err + } + + currentMappedKeys := make(map[string]*models.APIKey, len(apiKeys)) + for _, apiKey := range apiKeys { + if apiKey == nil || apiKey.UUID == "" { + continue + } + currentMappedKeys[apiKey.UUID] = apiKey + } + + return currentMappedKeys, nil +} + +func parseRemovedApplicationAPIKeyIDs(eventData string) ([]string, error) { + trimmedEventData := strings.TrimSpace(eventData) + if trimmedEventData == "" || trimmedEventData == eventhub.EmptyEventData { + return nil, nil + } + + var payload models.ApplicationEventData + if err := json.Unmarshal([]byte(trimmedEventData), &payload); err != nil { + return nil, err + } + + removedKeyIDs := make([]string, 0, len(payload.RemovedAPIKeyIDs)) + seen := make(map[string]struct{}, len(payload.RemovedAPIKeyIDs)) + for _, apiKeyID := range payload.RemovedAPIKeyIDs { + apiKeyID = strings.TrimSpace(apiKeyID) + if apiKeyID == "" { + continue + } + if _, exists := seen[apiKeyID]; exists { + continue + } + seen[apiKeyID] = struct{}{} + removedKeyIDs = append(removedKeyIDs, apiKeyID) + } + + return removedKeyIDs, nil +} + +func (l *EventListener) resolveAffectedApplicationAPIKeys(applicationUUID string, currentMappedKeys map[string]*models.APIKey, removedAPIKeyIDs []string) ([]*models.APIKey, error) { + affectedKeyIDs := make(map[string]struct{}, len(currentMappedKeys)) + for apiKeyID := range currentMappedKeys { + affectedKeyIDs[apiKeyID] = struct{}{} + } + + // Removed keys disappear from the DB-side application lookup after the writer + // replaces mappings, so replay merges the IDs carried in EventData back in. + for _, apiKeyID := range removedAPIKeyIDs { + if apiKeyID == "" { + continue + } + affectedKeyIDs[apiKeyID] = struct{}{} + } + + for _, cfg := range l.store.GetAll() { + apiKeys, err := l.store.GetAPIKeysByAPI(cfg.UUID) + if err != nil { + return nil, err + } + for _, apiKey := range apiKeys { + if apiKey == nil || apiKey.UUID == "" { + continue + } + if apiKey.ApplicationID == applicationUUID { + affectedKeyIDs[apiKey.UUID] = struct{}{} + } + } + } + + sortedKeyIDs := make([]string, 0, len(affectedKeyIDs)) + for apiKeyID := range affectedKeyIDs { + sortedKeyIDs = append(sortedKeyIDs, apiKeyID) + } + sort.Strings(sortedKeyIDs) + + affectedKeys := make([]*models.APIKey, 0, len(sortedKeyIDs)) + for _, apiKeyID := range sortedKeyIDs { + if apiKey, ok := currentMappedKeys[apiKeyID]; ok { + affectedKeys = append(affectedKeys, apiKey) + continue + } + + apiKey, err := l.db.GetAPIKeyByID(apiKeyID) + if err != nil { + l.logger.Error("Failed to reload API key from database during application replica sync", + slog.String("application_uuid", applicationUUID), + slog.String("api_key_id", apiKeyID), + slog.Any("error", err)) + continue + } + affectedKeys = append(affectedKeys, apiKey) + } + + 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", diff --git a/gateway/gateway-controller/pkg/eventlistener/subscription_processor_test.go b/gateway/gateway-controller/pkg/eventlistener/subscription_processor_test.go new file mode 100644 index 000000000..f483e7055 --- /dev/null +++ b/gateway/gateway-controller/pkg/eventlistener/subscription_processor_test.go @@ -0,0 +1,202 @@ +/* + * 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/models/application_event_data.go b/gateway/gateway-controller/pkg/models/application_event_data.go new file mode 100644 index 000000000..d090d3d7d --- /dev/null +++ b/gateway/gateway-controller/pkg/models/application_event_data.go @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package models + +// ApplicationEventData carries optional replica-sync details for application events. +type ApplicationEventData struct { + // RemovedAPIKeyIDs tracks keys that were mapped before the DB replace but are + // no longer returned by the post-update application lookup. + RemovedAPIKeyIDs []string `json:"removedApiKeyIds,omitempty"` +} diff --git a/gateway/gateway-controller/pkg/secrets/service_test.go b/gateway/gateway-controller/pkg/secrets/service_test.go index cf2583b39..0042533d7 100644 --- a/gateway/gateway-controller/pkg/secrets/service_test.go +++ b/gateway/gateway-controller/pkg/secrets/service_test.go @@ -155,18 +155,18 @@ type minimalStorage struct { } // Implement remaining required interface methods as no-ops -func (m *minimalStorage) SaveConfig(cfg *models.StoredConfig) error { return nil } -func (m *minimalStorage) UpdateConfig(cfg *models.StoredConfig) error { return nil } -func (m *minimalStorage) UpsertConfig(cfg *models.StoredConfig) (bool, error) { return false, nil } -func (m *minimalStorage) DeleteConfig(id string) error { return nil } -func (m *minimalStorage) GetConfig(id string) (*models.StoredConfig, error) { return nil, nil } +func (m *minimalStorage) SaveConfig(cfg *models.StoredConfig) error { return nil } +func (m *minimalStorage) UpdateConfig(cfg *models.StoredConfig) error { return nil } +func (m *minimalStorage) UpsertConfig(cfg *models.StoredConfig) (bool, error) { return false, nil } +func (m *minimalStorage) DeleteConfig(id string) error { return nil } +func (m *minimalStorage) GetConfig(id string) (*models.StoredConfig, error) { return nil, nil } func (m *minimalStorage) GetConfigByKindAndHandle(kind, handle string) (*models.StoredConfig, error) { return nil, nil } func (m *minimalStorage) GetConfigByKindNameAndVersion(kind, displayName, version string) (*models.StoredConfig, error) { return nil, nil } -func (m *minimalStorage) GetAllConfigs() ([]*models.StoredConfig, error) { return nil, nil } +func (m *minimalStorage) GetAllConfigs() ([]*models.StoredConfig, error) { return nil, nil } func (m *minimalStorage) GetAllConfigsByKind(kind string) ([]*models.StoredConfig, error) { return nil, nil } @@ -189,15 +189,18 @@ func (m *minimalStorage) GetAllLLMProviderTemplates() ([]*models.StoredLLMProvid func (m *minimalStorage) GetLLMProviderTemplateByHandle(handle string) (*models.StoredLLMProviderTemplate, error) { return nil, nil } -func (m *minimalStorage) SaveAPIKey(apiKey *models.APIKey) error { return nil } -func (m *minimalStorage) UpsertAPIKey(apiKey *models.APIKey) error { return nil } -func (m *minimalStorage) GetAPIKeyByID(id string) (*models.APIKey, error) { return nil, nil } +func (m *minimalStorage) SaveAPIKey(apiKey *models.APIKey) error { return nil } +func (m *minimalStorage) UpsertAPIKey(apiKey *models.APIKey) error { return nil } +func (m *minimalStorage) GetAPIKeyByID(id string) (*models.APIKey, error) { return nil, nil } func (m *minimalStorage) GetAPIKeyByUUID(uuid string) (*models.APIKey, error) { return nil, nil } -func (m *minimalStorage) GetAPIKeyByKey(key string) (*models.APIKey, error) { return nil, nil } +func (m *minimalStorage) GetAPIKeyByKey(key string) (*models.APIKey, error) { return nil, nil } func (m *minimalStorage) GetAPIKeysByAPI(apiId string) ([]*models.APIKey, error) { return nil, nil } func (m *minimalStorage) GetAllAPIKeys() ([]*models.APIKey, error) { return nil, nil } +func (m *minimalStorage) GetAPIKeysByApplicationUUID(applicationUUID string) ([]*models.APIKey, error) { + return nil, nil +} func (m *minimalStorage) GetAPIKeysByAPIAndName(apiId, name string) (*models.APIKey, error) { return nil, nil } @@ -241,8 +244,8 @@ func (m *minimalStorage) DeleteSubscription(id, gatewayID string) error { re func (m *minimalStorage) DeleteSubscriptionsForAPINotIn(apiID string, ids []string) error { return nil } -func (m *minimalStorage) ReplaceApplicationAPIKeyMappings(application *models.StoredApplication, mappings []*models.ApplicationAPIKeyMapping) error { - return nil +func (m *minimalStorage) ReplaceApplicationAPIKeyMappings(application *models.StoredApplication, mappings []*models.ApplicationAPIKeyMapping) ([]string, error) { + return nil, nil } func (m *minimalStorage) SaveCertificate(cert *models.StoredCertificate) error { return nil } func (m *minimalStorage) GetCertificate(id string) (*models.StoredCertificate, error) { diff --git a/gateway/gateway-controller/pkg/storage/interface.go b/gateway/gateway-controller/pkg/storage/interface.go index a35d15d74..53052347e 100644 --- a/gateway/gateway-controller/pkg/storage/interface.go +++ b/gateway/gateway-controller/pkg/storage/interface.go @@ -232,6 +232,11 @@ type Storage interface { // Used for loading active API keys into memory on startup. GetAllAPIKeys() ([]*models.APIKey, error) + // GetAPIKeysByApplicationUUID retrieves all active API keys mapped to an application UUID. + // + // Returns an empty slice if no active API keys exist for the application. + GetAPIKeysByApplicationUUID(applicationUUID string) ([]*models.APIKey, error) + // GetAPIKeysByAPIAndName retrieves an API key by its name within a specific API. // // Returns an error if the API key is not found. @@ -316,7 +321,8 @@ type Storage interface { // ReplaceApplicationAPIKeyMappings atomically replaces all API key mappings for an application. // // Existing mappings are removed and the supplied mapping set is inserted. - ReplaceApplicationAPIKeyMappings(application *models.StoredApplication, mappings []*models.ApplicationAPIKeyMapping) error + // Returns the API key IDs removed by the replacement, computed inside the transaction. + ReplaceApplicationAPIKeyMappings(application *models.StoredApplication, mappings []*models.ApplicationAPIKeyMapping) ([]string, error) // SaveCertificate persists a new certificate. // diff --git a/gateway/gateway-controller/pkg/storage/sql_store.go b/gateway/gateway-controller/pkg/storage/sql_store.go index 7d155f79d..fbc0649cb 100644 --- a/gateway/gateway-controller/pkg/storage/sql_store.go +++ b/gateway/gateway-controller/pkg/storage/sql_store.go @@ -26,6 +26,7 @@ import ( "errors" "fmt" "log/slog" + "sort" "strings" "time" @@ -1923,17 +1924,24 @@ func (s *sqlStore) UpsertAPIKey(apiKey *models.APIKey) error { // GetAPIKeyByID retrieves an API key by its UUID func (s *sqlStore) GetAPIKeyByID(id string) (*models.APIKey, error) { query := ` - SELECT uuid, name, api_key, masked_api_key, artifact_uuid, status, - created_at, created_by, updated_at, expires_at, source, external_ref_id, - issuer - FROM api_keys - WHERE uuid = ? AND gateway_id = ? + SELECT ak.uuid, ak.name, ak.api_key, ak.masked_api_key, ak.artifact_uuid, ak.status, + ak.created_at, ak.created_by, ak.updated_at, ak.expires_at, ak.source, ak.external_ref_id, + ak.issuer, app.application_uuid, app.application_name + FROM api_keys ak + LEFT JOIN application_api_keys aak + ON aak.api_key_id = ak.uuid AND aak.gateway_id = ak.gateway_id + LEFT JOIN applications app + ON app.application_uuid = aak.application_uuid AND app.gateway_id = aak.gateway_id + WHERE ak.uuid = ? AND ak.gateway_id = ? + LIMIT 1 ` var apiKey models.APIKey var expiresAt sql.NullTime var externalRefId sql.NullString var issuer sql.NullString + var applicationID sql.NullString + var applicationName sql.NullString err := s.queryRow(query, id, s.gatewayId).Scan( &apiKey.UUID, @@ -1949,6 +1957,8 @@ func (s *sqlStore) GetAPIKeyByID(id string) (*models.APIKey, error) { &apiKey.Source, &externalRefId, &issuer, + &applicationID, + &applicationName, ) if err != nil { @@ -1958,7 +1968,6 @@ func (s *sqlStore) GetAPIKeyByID(id string) (*models.APIKey, error) { return nil, fmt.Errorf("failed to query API key: %w", err) } - // Handle nullable fields if expiresAt.Valid { apiKey.ExpiresAt = &expiresAt.Time } @@ -1968,6 +1977,12 @@ func (s *sqlStore) GetAPIKeyByID(id string) (*models.APIKey, error) { if issuer.Valid { apiKey.Issuer = &issuer.String } + if applicationID.Valid { + apiKey.ApplicationID = applicationID.String + } + if applicationName.Valid { + apiKey.ApplicationName = applicationName.String + } return &apiKey, nil } @@ -2086,7 +2101,7 @@ func (s *sqlStore) GetAPIKeysByAPI(apiId string) ([]*models.APIKey, error) { LEFT JOIN application_api_keys aak ON aak.api_key_id = ak.uuid AND aak.gateway_id = ak.gateway_id LEFT JOIN applications app - ON app.application_uuid = aak.application_uuid + ON app.application_uuid = aak.application_uuid AND app.gateway_id = aak.gateway_id WHERE ak.artifact_uuid = ? AND ak.gateway_id = ? ORDER BY ak.created_at DESC ` @@ -2100,6 +2115,30 @@ func (s *sqlStore) GetAPIKeysByAPI(apiId string) ([]*models.APIKey, error) { return s.scanAPIKeyRows(rows) } +// GetAPIKeysByApplicationUUID retrieves all active API keys mapped to a specific application UUID. +func (s *sqlStore) GetAPIKeysByApplicationUUID(applicationUUID string) ([]*models.APIKey, error) { + query := ` + SELECT ak.uuid, ak.name, ak.api_key, ak.masked_api_key, ak.artifact_uuid, ak.status, + ak.created_at, ak.created_by, ak.updated_at, ak.expires_at, ak.source, ak.external_ref_id, + ak.issuer, app.application_uuid, app.application_name + FROM api_keys ak + INNER JOIN application_api_keys aak + ON aak.api_key_id = ak.uuid AND aak.gateway_id = ak.gateway_id + INNER JOIN applications app + ON app.application_uuid = aak.application_uuid AND app.gateway_id = aak.gateway_id + WHERE aak.application_uuid = ? AND ak.status = 'active' AND ak.gateway_id = ? + ORDER BY ak.created_at DESC + ` + + rows, err := s.query(query, applicationUUID, s.gatewayId) + if err != nil { + return nil, fmt.Errorf("failed to query API keys by application UUID: %w", err) + } + defer rows.Close() + + return s.scanAPIKeyRows(rows) +} + // GetAPIKeysByAPIAndName retrieves an API key by its artifact_uuid and name func (s *sqlStore) GetAPIKeysByAPIAndName(apiId, name string) (*models.APIKey, error) { query := ` @@ -2277,14 +2316,14 @@ func (s *sqlStore) RemoveAPIKeyAPIAndName(apiId, name string) error { } // ReplaceApplicationAPIKeyMappings atomically replaces all API key mappings for an application. -func (s *sqlStore) ReplaceApplicationAPIKeyMappings(application *models.StoredApplication, mappings []*models.ApplicationAPIKeyMapping) error { +func (s *sqlStore) ReplaceApplicationAPIKeyMappings(application *models.StoredApplication, mappings []*models.ApplicationAPIKeyMapping) ([]string, error) { if application == nil || application.ApplicationUUID == "" || application.ApplicationID == "" || application.ApplicationName == "" || application.ApplicationType == "" { - return fmt.Errorf("invalid application payload") + return nil, fmt.Errorf("invalid application payload") } tx, err := s.begin() if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) + return nil, fmt.Errorf("failed to begin transaction: %w", err) } defer func() { @@ -2295,8 +2334,40 @@ func (s *sqlStore) ReplaceApplicationAPIKeyMappings(application *models.StoredAp }() seen := make(map[string]struct{}) + incomingKeyIDs := make(map[string]struct{}, len(mappings)) + removedKeyIDs := make([]string, 0) now := time.Now() + rows, err := tx.QueryQ(` + SELECT api_key_id + FROM application_api_keys + WHERE application_uuid = ? AND gateway_id = ? + `, application.ApplicationUUID, s.gatewayId) + if err != nil { + _ = tx.Rollback() + return nil, fmt.Errorf("failed to query existing application mappings: %w", err) + } + + existingKeyIDs := make(map[string]struct{}) + for rows.Next() { + var apiKeyID string + if err = rows.Scan(&apiKeyID); err != nil { + rows.Close() + _ = tx.Rollback() + return nil, fmt.Errorf("failed to scan existing application mapping: %w", err) + } + if apiKeyID == "" { + continue + } + existingKeyIDs[apiKeyID] = struct{}{} + } + if err = rows.Err(); err != nil { + rows.Close() + _ = tx.Rollback() + return nil, fmt.Errorf("failed to iterate existing application mappings: %w", err) + } + rows.Close() + if _, err = tx.ExecQ(` INSERT INTO applications ( application_uuid, gateway_id, application_id, application_name, application_type, created_at, updated_at @@ -2309,7 +2380,7 @@ func (s *sqlStore) ReplaceApplicationAPIKeyMappings(application *models.StoredAp updated_at = excluded.updated_at `, application.ApplicationUUID, s.gatewayId, application.ApplicationID, application.ApplicationName, application.ApplicationType, now, now); err != nil { _ = tx.Rollback() - return fmt.Errorf("failed to upsert application metadata: %w", err) + return nil, fmt.Errorf("failed to upsert application metadata: %w", err) } if _, err = tx.ExecQ(` @@ -2317,7 +2388,7 @@ func (s *sqlStore) ReplaceApplicationAPIKeyMappings(application *models.StoredAp WHERE application_uuid = ? AND gateway_id = ? `, application.ApplicationUUID, s.gatewayId); err != nil { _ = tx.Rollback() - return fmt.Errorf("failed to clear application mappings: %w", err) + return nil, fmt.Errorf("failed to clear application mappings: %w", err) } for _, mapping := range mappings { @@ -2326,11 +2397,11 @@ func (s *sqlStore) ReplaceApplicationAPIKeyMappings(application *models.StoredAp } if mapping.ApplicationUUID == "" || mapping.APIKeyID == "" { _ = tx.Rollback() - return fmt.Errorf("invalid application mapping payload") + return nil, fmt.Errorf("invalid application mapping payload") } if mapping.ApplicationUUID != application.ApplicationUUID { _ = tx.Rollback() - return fmt.Errorf("application mapping UUID mismatch") + return nil, fmt.Errorf("application mapping UUID mismatch") } composite := mapping.ApplicationUUID + ":" + mapping.APIKeyID @@ -2338,6 +2409,7 @@ func (s *sqlStore) ReplaceApplicationAPIKeyMappings(application *models.StoredAp continue } seen[composite] = struct{}{} + incomingKeyIDs[mapping.APIKeyID] = struct{}{} if _, err = tx.ExecQ(` INSERT INTO application_api_keys ( @@ -2346,15 +2418,23 @@ func (s *sqlStore) ReplaceApplicationAPIKeyMappings(application *models.StoredAp VALUES (?, ?, ?, ?, ?) `, mapping.ApplicationUUID, mapping.APIKeyID, s.gatewayId, now, now); err != nil { _ = tx.Rollback() - return fmt.Errorf("failed to insert application mapping: %w", err) + return nil, fmt.Errorf("failed to insert application mapping: %w", err) + } + } + + for apiKeyID := range existingKeyIDs { + if _, exists := incomingKeyIDs[apiKeyID]; exists { + continue } + removedKeyIDs = append(removedKeyIDs, apiKeyID) } + sort.Strings(removedKeyIDs) if err := tx.Commit(); err != nil { - return fmt.Errorf("failed to commit application mapping transaction: %w", err) + return nil, fmt.Errorf("failed to commit application mapping transaction: %w", err) } - return nil + return removedKeyIDs, nil } // Close closes the database connection @@ -2384,7 +2464,7 @@ func (s *sqlStore) GetAllAPIKeys() ([]*models.APIKey, error) { LEFT JOIN application_api_keys aak ON aak.api_key_id = ak.uuid AND aak.gateway_id = ak.gateway_id LEFT JOIN applications app - ON app.application_uuid = aak.application_uuid + ON app.application_uuid = aak.application_uuid AND app.gateway_id = aak.gateway_id WHERE ak.status = 'active' AND ak.gateway_id = ? ORDER BY ak.created_at DESC ` diff --git a/gateway/gateway-controller/pkg/storage/sqlite_test.go b/gateway/gateway-controller/pkg/storage/sqlite_test.go index a0be7198c..efbb007c6 100644 --- a/gateway/gateway-controller/pkg/storage/sqlite_test.go +++ b/gateway/gateway-controller/pkg/storage/sqlite_test.go @@ -971,13 +971,14 @@ func TestSQLiteStorage_ReplaceApplicationAPIKeyMappings_Success(t *testing.T) { ApplicationType: "web", } - err = storage.ReplaceApplicationAPIKeyMappings(application, []*models.ApplicationAPIKeyMapping{ + removedKeyIDs, err := storage.ReplaceApplicationAPIKeyMappings(application, []*models.ApplicationAPIKeyMapping{ { ApplicationUUID: application.ApplicationUUID, APIKeyID: apiKey1.UUID, }, }) assert.NilError(t, err) + assert.DeepEqual(t, removedKeyIDs, []string{}) var gatewayID string var applicationName string @@ -1009,7 +1010,7 @@ func TestSQLiteStorage_ReplaceApplicationAPIKeyMappings_Success(t *testing.T) { assert.Equal(t, mappedKeyID, apiKey1.UUID) application.ApplicationName = "App One Updated" - err = storage.ReplaceApplicationAPIKeyMappings(application, []*models.ApplicationAPIKeyMapping{ + removedKeyIDs, err = storage.ReplaceApplicationAPIKeyMappings(application, []*models.ApplicationAPIKeyMapping{ { ApplicationUUID: application.ApplicationUUID, APIKeyID: apiKey2.UUID, @@ -1020,6 +1021,7 @@ func TestSQLiteStorage_ReplaceApplicationAPIKeyMappings_Success(t *testing.T) { }, }) assert.NilError(t, err) + assert.DeepEqual(t, removedKeyIDs, []string{apiKey1.UUID}) err = storage.db.QueryRow(` SELECT application_name @@ -1046,6 +1048,86 @@ func TestSQLiteStorage_ReplaceApplicationAPIKeyMappings_Success(t *testing.T) { assert.Equal(t, mappedKeyID, apiKey2.UUID) } +func TestSQLiteStorage_GetAPIKeysByApplicationUUID_ReturnsMappedActiveKeys(t *testing.T) { + storage := setupTestStorage(t) + defer storage.db.Close() + + configA := createTestStoredConfig() + configA.UUID = "app-query-api-a" + configA.Handle = "app-query-api-a" + err := storage.SaveConfig(configA) + assert.NilError(t, err) + + configB := createTestStoredConfig() + configB.UUID = "app-query-api-b" + configB.Handle = "app-query-api-b" + err = storage.SaveConfig(configB) + assert.NilError(t, err) + + activeMappedKey := createTestAPIKey() + activeMappedKey.UUID = "app-query-key-active" + activeMappedKey.Name = "app-query-key-active" + activeMappedKey.ArtifactUUID = configA.UUID + err = storage.SaveAPIKey(activeMappedKey) + assert.NilError(t, err) + + revokedMappedKey := createTestAPIKey() + revokedMappedKey.UUID = "app-query-key-revoked" + revokedMappedKey.Name = "app-query-key-revoked" + revokedMappedKey.ArtifactUUID = configA.UUID + revokedMappedKey.Status = models.APIKeyStatusRevoked + err = storage.SaveAPIKey(revokedMappedKey) + assert.NilError(t, err) + + otherApplicationKey := createTestAPIKey() + otherApplicationKey.UUID = "app-query-key-other-app" + otherApplicationKey.Name = "app-query-key-other-app" + otherApplicationKey.ArtifactUUID = configB.UUID + err = storage.SaveAPIKey(otherApplicationKey) + assert.NilError(t, err) + + applicationOne := &models.StoredApplication{ + ApplicationUUID: "app-query-uuid-1", + ApplicationID: "app-query-id-1", + ApplicationName: "Application One", + ApplicationType: "web", + } + _, err = storage.ReplaceApplicationAPIKeyMappings(applicationOne, []*models.ApplicationAPIKeyMapping{ + { + ApplicationUUID: applicationOne.ApplicationUUID, + APIKeyID: activeMappedKey.UUID, + }, + { + ApplicationUUID: applicationOne.ApplicationUUID, + APIKeyID: revokedMappedKey.UUID, + }, + }) + assert.NilError(t, err) + + applicationTwo := &models.StoredApplication{ + ApplicationUUID: "app-query-uuid-2", + ApplicationID: "app-query-id-2", + ApplicationName: "Application Two", + ApplicationType: "web", + } + _, err = storage.ReplaceApplicationAPIKeyMappings(applicationTwo, []*models.ApplicationAPIKeyMapping{ + { + ApplicationUUID: applicationTwo.ApplicationUUID, + APIKeyID: otherApplicationKey.UUID, + }, + }) + assert.NilError(t, err) + + apiKeys, err := storage.GetAPIKeysByApplicationUUID(applicationOne.ApplicationUUID) + assert.NilError(t, err) + assert.Equal(t, 1, len(apiKeys)) + if len(apiKeys) == 1 { + assert.Equal(t, activeMappedKey.UUID, apiKeys[0].UUID) + assert.Equal(t, applicationOne.ApplicationUUID, apiKeys[0].ApplicationID) + assert.Equal(t, applicationOne.ApplicationName, apiKeys[0].ApplicationName) + } +} + // Helper functions func setupTestStorage(t *testing.T) *sqlStore { diff --git a/gateway/gateway-controller/pkg/subscriptionxds/subscription_snapshot_test.go b/gateway/gateway-controller/pkg/subscriptionxds/subscription_snapshot_test.go index ad8fbddfb..6082aa48a 100644 --- a/gateway/gateway-controller/pkg/subscriptionxds/subscription_snapshot_test.go +++ b/gateway/gateway-controller/pkg/subscriptionxds/subscription_snapshot_test.go @@ -108,6 +108,9 @@ func (m *MockStorage) GetAPIKeyByUUID(uuid string) (*models.APIKey, error) { func (m *MockStorage) GetAPIKeyByKey(key string) (*models.APIKey, error) { return nil, nil } func (m *MockStorage) GetAPIKeysByAPI(apiId string) ([]*models.APIKey, error) { return nil, nil } func (m *MockStorage) GetAllAPIKeys() ([]*models.APIKey, error) { return nil, nil } +func (m *MockStorage) GetAPIKeysByApplicationUUID(applicationUUID string) ([]*models.APIKey, error) { + return nil, nil +} func (m *MockStorage) GetAPIKeysByAPIAndName(apiId, name string) (*models.APIKey, error) { return nil, nil } @@ -141,8 +144,8 @@ func (m *MockStorage) DeleteSubscription(id, gatewayID string) error { retur func (m *MockStorage) DeleteSubscriptionsForAPINotIn(apiID string, ids []string) error { return nil } -func (m *MockStorage) ReplaceApplicationAPIKeyMappings(application *models.StoredApplication, mappings []*models.ApplicationAPIKeyMapping) error { - return nil +func (m *MockStorage) ReplaceApplicationAPIKeyMappings(application *models.StoredApplication, mappings []*models.ApplicationAPIKeyMapping) ([]string, error) { + return nil, nil } func (m *MockStorage) SaveCertificate(cert *models.StoredCertificate) error { return nil } func (m *MockStorage) GetCertificate(id string) (*models.StoredCertificate, error) { return nil, nil } diff --git a/gateway/gateway-controller/pkg/utils/mock_db_test.go b/gateway/gateway-controller/pkg/utils/mock_db_test.go index 1272e3571..5f8bf9dc7 100644 --- a/gateway/gateway-controller/pkg/utils/mock_db_test.go +++ b/gateway/gateway-controller/pkg/utils/mock_db_test.go @@ -160,6 +160,9 @@ func (m *testMockDB) GetAPIKeyByKey(key string) (*models.APIKey, error) { } func (m *testMockDB) GetAPIKeysByAPI(apiId string) ([]*models.APIKey, error) { return nil, nil } func (m *testMockDB) GetAllAPIKeys() ([]*models.APIKey, error) { return nil, nil } +func (m *testMockDB) GetAPIKeysByApplicationUUID(applicationUUID string) ([]*models.APIKey, error) { + return nil, nil +} func (m *testMockDB) GetAPIKeysByAPIAndName(apiId, name string) (*models.APIKey, error) { return nil, storage.ErrNotFound } @@ -197,8 +200,8 @@ func (m *testMockDB) ListActiveSubscriptions() ([]*models.Subscription, error) func (m *testMockDB) UpdateSubscription(sub *models.Subscription) error { return nil } func (m *testMockDB) DeleteSubscription(id, gatewayID string) error { return nil } func (m *testMockDB) DeleteSubscriptionsForAPINotIn(apiID string, ids []string) error { return nil } -func (m *testMockDB) ReplaceApplicationAPIKeyMappings(application *models.StoredApplication, mappings []*models.ApplicationAPIKeyMapping) error { - return nil +func (m *testMockDB) ReplaceApplicationAPIKeyMappings(application *models.StoredApplication, mappings []*models.ApplicationAPIKeyMapping) ([]string, error) { + return nil, nil } func (m *testMockDB) SaveCertificate(cert *models.StoredCertificate) error { return nil } diff --git a/gateway/gateway-controller/pkg/utils/subscription_resource.go b/gateway/gateway-controller/pkg/utils/subscription_resource.go index 0af56fb1a..3f638b206 100644 --- a/gateway/gateway-controller/pkg/utils/subscription_resource.go +++ b/gateway/gateway-controller/pkg/utils/subscription_resource.go @@ -20,7 +20,9 @@ package utils import ( "context" + "encoding/json" "log/slog" + "sort" "time" "github.com/wso2/api-platform/common/eventhub" @@ -150,8 +152,13 @@ func (s *SubscriptionResourceService) DeleteSubscriptionPlan(id, correlationID s // ReplaceApplicationAPIKeyMappings persists canonical application metadata and publishes a replica-sync event. func (s *SubscriptionResourceService) ReplaceApplicationAPIKeyMappings(application *models.StoredApplication, mappings []*models.ApplicationAPIKeyMapping, correlationID string, logger *slog.Logger) error { - return s.persistAndSync(eventhub.EventTypeApplication, "UPDATE", application.ApplicationUUID, false, correlationID, logger, func() error { - return s.requireDB().ReplaceApplicationAPIKeyMappings(application, mappings) + var removedKeyIDs []string + return s.persistAndSyncWithEventData(eventhub.EventTypeApplication, "UPDATE", application.ApplicationUUID, func() (string, error) { + return s.buildApplicationEventData(removedKeyIDs) + }, false, correlationID, logger, func() error { + var err error + removedKeyIDs, err = s.requireDB().ReplaceApplicationAPIKeyMappings(application, mappings) + return err }) } @@ -159,6 +166,21 @@ func (s *SubscriptionResourceService) requireDB() storage.Storage { return s.db } +func (s *SubscriptionResourceService) buildApplicationEventData(removedKeyIDs []string) (string, error) { + if len(removedKeyIDs) == 0 { + return eventhub.EmptyEventData, nil + } + + eventRemovedKeyIDs := append([]string(nil), removedKeyIDs...) + sort.Strings(eventRemovedKeyIDs) + payload, err := json.Marshal(models.ApplicationEventData{RemovedAPIKeyIDs: eventRemovedKeyIDs}) + if err != nil { + return "", err + } + + return string(payload), nil +} + func (s *SubscriptionResourceService) persistAndSync( eventType eventhub.EventType, action string, @@ -167,6 +189,21 @@ func (s *SubscriptionResourceService) persistAndSync( correlationID string, logger *slog.Logger, persist func() error, +) error { + return s.persistAndSyncWithEventData(eventType, action, entityID, func() (string, error) { + return eventhub.EmptyEventData, nil + }, refreshSubscriptionSnapshot, correlationID, logger, persist) +} + +func (s *SubscriptionResourceService) persistAndSyncWithEventData( + eventType eventhub.EventType, + action string, + entityID string, + eventDataBuilder func() (string, error), + refreshSubscriptionSnapshot bool, + correlationID string, + logger *slog.Logger, + persist func() error, ) error { if logger == nil { logger = slog.Default() @@ -176,11 +213,16 @@ func (s *SubscriptionResourceService) persistAndSync( return err } - s.publishEvent(eventType, action, entityID, correlationID, logger) + eventData, err := eventDataBuilder() + if err != nil { + return err + } + + s.publishEvent(eventType, action, entityID, eventData, correlationID, logger) return nil } -func (s *SubscriptionResourceService) publishEvent(eventType eventhub.EventType, action, entityID, correlationID string, logger *slog.Logger) { +func (s *SubscriptionResourceService) publishEvent(eventType eventhub.EventType, action, entityID, eventData, correlationID string, logger *slog.Logger) { event := eventhub.Event{ GatewayID: s.gatewayID, OriginatedTimestamp: time.Now(), @@ -188,7 +230,7 @@ func (s *SubscriptionResourceService) publishEvent(eventType eventhub.EventType, Action: action, EntityID: entityID, EventID: correlationID, - EventData: eventhub.EmptyEventData, + EventData: eventData, } if err := s.eventHub.PublishEvent(s.gatewayID, event); err != nil { logger.Error("Failed to publish subscription resource event", diff --git a/gateway/gateway-controller/pkg/utils/subscription_resource_test.go b/gateway/gateway-controller/pkg/utils/subscription_resource_test.go index 84b0db386..4a99d1bc8 100644 --- a/gateway/gateway-controller/pkg/utils/subscription_resource_test.go +++ b/gateway/gateway-controller/pkg/utils/subscription_resource_test.go @@ -20,6 +20,7 @@ package utils import ( "context" + "encoding/json" "io" "log/slog" "testing" @@ -32,9 +33,10 @@ import ( type recordingSubscriptionDB struct { *testMockDB - calls *[]string - application *models.StoredApplication - mappings []*models.ApplicationAPIKeyMapping + calls *[]string + application *models.StoredApplication + mappings []*models.ApplicationAPIKeyMapping + removedKeyIDs []string } func newRecordingSubscriptionDB(calls *[]string) *recordingSubscriptionDB { @@ -54,11 +56,11 @@ func (m *recordingSubscriptionDB) SaveSubscriptionPlan(plan *models.Subscription return nil } -func (m *recordingSubscriptionDB) ReplaceApplicationAPIKeyMappings(application *models.StoredApplication, mappings []*models.ApplicationAPIKeyMapping) error { +func (m *recordingSubscriptionDB) ReplaceApplicationAPIKeyMappings(application *models.StoredApplication, mappings []*models.ApplicationAPIKeyMapping) ([]string, error) { *m.calls = append(*m.calls, "replace_application_mappings") m.application = application m.mappings = mappings - return nil + return append([]string(nil), m.removedKeyIDs...), nil } type recordingSubscriptionUpdater struct { @@ -155,8 +157,38 @@ func TestSubscriptionResourceServiceReplaceApplicationMappings_PublishesWithoutL assert.Equal(t, "UPDATE", hub.publishedEvents[0].Action) assert.Equal(t, "app-uuid-123", hub.publishedEvents[0].EntityID) assert.Equal(t, "corr-app-update", hub.publishedEvents[0].EventID) + assert.Equal(t, eventhub.EmptyEventData, hub.publishedEvents[0].EventData) assert.Zero(t, updater.calls) require.NotNil(t, db.application) assert.Equal(t, "app-uuid-123", db.application.ApplicationUUID) require.Len(t, db.mappings, 2) } + +func TestSubscriptionResourceServiceReplaceApplicationMappings_PublishesRemovedKeyIDs(t *testing.T) { + calls := []string{} + db := newRecordingSubscriptionDB(&calls) + updater := &recordingSubscriptionUpdater{} + hub := &recordingSubscriptionEventHub{calls: &calls} + service := NewSubscriptionResourceService(db, updater, hub, "gateway-2") + + db.removedKeyIDs = []string{"key-3"} + + application := &models.StoredApplication{ + ApplicationID: "app-123", + ApplicationUUID: "app-uuid-123", + ApplicationName: "Shopping App", + ApplicationType: "genai", + } + mappings := []*models.ApplicationAPIKeyMapping{ + {ApplicationUUID: "app-uuid-123", APIKeyID: "key-1"}, + {ApplicationUUID: "app-uuid-123", APIKeyID: "key-2"}, + } + + err := service.ReplaceApplicationAPIKeyMappings(application, mappings, "corr-app-update", newSubscriptionResourceTestLogger()) + require.NoError(t, err) + + require.Len(t, hub.publishedEvents, 1) + var eventData models.ApplicationEventData + require.NoError(t, json.Unmarshal([]byte(hub.publishedEvents[0].EventData), &eventData)) + assert.Equal(t, []string{"key-3"}, eventData.RemovedAPIKeyIDs) +}