diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index b5be61354..422b1070b 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -257,7 +257,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, subscriptionPlanService := service.NewSubscriptionPlanService(subscriptionPlanRepo, gatewayRepo, orgRepo, gatewayEventsService, auditRepo, slogger) internalGatewayService := service.NewGatewayInternalAPIService(apiRepo, subscriptionRepo, subscriptionPlanRepo, llmProviderRepo, llmProxyRepo, mcpProxyRepo, deploymentRepo, gatewayRepo, orgRepo, projectRepo, apiKeyRepo, artifactRepo, secretRepo, cfg, slogger) apiKeyService := service.NewAPIKeyService(apiRepo, artifactRepo, apiKeyRepo, gatewayEventsService, auditRepo, cfg.APIKey.HashingAlgorithms, slogger) - deploymentService := service.NewDeploymentService(apiRepo, artifactRepo, deploymentRepo, gatewayRepo, orgRepo, gatewayEventsService, auditRepo, apiUtil, cfg, slogger) + deploymentService := service.NewDeploymentService(apiRepo, artifactRepo, deploymentRepo, gatewayRepo, orgRepo, apiKeyRepo, gatewayEventsService, auditRepo, apiUtil, cfg, slogger) llmTemplateService := service.NewLLMProviderTemplateService(llmTemplateRepo, auditRepo, identityService) llmProviderService := service.NewLLMProviderService(llmProviderRepo, llmTemplateRepo, orgRepo, llmTemplateSeeder, deploymentRepo, gatewayRepo, gatewayEventsService, slogger, auditRepo, cfg, identityService) llmProxyService := service.NewLLMProxyService(llmProxyRepo, llmProviderRepo, projectRepo, deploymentRepo, gatewayRepo, gatewayEventsService, slogger, auditRepo, cfg, identityService) @@ -272,18 +272,20 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, deploymentRepo, gatewayRepo, orgRepo, + apiKeyRepo, gatewayEventsService, cfg, slogger, ) - llmProviderAPIKeyService := service.NewLLMProviderAPIKeyService(llmProviderRepo, gatewayRepo, apiKeyRepo, gatewayEventsService, identityService, slogger) - llmProxyAPIKeyService := service.NewLLMProxyAPIKeyService(llmProxyRepo, gatewayRepo, apiKeyRepo, gatewayEventsService, identityService, slogger) + llmProviderAPIKeyService := service.NewLLMProviderAPIKeyService(llmProviderRepo, apiRepo, apiKeyRepo, gatewayEventsService, identityService, slogger) + llmProxyAPIKeyService := service.NewLLMProxyAPIKeyService(llmProxyRepo, apiRepo, apiKeyRepo, gatewayEventsService, identityService, slogger) apiKeyUserService := service.NewAPIKeyUserService(apiKeyRepo, identityService, slogger) llmProxyDeploymentService := service.NewLLMProxyDeploymentService( llmProxyRepo, deploymentRepo, gatewayRepo, orgRepo, + apiKeyRepo, gatewayEventsService, cfg, slogger, @@ -294,6 +296,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, gatewayRepo, orgRepo, artifactRepo, + apiKeyRepo, gatewayEventsService, cfg, slogger, diff --git a/platform-api/internal/service/apikey.go b/platform-api/internal/service/apikey.go index ac0d4cb34..dd37434fc 100644 --- a/platform-api/internal/service/apikey.go +++ b/platform-api/internal/service/apikey.go @@ -166,19 +166,47 @@ func filterGatewaysByAllowedTargets(gateways []*model.Gateway, allowedTargets st if allowedTargets == "" || allowedTargets == constants.APIKeyAllowedTargetsAll { return gateways } - allowed := make(map[string]struct{}) - for _, name := range strings.Split(allowedTargets, ",") { - allowed[strings.TrimSpace(name)] = struct{}{} - } filtered := make([]*model.Gateway, 0, len(gateways)) for _, gw := range gateways { - if _, ok := allowed[gw.Name]; ok { + if gatewayAllowedByTargets(gw.Name, allowedTargets) { filtered = append(filtered, gw) } } return filtered } +// filterAPIGatewaysByAllowedTargets is filterGatewaysByAllowedTargets for the +// association-scoped []*model.APIGatewayWithDetails shape returned by +// GetAPIGatewaysWithDetails. Matching is by gateway Name, identical to the base helper. +func filterAPIGatewaysByAllowedTargets(gateways []*model.APIGatewayWithDetails, allowedTargets string) []*model.APIGatewayWithDetails { + if allowedTargets == "" || allowedTargets == constants.APIKeyAllowedTargetsAll { + return gateways + } + filtered := make([]*model.APIGatewayWithDetails, 0, len(gateways)) + for _, gw := range gateways { + if gatewayAllowedByTargets(gw.Name, allowedTargets) { + filtered = append(filtered, gw) + } + } + return filtered +} + +// gatewayAllowedByTargets reports whether a gateway (identified by name) is permitted by a +// key's allowedTargets. An empty value or constants.APIKeyAllowedTargetsAll ("ALL") permits +// every gateway; otherwise the gateway name must appear in the comma-separated list. This is +// the single source of truth shared by the filter helpers and the deploy-time backfill. +func gatewayAllowedByTargets(gatewayName, allowedTargets string) bool { + if allowedTargets == "" || allowedTargets == constants.APIKeyAllowedTargetsAll { + return true + } + for _, name := range strings.Split(allowedTargets, ",") { + if strings.TrimSpace(name) == gatewayName { + return true + } + } + return false +} + // randomHexString generates a random lowercase hex string of the requested length. func randomHexString(n int) (string, error) { bytes := make([]byte, (n+1)/2) @@ -276,6 +304,104 @@ func (s *APIKeyService) resolveUniqueKeyName(artifactUUID string, req *api.Creat return "", fmt.Errorf("failed to generate a unique API key name after %d retries", maxRetries) } +// APIKeyCreatedEventFromModel builds an apikey.created broadcast payload from a +// persisted API key record. It sends the hash JSON and masked key, never the plain +// key. Used both at key-creation time and to (re)broadcast pre-existing keys to a +// gateway the API is newly associated with at deploy time. +func APIKeyCreatedEventFromModel(k *model.APIKey) *model.APIKeyCreatedEvent { + event := &model.APIKeyCreatedEvent{ + UUID: k.UUID, + ApiId: k.ArtifactUUID, + Name: k.Name, + ApiKeyHashes: k.APIKeyHashes, + MaskedApiKey: k.MaskedAPIKey, + Issuer: k.Issuer, + CreatedAt: k.CreatedAt.Format(time.RFC3339), + UpdatedAt: k.UpdatedAt.Format(time.RFC3339), + } + if k.ExpiresAt != nil { + expiresAtStr := k.ExpiresAt.Format(time.RFC3339) + event.ExpiresAt = &expiresAtStr + } + return event +} + +// BackfillAPIKeysToGateway (re)broadcasts an artifact's existing active API keys to a +// gateway it has just been deployed/associated to. Keys are broadcast to their associated +// gateways only once, at creation time (CreateAPIKey and the per-kind key services), so a +// key created before this association would otherwise reach the gateway only after the +// gateway-controller's next reconnect bulk sync — leaving a window where the artifact is +// live but the key is rejected. Pushing existing keys here closes that window. +// +// It is artifact-kind-agnostic: ListByArtifact and the apikey.created event/controller +// handler are the same for REST APIs, LLM providers/proxies, MCP proxies, and WebSub/ +// WebBroker APIs, so every deploy path can share this one helper. +// +// Best-effort: the controller upserts apikey.created idempotently, so re-sending a key the +// gateway already holds is harmless, and any failure is logged without blocking the +// deployment (the reconnect bulk sync remains the safety net). +func BackfillAPIKeysToGateway(apiKeyRepo repository.APIKeyRepository, gatewayRepo repository.GatewayRepository, events *GatewayEventsService, slogger *slog.Logger, artifactUUID, gatewayID, actor string) { + if events == nil || apiKeyRepo == nil { + return + } + + keys, err := apiKeyRepo.ListByArtifact(artifactUUID) + if err != nil { + if slogger != nil { + slogger.Warn("Failed to load API keys for deploy-time backfill", + "artifactId", artifactUUID, "gatewayId", gatewayID, "error", err) + } + return + } + + // Resolve the target gateway's name once so we can honor each key's AllowedTargets, + // which is matched by gateway name — consistent with the create/revoke broadcast paths + // (filterGatewaysByAllowedTargets). If the name can't be resolved, keys restricted to + // specific targets are conservatively skipped (only "ALL"/unrestricted keys go through). + gatewayName := "" + if gatewayRepo != nil { + if gw, gwErr := gatewayRepo.GetByUUID(gatewayID); gwErr != nil { + if slogger != nil { + slogger.Warn("Failed to resolve gateway for API key backfill target filtering", + "gatewayId", gatewayID, "error", gwErr) + } + } else if gw != nil { + gatewayName = gw.Name + } + } + + now := time.Now() + backfilled := 0 + for _, k := range keys { + if k == nil || k.Status != constants.APIKeyStatusActive { + continue + } + // Skip expired keys — never push a dead key to a gateway. + if k.ExpiresAt != nil && !k.ExpiresAt.After(now) { + continue + } + // Honor per-key AllowedTargets — skip keys not permitted for this gateway. + if !gatewayAllowedByTargets(gatewayName, k.AllowedTargets) { + continue + } + + event := APIKeyCreatedEventFromModel(k) + if err := events.BroadcastAPIKeyCreatedEvent(gatewayID, actor, event); err != nil { + if slogger != nil { + slogger.Warn("Failed to backfill API key to gateway", + "artifactId", artifactUUID, "gatewayId", gatewayID, "keyName", k.Name, "error", err) + } + continue + } + backfilled++ + } + + if backfilled > 0 && slogger != nil { + slogger.Info("Backfilled existing API keys to gateway at deploy time", + "artifactId", artifactUUID, "gatewayId", gatewayID, "count", backfilled) + } +} + // CreateAPIKey hashes an external API key and broadcasts it to gateways where the API is deployed. // This method is used when external platforms inject API keys to hybrid gateways. func (s *APIKeyService) CreateAPIKey(ctx context.Context, apiHandle, kind, orgId, userId string, req *api.CreateAPIKeyRequest) error { @@ -292,14 +418,13 @@ func (s *APIKeyService) CreateAPIKey(ctx context.Context, apiHandle, kind, orgId } apiId := apiMetadata.ID - // Get all deployments for this API to find target gateways + // Get all deployments for this API to find target gateways. + // An empty list is valid: the key is still persisted centrally and any gateway + // associated later picks it up via deployment-time sync. gateways, err := s.apiRepo.GetAPIGatewaysWithDetails(apiId, orgId) if err != nil { return fmt.Errorf("failed to get API deployments for API handle: %s: %w", apiHandle, err) } - if len(gateways) == 0 { - return apperror.GatewayConnectionUnavailable.New() - } // Resolve key name (required for DB uniqueness; derive from request or generate) keyName, err := s.resolveUniqueKeyName(apiId, req, apiHandle) @@ -361,21 +486,8 @@ func (s *APIKeyService) CreateAPIKey(ctx context.Context, apiHandle, kind, orgId _ = s.auditRepo.Record("CREATE", apiKeyUUID, "api_key", orgId, userId) // Build the API key created event — send the hash JSON and masked key, not the plain key - event := &model.APIKeyCreatedEvent{ - UUID: apiKeyUUID, - ApiId: apiId, - Name: keyName, - ApiKeyHashes: apiKeyHashesJSON, - MaskedApiKey: maskedAPIKey, - ExternalRefId: req.ExternalRefId, - Issuer: issuer, - CreatedAt: dbKey.CreatedAt.Format(time.RFC3339), - UpdatedAt: dbKey.UpdatedAt.Format(time.RFC3339), - } - if expiresAt != nil { - expiresAtStr := expiresAt.Format(time.RFC3339) - event.ExpiresAt = &expiresAtStr - } + event := APIKeyCreatedEventFromModel(dbKey) + event.ExternalRefId = req.ExternalRefId successCount := 0 failureCount := 0 diff --git a/platform-api/internal/service/artifact_dp_apikey_test.go b/platform-api/internal/service/artifact_dp_apikey_test.go index ae0b0f1da..02cda7f72 100644 --- a/platform-api/internal/service/artifact_dp_apikey_test.go +++ b/platform-api/internal/service/artifact_dp_apikey_test.go @@ -50,14 +50,23 @@ func (dpNoopEventHub) UnsubscribeAll(string) error { return func (dpNoopEventHub) CleanUpEvents() error { return nil } func (dpNoopEventHub) Close() error { return nil } -// dpKeyGatewayRepo returns a single gateway for the org so API-key creation has a -// broadcast target (an empty list would short-circuit with ErrGatewayUnavailable). -type dpKeyGatewayRepo struct { - repository.GatewayRepository +// dpKeyAPIRepo returns a single associated gateway so LLM API-key creation has a +// broadcast target. Association-scoped selection mirrors the REST key path. +type dpKeyAPIRepo struct { + repository.APIRepository } -func (dpKeyGatewayRepo) GetByOrganizationID(string) ([]*model.Gateway, error) { - return []*model.Gateway{{ID: "gw-1"}}, nil +func (dpKeyAPIRepo) GetAPIGatewaysWithDetails(string, string) ([]*model.APIGatewayWithDetails, error) { + return []*model.APIGatewayWithDetails{{ID: "gw-1", Name: "gw-1"}}, nil +} + +// dpKeyNoAssocAPIRepo models an artifact with NO gateway associations (undeployed). +type dpKeyNoAssocAPIRepo struct { + repository.APIRepository +} + +func (dpKeyNoAssocAPIRepo) GetAPIGatewaysWithDetails(string, string) ([]*model.APIGatewayWithDetails, error) { + return nil, nil } // dpCapturingAPIKeyRepo captures the persisted API key so tests can assert it was created @@ -91,7 +100,7 @@ func TestLLMProviderAPIKey_AllowedForDPOrigin(t *testing.T) { getByIDFunc: func(string, string) (*model.LLMProvider, error) { return provider, nil }, } keyRepo := &dpCapturingAPIKeyRepo{} - svc := NewLLMProviderAPIKeyService(providerRepo, dpKeyGatewayRepo{}, keyRepo, newDPKeyEventsService(), newTestIdentityService(), newTestLogger()) + svc := NewLLMProviderAPIKeyService(providerRepo, dpKeyAPIRepo{}, keyRepo, newDPKeyEventsService(), newTestIdentityService(), newTestLogger()) resp, err := svc.CreateLLMProviderAPIKey(context.Background(), "dp-prov", "org-1", "", &api.CreateLLMProviderAPIKeyRequest{DisplayName: "dp-consumer-key"}) @@ -124,7 +133,7 @@ func TestLLMProxyAPIKey_AllowedForDPOrigin(t *testing.T) { getByIDFunc: func(string, string) (*model.LLMProxy, error) { return proxy, nil }, } keyRepo := &dpCapturingAPIKeyRepo{} - svc := NewLLMProxyAPIKeyService(proxyRepo, dpKeyGatewayRepo{}, keyRepo, newDPKeyEventsService(), newTestIdentityService(), newTestLogger()) + svc := NewLLMProxyAPIKeyService(proxyRepo, dpKeyAPIRepo{}, keyRepo, newDPKeyEventsService(), newTestIdentityService(), newTestLogger()) resp, err := svc.CreateLLMProxyAPIKey(context.Background(), "dp-proxy", "org-1", "", &api.CreateLLMProxyAPIKeyRequest{DisplayName: "dp-consumer-key"}) @@ -141,3 +150,76 @@ func TestLLMProxyAPIKey_AllowedForDPOrigin(t *testing.T) { t.Errorf("persisted key ArtifactUUID = %q, want proxy UUID %q", keyRepo.created.ArtifactUUID, proxy.UUID) } } + +// TestCreateLLMProviderAPIKey_AssociationScoped verifies that creating a key for a +// provider with NO gateway associations persists the key and broadcasts to ZERO +// gateways — i.e. the broadcast is association-scoped, not org-wide (issue: LLM key +// events were previously sent to every org gateway, causing "artifact not found"). +func TestCreateLLMProviderAPIKey_AssociationScoped(t *testing.T) { + provider := &model.LLMProvider{ + UUID: "prov-uuid", + ID: "prov", + OrganizationUUID: "org-1", + Name: "Prov", + Version: "v1.0", + } + providerRepo := &mockLLMProviderRepo{ + getByIDFunc: func(string, string) (*model.LLMProvider, error) { return provider, nil }, + } + keyRepo := &dpCapturingAPIKeyRepo{} + hub := &capturingEventHub{} + events := NewGatewayEventsService(hub, newTestIdentityService(), newTestLogger()) + + svc := NewLLMProviderAPIKeyService(providerRepo, dpKeyNoAssocAPIRepo{}, keyRepo, + events, newTestIdentityService(), newTestLogger()) + + resp, err := svc.CreateLLMProviderAPIKey(context.Background(), "prov", "org-1", "", + &api.CreateLLMProviderAPIKeyRequest{DisplayName: "k"}) + if err != nil { + t.Fatalf("CreateLLMProviderAPIKey with no associations = %v, want success", err) + } + if resp == nil || resp.ApiKey == "" { + t.Fatalf("expected a generated key, got %#v", resp) + } + if keyRepo.created == nil { + t.Fatalf("key was not persisted") + } + if len(hub.published) != 0 { + t.Fatalf("expected 0 broadcasts for an unassociated provider, got %d", len(hub.published)) + } +} + +// TestCreateLLMProxyAPIKey_AssociationScoped is the LLM-proxy counterpart. +func TestCreateLLMProxyAPIKey_AssociationScoped(t *testing.T) { + proxy := &model.LLMProxy{ + UUID: "proxy-uuid", + ID: "proxy", + OrganizationUUID: "org-1", + Name: "Proxy", + Version: "v1.0", + } + proxyRepo := &mockLLMProxyRepo{ + getByIDFunc: func(string, string) (*model.LLMProxy, error) { return proxy, nil }, + } + keyRepo := &dpCapturingAPIKeyRepo{} + hub := &capturingEventHub{} + events := NewGatewayEventsService(hub, newTestIdentityService(), newTestLogger()) + + svc := NewLLMProxyAPIKeyService(proxyRepo, dpKeyNoAssocAPIRepo{}, keyRepo, + events, newTestIdentityService(), newTestLogger()) + + resp, err := svc.CreateLLMProxyAPIKey(context.Background(), "proxy", "org-1", "", + &api.CreateLLMProxyAPIKeyRequest{DisplayName: "k"}) + if err != nil { + t.Fatalf("CreateLLMProxyAPIKey with no associations = %v, want success", err) + } + if resp == nil || resp.ApiKey == "" { + t.Fatalf("expected a generated key, got %#v", resp) + } + if keyRepo.created == nil { + t.Fatalf("key was not persisted") + } + if len(hub.published) != 0 { + t.Fatalf("expected 0 broadcasts for an unassociated proxy, got %d", len(hub.published)) + } +} diff --git a/platform-api/internal/service/deployment.go b/platform-api/internal/service/deployment.go index e6f1af766..cf4415167 100644 --- a/platform-api/internal/service/deployment.go +++ b/platform-api/internal/service/deployment.go @@ -47,6 +47,7 @@ type DeploymentService struct { deploymentRepo repository.DeploymentRepository gatewayRepo repository.GatewayRepository orgRepo repository.OrganizationRepository + apiKeyRepo repository.APIKeyRepository gatewayEventsService *GatewayEventsService auditRepo repository.AuditRepository apiUtil *utils.APIUtil @@ -61,6 +62,7 @@ func NewDeploymentService( deploymentRepo repository.DeploymentRepository, gatewayRepo repository.GatewayRepository, orgRepo repository.OrganizationRepository, + apiKeyRepo repository.APIKeyRepository, gatewayEventsService *GatewayEventsService, auditRepo repository.AuditRepository, apiUtil *utils.APIUtil, @@ -73,6 +75,7 @@ func NewDeploymentService( deploymentRepo: deploymentRepo, gatewayRepo: gatewayRepo, orgRepo: orgRepo, + apiKeyRepo: apiKeyRepo, gatewayEventsService: gatewayEventsService, auditRepo: auditRepo, apiUtil: apiUtil, @@ -341,6 +344,11 @@ func (s *DeploymentService) DeployAPI(apiUUID string, req *api.DeployRequest, or if err := s.gatewayEventsService.BroadcastDeploymentEvent(gatewayID, deploymentEvent); err != nil { s.slogger.Warn("Failed to broadcast deployment event", "error", err) } + + // Push existing active API keys for this artifact to the (possibly newly + // associated) gateway so keys created before this association are recognized + // immediately, rather than only after the controller's next reconnect sync. + s.backfillAPIKeysToGateway(apiUUID, gatewayID, createdBy) } return toAPIDeploymentResponse( @@ -423,6 +431,10 @@ func (s *DeploymentService) RestoreDeployment(apiUUID, deploymentID, gatewayID, if err := s.gatewayEventsService.BroadcastDeploymentEvent(targetDeployment.GatewayID, deploymentEvent); err != nil { s.slogger.Warn("Failed to broadcast deployment event", "error", err) } + + // Push existing active API keys for this artifact to the gateway so a restored + // deployment recognizes pre-existing keys immediately (see backfillAPIKeysToGateway). + s.backfillAPIKeysToGateway(apiUUID, targetDeployment.GatewayID, actor) } if s.auditRepo != nil { @@ -875,6 +887,13 @@ func (s *DeploymentService) ensureAPIGatewayAssociation(apiUUID, gatewayID, orgU return s.apiRepo.CreateAPIAssociation(association) } +// backfillAPIKeysToGateway delegates to the shared BackfillAPIKeysToGateway helper so +// every deploy path (REST, LLM provider/proxy, MCP, WebSub, WebBroker) pushes existing +// keys identically. +func (s *DeploymentService) backfillAPIKeysToGateway(apiUUID, gatewayID, actor string) { + BackfillAPIKeysToGateway(s.apiKeyRepo, s.gatewayRepo, s.gatewayEventsService, s.slogger, apiUUID, gatewayID, actor) +} + // DeployAPIByHandle creates a new immutable deployment artifact using API handle func (s *DeploymentService) DeployAPIByHandle(apiHandle string, req *api.DeployRequest, orgUUID, createdBy string) (*api.DeploymentResponse, error) { // Convert API handle to UUID diff --git a/platform-api/internal/service/deployment_apikey_backfill_test.go b/platform-api/internal/service/deployment_apikey_backfill_test.go new file mode 100644 index 000000000..f9cfd92d5 --- /dev/null +++ b/platform-api/internal/service/deployment_apikey_backfill_test.go @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package service + +import ( + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/wso2/api-platform/common/eventhub" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/dto" + "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/repository" +) + +// capturingEventHub records every published event so tests can assert what was broadcast. +type capturingEventHub struct { + published []eventhub.Event +} + +func (h *capturingEventHub) Initialize() error { return nil } +func (h *capturingEventHub) RegisterGateway(string) error { return nil } +func (h *capturingEventHub) PublishEvent(_ string, e eventhub.Event) error { + h.published = append(h.published, e) + return nil +} +func (h *capturingEventHub) Subscribe(string) (<-chan eventhub.Event, error) { return nil, nil } +func (h *capturingEventHub) Unsubscribe(string, <-chan eventhub.Event) error { return nil } +func (h *capturingEventHub) UnsubscribeAll(string) error { return nil } +func (h *capturingEventHub) CleanUpEvents() error { return nil } +func (h *capturingEventHub) Close() error { return nil } + +// stubBackfillAPIKeyRepo returns a fixed key list from ListByArtifact. +type stubBackfillAPIKeyRepo struct { + repository.APIKeyRepository + keys []*model.APIKey + err error +} + +func (r *stubBackfillAPIKeyRepo) ListByArtifact(string) ([]*model.APIKey, error) { + return r.keys, r.err +} + +// stubBackfillGatewayRepo resolves a gateway UUID to a gateway with a fixed name so the +// backfill can evaluate per-key AllowedTargets (which match by gateway name). +type stubBackfillGatewayRepo struct { + repository.GatewayRepository + name string +} + +func (r *stubBackfillGatewayRepo) GetByUUID(id string) (*model.Gateway, error) { + return &model.Gateway{ID: id, Name: r.name}, nil +} + +// decodeKeyName extracts the API key name from a captured apikey.created event. +func decodeKeyName(t *testing.T, e eventhub.Event) string { + t.Helper() + var envelope dto.GatewayEventDTO + if err := json.Unmarshal([]byte(e.EventData), &envelope); err != nil { + t.Fatalf("failed to decode event envelope: %v", err) + } + if envelope.Type != EventTypeAPIKeyCreated { + t.Fatalf("unexpected event type %q, want %q", envelope.Type, EventTypeAPIKeyCreated) + } + payloadBytes, err := json.Marshal(envelope.Payload) + if err != nil { + t.Fatalf("failed to re-marshal payload: %v", err) + } + var key model.APIKeyCreatedEvent + if err := json.Unmarshal(payloadBytes, &key); err != nil { + t.Fatalf("failed to decode api key payload: %v", err) + } + return key.Name +} + +func TestBackfillAPIKeysToGateway(t *testing.T) { + past := time.Now().Add(-time.Hour) + future := time.Now().Add(time.Hour) + + keys := []*model.APIKey{ + {UUID: "k-active", ArtifactUUID: "api-1", Name: "active-key", Status: constants.APIKeyStatusActive}, + {UUID: "k-future", ArtifactUUID: "api-1", Name: "active-future-key", Status: constants.APIKeyStatusActive, ExpiresAt: &future}, + {UUID: "k-expired", ArtifactUUID: "api-1", Name: "expired-key", Status: constants.APIKeyStatusActive, ExpiresAt: &past}, + {UUID: "k-revoked", ArtifactUUID: "api-1", Name: "revoked-key", Status: "revoked"}, + nil, + } + + hub := &capturingEventHub{} + svc := &DeploymentService{ + apiKeyRepo: &stubBackfillAPIKeyRepo{keys: keys}, + gatewayEventsService: NewGatewayEventsService(hub, newTestIdentityService(), newTestLogger()), + slogger: newTestLogger(), + } + + svc.backfillAPIKeysToGateway("api-1", "gw-B", "actor-1") + + // Only the two active, non-expired keys should be broadcast. + if len(hub.published) != 2 { + t.Fatalf("expected 2 broadcast events, got %d", len(hub.published)) + } + + got := map[string]bool{} + for _, e := range hub.published { + if e.GatewayID != "gw-B" { + t.Errorf("event broadcast to gateway %q, want gw-B", e.GatewayID) + } + got[decodeKeyName(t, e)] = true + } + for _, want := range []string{"active-key", "active-future-key"} { + if !got[want] { + t.Errorf("expected key %q to be backfilled, but it was not", want) + } + } + for _, notWant := range []string{"expired-key", "revoked-key"} { + if got[notWant] { + t.Errorf("key %q should not have been backfilled", notWant) + } + } +} + +// TestBackfillAPIKeysToGateway_NoKeys ensures an empty artifact broadcasts nothing and does not panic. +func TestBackfillAPIKeysToGateway_NoKeys(t *testing.T) { + hub := &capturingEventHub{} + svc := &DeploymentService{ + apiKeyRepo: &stubBackfillAPIKeyRepo{keys: nil}, + gatewayEventsService: NewGatewayEventsService(hub, newTestIdentityService(), newTestLogger()), + slogger: newTestLogger(), + } + + svc.backfillAPIKeysToGateway("api-1", "gw-B", "actor-1") + + if len(hub.published) != 0 { + t.Fatalf("expected no broadcast events, got %d", len(hub.published)) + } +} + +// TestBackfillAPIKeysToGateway_NilDeps is a no-op when dependencies are unset (e.g. events disabled). +func TestBackfillAPIKeysToGateway_NilDeps(t *testing.T) { + svc := &DeploymentService{slogger: newTestLogger()} + // Must not panic despite nil apiKeyRepo / gatewayEventsService. + svc.backfillAPIKeysToGateway("api-1", "gw-B", "actor-1") +} + +// TestBackfillAPIKeysToGateway_ExportedSharedByAllKinds exercises the exported helper +// directly — the single implementation the REST, LLM provider/proxy, MCP, WebSub and +// WebBroker deploy paths all delegate to. Also asserts nil-slogger safety (some call +// sites may pass a nil logger). +func TestBackfillAPIKeysToGateway_ExportedSharedByAllKinds(t *testing.T) { + future := time.Now().Add(time.Hour) + repo := &stubBackfillAPIKeyRepo{keys: []*model.APIKey{ + {UUID: "k1", ArtifactUUID: "artifact-1", Name: "k1", Status: constants.APIKeyStatusActive}, + {UUID: "k2", ArtifactUUID: "artifact-1", Name: "k2", Status: constants.APIKeyStatusActive, ExpiresAt: &future}, + }} + hub := &capturingEventHub{} + events := NewGatewayEventsService(hub, newTestIdentityService(), newTestLogger()) + + // nil slogger must not panic and must still broadcast. + BackfillAPIKeysToGateway(repo, nil, events, nil, "artifact-1", "gw-X", "") + + if len(hub.published) != 2 { + t.Fatalf("expected 2 broadcast events, got %d", len(hub.published)) + } + for _, e := range hub.published { + if e.GatewayID != "gw-X" { + t.Errorf("event broadcast to %q, want gw-X", e.GatewayID) + } + } +} + +// TestBackfillAPIKeysToGateway_RepoError stops silently (best-effort) when the key +// lookup fails — the deployment must not be blocked by a backfill error. +func TestBackfillAPIKeysToGateway_RepoError(t *testing.T) { + repo := &stubBackfillAPIKeyRepo{err: fmt.Errorf("db down")} + hub := &capturingEventHub{} + events := NewGatewayEventsService(hub, newTestIdentityService(), newTestLogger()) + + BackfillAPIKeysToGateway(repo, nil, events, newTestLogger(), "artifact-1", "gw-X", "") + + if len(hub.published) != 0 { + t.Fatalf("expected no broadcasts on repo error, got %d", len(hub.published)) + } +} + +// TestBackfillAPIKeysToGateway_HonorsAllowedTargets verifies a key is backfilled only to a +// gateway permitted by its AllowedTargets — matching the create/revoke broadcast behaviour. +func TestBackfillAPIKeysToGateway_HonorsAllowedTargets(t *testing.T) { + keys := []*model.APIKey{ + {UUID: "k-all", ArtifactUUID: "api-1", Name: "all-key", Status: constants.APIKeyStatusActive, AllowedTargets: constants.APIKeyAllowedTargetsAll}, + {UUID: "k-here", ArtifactUUID: "api-1", Name: "here-key", Status: constants.APIKeyStatusActive, AllowedTargets: "gw-target,other-gw"}, + {UUID: "k-else", ArtifactUUID: "api-1", Name: "elsewhere-key", Status: constants.APIKeyStatusActive, AllowedTargets: "other-gw"}, + } + hub := &capturingEventHub{} + events := NewGatewayEventsService(hub, newTestIdentityService(), newTestLogger()) + + // Target gateway "gw-target": the ALL key and the key that lists gw-target should go through; + // the key restricted to "other-gw" must be skipped. + BackfillAPIKeysToGateway(&stubBackfillAPIKeyRepo{keys: keys}, &stubBackfillGatewayRepo{name: "gw-target"}, + events, newTestLogger(), "api-1", "gw-uuid", "") + + if len(hub.published) != 2 { + t.Fatalf("expected 2 broadcasts (ALL + gw-target-listed), got %d", len(hub.published)) + } + got := map[string]bool{} + for _, e := range hub.published { + got[decodeKeyName(t, e)] = true + } + if !got["all-key"] || !got["here-key"] { + t.Errorf("expected all-key and here-key backfilled; got %v", got) + } + if got["elsewhere-key"] { + t.Errorf("key restricted to other-gw must not be backfilled to gw-target") + } +} diff --git a/platform-api/internal/service/llm_apikey.go b/platform-api/internal/service/llm_apikey.go index fbcf52445..c72339258 100644 --- a/platform-api/internal/service/llm_apikey.go +++ b/platform-api/internal/service/llm_apikey.go @@ -35,7 +35,7 @@ import ( // LLMProviderAPIKeyService handles API key management for LLM providers type LLMProviderAPIKeyService struct { llmProviderRepo repository.LLMProviderRepository - gatewayRepo repository.GatewayRepository + apiRepo repository.APIRepository apiKeyRepo repository.APIKeyRepository gatewayEventsService *GatewayEventsService identity *IdentityService @@ -45,7 +45,7 @@ type LLMProviderAPIKeyService struct { // NewLLMProviderAPIKeyService creates a new LLM provider API key service instance func NewLLMProviderAPIKeyService( llmProviderRepo repository.LLMProviderRepository, - gatewayRepo repository.GatewayRepository, + apiRepo repository.APIRepository, apiKeyRepo repository.APIKeyRepository, gatewayEventsService *GatewayEventsService, identity *IdentityService, @@ -53,7 +53,7 @@ func NewLLMProviderAPIKeyService( ) *LLMProviderAPIKeyService { return &LLMProviderAPIKeyService{ llmProviderRepo: llmProviderRepo, - gatewayRepo: gatewayRepo, + apiRepo: apiRepo, apiKeyRepo: apiKeyRepo, gatewayEventsService: gatewayEventsService, identity: identity, @@ -163,14 +163,14 @@ func (s *LLMProviderAPIKeyService) DeleteLLMProviderAPIKey( s.slogger.Info("Successfully deleted LLM provider API key", "providerId", providerID, "keyName", keyName) - // Broadcast revoke event to gateways. - gateways, err := s.gatewayRepo.GetByOrganizationID(orgID) + // Broadcast revoke only to gateways the provider is associated with (not all org gateways). + gateways, err := s.apiRepo.GetAPIGatewaysWithDetails(provider.UUID, orgID) if err != nil { - s.slogger.Error("Failed to get gateways for API key revoke broadcast", "providerId", providerID, "keyName", keyName, "error", err) + s.slogger.Error("Failed to get associated gateways for API key revoke broadcast", "providerId", providerID, "keyName", keyName, "error", err) return nil } if len(gateways) == 0 { - s.slogger.Warn("No gateways found for organization; skipping revoke broadcast", "organizationId", orgID) + s.slogger.Info("Provider not associated with any gateway; skipping revoke broadcast", "providerId", providerID, "keyName", keyName) return nil } @@ -179,7 +179,7 @@ func (s *LLMProviderAPIKeyService) DeleteLLMProviderAPIKey( KeyName: keyName, } - for _, gateway := range filterGatewaysByAllowedTargets(gateways, existingKey.AllowedTargets) { + for _, gateway := range filterAPIGatewaysByAllowedTargets(gateways, existingKey.AllowedTargets) { gatewayID := gateway.ID if err := s.gatewayEventsService.BroadcastAPIKeyRevokedEvent(gatewayID, userID, event); err != nil { s.slogger.Error("Failed to broadcast LLM provider API key revoked event", "providerId", providerID, "gatewayId", gatewayID, "keyName", keyName, "error", err) @@ -240,17 +240,15 @@ func (s *LLMProviderAPIKeyService) CreateLLMProviderAPIKey( expiresAt = &expiresAtStr } - gateways, err := s.gatewayRepo.GetByOrganizationID(orgID) + // Broadcast only to gateways the provider is associated with (not all org gateways), + // mirroring the REST key path. An empty list is valid: the key is still persisted and + // any gateway associated later picks it up via the deploy-time backfill. + gateways, err := s.apiRepo.GetAPIGatewaysWithDetails(provider.UUID, orgID) if err != nil { s.slogger.Error("Failed to get gateways for API key broadcast", "providerId", providerID, "error", err) return nil, fmt.Errorf("failed to get gateways: %w", err) } - if len(gateways) == 0 { - s.slogger.Warn("No gateways found for organization", "organizationId", orgID) - return nil, apperror.GatewayConnectionUnavailable.New() - } - apiKeyHashesJSON, err := buildAPIKeyHashesJSON(apiKey, []string{defaultHashingAlgorithm}) if err != nil { s.slogger.Error("Failed to hash API key for LLM provider", "providerId", providerID, "error", err) @@ -306,7 +304,7 @@ func (s *LLMProviderAPIKeyService) CreateLLMProviderAPIKey( UpdatedAt: dbKey.UpdatedAt.Format(time.RFC3339), } - targetGateways := filterGatewaysByAllowedTargets(gateways, allowedTargets) + targetGateways := filterAPIGatewaysByAllowedTargets(gateways, allowedTargets) successCount := 0 failureCount := 0 var lastError error @@ -329,8 +327,12 @@ func (s *LLMProviderAPIKeyService) CreateLLMProviderAPIKey( s.slogger.Info("LLM provider API key creation broadcast summary", "providerId", providerID, "keyName", name, "total", len(targetGateways), "success", successCount, "failed", failureCount) - if successCount == 0 { - s.slogger.Warn("Failed to deliver LLM provider API key to any gateway; key is saved to the database", "providerId", providerID, "keyName", name, "error", lastError) + if len(targetGateways) == 0 { + // No gateways associated yet — a valid state. The key is persisted centrally and any + // gateway associated later picks it up via the deploy-time backfill. + s.slogger.Info("LLM provider not associated with any gateway; API key saved and will be delivered at deploy time", "providerId", providerID, "keyName", name) + } else if successCount == 0 { + s.slogger.Error("LLM provider API key created event was not broadcast to any associated gateway", "providerId", providerID, "keyName", name, "error", lastError) } return &api.CreateLLMProviderAPIKeyResponse{ diff --git a/platform-api/internal/service/llm_deployment.go b/platform-api/internal/service/llm_deployment.go index 25275c15b..22cf24ad7 100644 --- a/platform-api/internal/service/llm_deployment.go +++ b/platform-api/internal/service/llm_deployment.go @@ -56,6 +56,7 @@ type LLMProviderDeploymentService struct { deploymentRepo repository.DeploymentRepository gatewayRepo repository.GatewayRepository orgRepo repository.OrganizationRepository + apiKeyRepo repository.APIKeyRepository gatewayEventsService *GatewayEventsService cfg *config.Server slogger *slog.Logger @@ -68,6 +69,7 @@ type LLMProxyDeploymentService struct { deploymentRepo repository.DeploymentRepository gatewayRepo repository.GatewayRepository orgRepo repository.OrganizationRepository + apiKeyRepo repository.APIKeyRepository gatewayEventsService *GatewayEventsService cfg *config.Server slogger *slog.Logger @@ -80,6 +82,7 @@ func NewLLMProviderDeploymentService( deploymentRepo repository.DeploymentRepository, gatewayRepo repository.GatewayRepository, orgRepo repository.OrganizationRepository, + apiKeyRepo repository.APIKeyRepository, gatewayEventsService *GatewayEventsService, cfg *config.Server, slogger *slog.Logger, @@ -90,6 +93,7 @@ func NewLLMProviderDeploymentService( deploymentRepo: deploymentRepo, gatewayRepo: gatewayRepo, orgRepo: orgRepo, + apiKeyRepo: apiKeyRepo, gatewayEventsService: gatewayEventsService, cfg: cfg, slogger: slogger, @@ -102,6 +106,7 @@ func NewLLMProxyDeploymentService( deploymentRepo repository.DeploymentRepository, gatewayRepo repository.GatewayRepository, orgRepo repository.OrganizationRepository, + apiKeyRepo repository.APIKeyRepository, gatewayEventsService *GatewayEventsService, cfg *config.Server, slogger *slog.Logger, @@ -111,6 +116,7 @@ func NewLLMProxyDeploymentService( deploymentRepo: deploymentRepo, gatewayRepo: gatewayRepo, orgRepo: orgRepo, + apiKeyRepo: apiKeyRepo, gatewayEventsService: gatewayEventsService, cfg: cfg, slogger: slogger, @@ -276,6 +282,11 @@ func (s *LLMProviderDeploymentService) DeployLLMProvider(providerID string, req if err := s.gatewayEventsService.BroadcastLLMProviderDeploymentEvent(gatewayID, deploymentEvent); err != nil { s.slogger.Warn("Failed to broadcast LLM provider deployment event", "error", err) } + + // Push existing active API keys for this provider to the (possibly newly + // associated) gateway so keys created before this association are recognized + // immediately, rather than only after the controller's next reconnect sync. + BackfillAPIKeysToGateway(s.apiKeyRepo, s.gatewayRepo, s.gatewayEventsService, s.slogger, provider.UUID, gatewayID, "") } return toAPIDeploymentResponse( @@ -368,6 +379,9 @@ func (s *LLMProviderDeploymentService) RestoreLLMProviderDeployment(providerID, if err := s.gatewayEventsService.BroadcastLLMProviderDeploymentEvent(targetDeployment.GatewayID, deploymentEvent); err != nil { s.slogger.Warn("Failed to broadcast LLM provider deployment event", "error", err) } + + // Backfill existing active API keys to the gateway (see BackfillAPIKeysToGateway). + BackfillAPIKeysToGateway(s.apiKeyRepo, s.gatewayRepo, s.gatewayEventsService, s.slogger, provider.UUID, targetDeployment.GatewayID, "") } return toAPIDeploymentResponse( @@ -1440,6 +1454,9 @@ func (s *LLMProxyDeploymentService) DeployLLMProxy(proxyID string, req *api.Depl if err := s.gatewayEventsService.BroadcastLLMProxyDeploymentEvent(gatewayID, deploymentEvent); err != nil { s.slogger.Warn("Failed to broadcast LLM proxy deployment event", "error", err) } + + // Push existing active API keys for this proxy to the gateway (see BackfillAPIKeysToGateway). + BackfillAPIKeysToGateway(s.apiKeyRepo, s.gatewayRepo, s.gatewayEventsService, s.slogger, proxy.UUID, gatewayID, "") } return toAPIDeploymentResponse( @@ -1532,6 +1549,9 @@ func (s *LLMProxyDeploymentService) RestoreLLMProxyDeployment(proxyID, deploymen if err := s.gatewayEventsService.BroadcastLLMProxyDeploymentEvent(targetDeployment.GatewayID, deploymentEvent); err != nil { s.slogger.Warn("Failed to broadcast LLM proxy deployment event", "error", err) } + + // Backfill existing active API keys to the gateway (see BackfillAPIKeysToGateway). + BackfillAPIKeysToGateway(s.apiKeyRepo, s.gatewayRepo, s.gatewayEventsService, s.slogger, proxy.UUID, targetDeployment.GatewayID, "") } return toAPIDeploymentResponse( diff --git a/platform-api/internal/service/llm_proxy_apikey.go b/platform-api/internal/service/llm_proxy_apikey.go index 44a3462fa..b8c034e17 100644 --- a/platform-api/internal/service/llm_proxy_apikey.go +++ b/platform-api/internal/service/llm_proxy_apikey.go @@ -35,7 +35,7 @@ import ( // LLMProxyAPIKeyService handles API key management for LLM proxies type LLMProxyAPIKeyService struct { llmProxyRepo repository.LLMProxyRepository - gatewayRepo repository.GatewayRepository + apiRepo repository.APIRepository apiKeyRepo repository.APIKeyRepository gatewayEventsService *GatewayEventsService identity *IdentityService @@ -45,7 +45,7 @@ type LLMProxyAPIKeyService struct { // NewLLMProxyAPIKeyService creates a new LLM proxy API key service instance func NewLLMProxyAPIKeyService( llmProxyRepo repository.LLMProxyRepository, - gatewayRepo repository.GatewayRepository, + apiRepo repository.APIRepository, apiKeyRepo repository.APIKeyRepository, gatewayEventsService *GatewayEventsService, identity *IdentityService, @@ -53,7 +53,7 @@ func NewLLMProxyAPIKeyService( ) *LLMProxyAPIKeyService { return &LLMProxyAPIKeyService{ llmProxyRepo: llmProxyRepo, - gatewayRepo: gatewayRepo, + apiRepo: apiRepo, apiKeyRepo: apiKeyRepo, gatewayEventsService: gatewayEventsService, identity: identity, @@ -136,14 +136,14 @@ func (s *LLMProxyAPIKeyService) DeleteLLMProxyAPIKey( s.slogger.Info("Successfully deleted LLM proxy API key", "proxyId", proxyID, "keyName", keyName) - // Broadcast revoke event to gateways. - gateways, err := s.gatewayRepo.GetByOrganizationID(orgID) + // Broadcast revoke only to gateways the proxy is associated with (not all org gateways). + gateways, err := s.apiRepo.GetAPIGatewaysWithDetails(proxy.UUID, orgID) if err != nil { - s.slogger.Error("Failed to get gateways for API key revoke broadcast", "proxyId", proxyID, "keyName", keyName, "error", err) + s.slogger.Error("Failed to get associated gateways for API key revoke broadcast", "proxyId", proxyID, "keyName", keyName, "error", err) return nil } if len(gateways) == 0 { - s.slogger.Warn("No gateways found for organization; skipping revoke broadcast", "organizationId", orgID) + s.slogger.Info("Proxy not associated with any gateway; skipping revoke broadcast", "proxyId", proxyID, "keyName", keyName) return nil } @@ -152,7 +152,7 @@ func (s *LLMProxyAPIKeyService) DeleteLLMProxyAPIKey( KeyName: keyName, } - for _, gateway := range filterGatewaysByAllowedTargets(gateways, existingKey.AllowedTargets) { + for _, gateway := range filterAPIGatewaysByAllowedTargets(gateways, existingKey.AllowedTargets) { gatewayID := gateway.ID if err := s.gatewayEventsService.BroadcastAPIKeyRevokedEvent(gatewayID, userID, event); err != nil { s.slogger.Error("Failed to broadcast LLM proxy API key revoked event", "proxyId", proxyID, "gatewayId", gatewayID, "keyName", keyName, "error", err) @@ -203,17 +203,15 @@ func (s *LLMProxyAPIKeyService) CreateLLMProxyAPIKey( displayName = name } - gateways, err := s.gatewayRepo.GetByOrganizationID(orgID) + // Broadcast only to gateways the proxy is associated with (not all org gateways), + // mirroring the REST key path. An empty list is valid: the key is still persisted and + // any gateway associated later picks it up via the deploy-time backfill. + gateways, err := s.apiRepo.GetAPIGatewaysWithDetails(proxy.UUID, orgID) if err != nil { s.slogger.Error("Failed to get gateways for API key broadcast", "proxyId", proxyID, "error", err) return nil, fmt.Errorf("failed to get gateways: %w", err) } - if len(gateways) == 0 { - s.slogger.Warn("No gateways found for organization", "organizationId", orgID) - return nil, apperror.GatewayConnectionUnavailable.New() - } - apiKeyHashesJSON, err := buildAPIKeyHashesJSON(apiKey, []string{defaultHashingAlgorithm}) if err != nil { s.slogger.Error("Failed to hash API key for LLM proxy", "proxyId", proxyID, "error", err) @@ -275,7 +273,7 @@ func (s *LLMProxyAPIKeyService) CreateLLMProxyAPIKey( UpdatedAt: dbKey.UpdatedAt.Format(time.RFC3339), } - targetGateways := filterGatewaysByAllowedTargets(gateways, allowedTargets) + targetGateways := filterAPIGatewaysByAllowedTargets(gateways, allowedTargets) successCount := 0 failureCount := 0 var lastError error @@ -298,8 +296,12 @@ func (s *LLMProxyAPIKeyService) CreateLLMProxyAPIKey( s.slogger.Info("LLM proxy API key creation broadcast summary", "proxyId", proxyID, "keyName", name, "total", len(targetGateways), "success", successCount, "failed", failureCount) - if successCount == 0 { - s.slogger.Warn("Failed to deliver LLM proxy API key to any gateway; key is saved to the database", "proxyId", proxyID, "keyName", name, "error", lastError) + if len(targetGateways) == 0 { + // No gateways associated yet — a valid state. The key is persisted centrally and any + // gateway associated later picks it up via the deploy-time backfill. + s.slogger.Info("LLM proxy not associated with any gateway; API key saved and will be delivered at deploy time", "proxyId", proxyID, "keyName", name) + } else if successCount == 0 { + s.slogger.Error("LLM proxy API key created event was not broadcast to any associated gateway", "proxyId", proxyID, "keyName", name, "error", lastError) } return &api.CreateLLMProxyAPIKeyResponse{ diff --git a/platform-api/internal/service/mcp_deployment.go b/platform-api/internal/service/mcp_deployment.go index 4c40dd9e5..b66fecfa0 100644 --- a/platform-api/internal/service/mcp_deployment.go +++ b/platform-api/internal/service/mcp_deployment.go @@ -42,6 +42,7 @@ type MCPDeploymentService struct { deploymentRepo repository.DeploymentRepository gatewayRepo repository.GatewayRepository orgRepo repository.OrganizationRepository + apiKeyRepo repository.APIKeyRepository gatewayEventsService *GatewayEventsService cfg *config.Server utils *utils.MCPUtils @@ -50,13 +51,14 @@ type MCPDeploymentService struct { func NewMCPDeploymentService(mcpRepo repository.MCPProxyRepository, deploymentRepo repository.DeploymentRepository, gatewayRepo repository.GatewayRepository, orgRepo repository.OrganizationRepository, artifactRepo repository.ArtifactRepository, - gatewayEventsService *GatewayEventsService, cfg *config.Server, slogger *slog.Logger) *MCPDeploymentService { + apiKeyRepo repository.APIKeyRepository, gatewayEventsService *GatewayEventsService, cfg *config.Server, slogger *slog.Logger) *MCPDeploymentService { return &MCPDeploymentService{ mcpRepo: mcpRepo, deploymentRepo: deploymentRepo, gatewayRepo: gatewayRepo, orgRepo: orgRepo, artifactRepo: artifactRepo, + apiKeyRepo: apiKeyRepo, gatewayEventsService: gatewayEventsService, cfg: cfg, utils: &utils.MCPUtils{}, @@ -344,6 +346,9 @@ func (s *MCPDeploymentService) deployMCPProxy(proxyUUID string, req *api.DeployR if err := s.gatewayEventsService.BroadcastMCPProxyDeploymentEvent(gatewayID, deploymentEvent); err != nil { s.slogger.Warn("Failed to broadcast MCP proxy deployment event", "error", err) } + + // Push existing active API keys for this proxy to the gateway (see BackfillAPIKeysToGateway). + BackfillAPIKeysToGateway(s.apiKeyRepo, s.gatewayRepo, s.gatewayEventsService, s.slogger, proxyUUID, gatewayID, createdBy) } // Return deployment response @@ -553,6 +558,9 @@ func (s *MCPDeploymentService) restoreMCPProxyDeployment(proxyUUID string, deplo if err := s.gatewayEventsService.BroadcastMCPProxyDeploymentEvent(targetDeployment.GatewayID, deploymentEvent); err != nil { s.slogger.Warn("Failed to broadcast MCP proxy deployment event", "error", err) } + + // Backfill existing active API keys to the gateway (see BackfillAPIKeysToGateway). + BackfillAPIKeysToGateway(s.apiKeyRepo, s.gatewayRepo, s.gatewayEventsService, s.slogger, proxyUUID, targetDeployment.GatewayID, "") } return toAPIDeploymentResponse( diff --git a/platform-api/plugins/eventgateway/plugin.go b/platform-api/plugins/eventgateway/plugin.go index 1c500ea43..fdde3af64 100644 --- a/platform-api/plugins/eventgateway/plugin.go +++ b/platform-api/plugins/eventgateway/plugin.go @@ -154,6 +154,7 @@ func (p *EventGatewayPlugin) Init(deps *plugin.Deps) error { deps.OrgRepo, deps.ArtifactRepo, deps.APIRepo, + deps.APIKeyRepo, deps.GatewayEventsService, cfg, logger, @@ -178,6 +179,7 @@ func (p *EventGatewayPlugin) Init(deps *plugin.Deps) error { deps.OrgRepo, deps.ArtifactRepo, deps.APIRepo, + deps.APIKeyRepo, deps.GatewayEventsService, cfg, logger, diff --git a/platform-api/plugins/eventgateway/service/webbroker_api_deployment.go b/platform-api/plugins/eventgateway/service/webbroker_api_deployment.go index 9f18c499a..c639e98a4 100644 --- a/platform-api/plugins/eventgateway/service/webbroker_api_deployment.go +++ b/platform-api/plugins/eventgateway/service/webbroker_api_deployment.go @@ -46,6 +46,7 @@ type WebBrokerAPIDeploymentService struct { deploymentRepo repository.DeploymentRepository gatewayRepo repository.GatewayRepository orgRepo repository.OrganizationRepository + apiKeyRepo repository.APIKeyRepository gatewayEventsService *coreservice.GatewayEventsService cfg *config.Server slogger *slog.Logger @@ -59,6 +60,7 @@ func NewWebBrokerAPIDeploymentService( orgRepo repository.OrganizationRepository, artifactRepo repository.ArtifactRepository, apiRepo repository.APIRepository, + apiKeyRepo repository.APIKeyRepository, gatewayEventsService *coreservice.GatewayEventsService, cfg *config.Server, slogger *slog.Logger, @@ -70,6 +72,7 @@ func NewWebBrokerAPIDeploymentService( orgRepo: orgRepo, artifactRepo: artifactRepo, apiRepo: apiRepo, + apiKeyRepo: apiKeyRepo, gatewayEventsService: gatewayEventsService, cfg: cfg, slogger: slogger, @@ -261,6 +264,9 @@ func (s *WebBrokerAPIDeploymentService) deployWebBrokerAPI(apiUUID string, req * if err := s.gatewayEventsService.BroadcastWebBrokerAPIDeploymentEvent(gatewayID, deploymentEvent); err != nil { s.slogger.Warn("Failed to broadcast WebBroker API deployment event", "error", err) } + + // Push existing active API keys for this API to the gateway (see BackfillAPIKeysToGateway). + coreservice.BackfillAPIKeysToGateway(s.apiKeyRepo, s.gatewayRepo, s.gatewayEventsService, s.slogger, apiUUID, gatewayID, createdBy) } return toAPIDeploymentResponse( @@ -444,6 +450,9 @@ func (s *WebBrokerAPIDeploymentService) restoreWebBrokerAPIDeployment(apiUUID st if err := s.gatewayEventsService.BroadcastWebBrokerAPIDeploymentEvent(targetDeployment.GatewayID, deploymentEvent); err != nil { s.slogger.Warn("Failed to broadcast WebBroker API deployment event", "error", err) } + + // Backfill existing active API keys to the gateway (see BackfillAPIKeysToGateway). + coreservice.BackfillAPIKeysToGateway(s.apiKeyRepo, s.gatewayRepo, s.gatewayEventsService, s.slogger, apiUUID, targetDeployment.GatewayID, "") } return toAPIDeploymentResponse( diff --git a/platform-api/plugins/eventgateway/service/websub_api_deployment.go b/platform-api/plugins/eventgateway/service/websub_api_deployment.go index 96945ac2e..2f19d846f 100644 --- a/platform-api/plugins/eventgateway/service/websub_api_deployment.go +++ b/platform-api/plugins/eventgateway/service/websub_api_deployment.go @@ -46,6 +46,7 @@ type WebSubAPIDeploymentService struct { deploymentRepo repository.DeploymentRepository gatewayRepo repository.GatewayRepository orgRepo repository.OrganizationRepository + apiKeyRepo repository.APIKeyRepository gatewayEventsService *coreservice.GatewayEventsService cfg *config.Server slogger *slog.Logger @@ -59,6 +60,7 @@ func NewWebSubAPIDeploymentService( orgRepo repository.OrganizationRepository, artifactRepo repository.ArtifactRepository, apiRepo repository.APIRepository, + apiKeyRepo repository.APIKeyRepository, gatewayEventsService *coreservice.GatewayEventsService, cfg *config.Server, slogger *slog.Logger, @@ -70,6 +72,7 @@ func NewWebSubAPIDeploymentService( orgRepo: orgRepo, artifactRepo: artifactRepo, apiRepo: apiRepo, + apiKeyRepo: apiKeyRepo, gatewayEventsService: gatewayEventsService, cfg: cfg, slogger: slogger, @@ -261,6 +264,9 @@ func (s *WebSubAPIDeploymentService) deployWebSubAPI(apiUUID string, req *api.De if err := s.gatewayEventsService.BroadcastWebSubAPIDeploymentEvent(gatewayID, deploymentEvent); err != nil { s.slogger.Warn("Failed to broadcast WebSub API deployment event", "error", err) } + + // Push existing active API keys for this API to the gateway (see BackfillAPIKeysToGateway). + coreservice.BackfillAPIKeysToGateway(s.apiKeyRepo, s.gatewayRepo, s.gatewayEventsService, s.slogger, apiUUID, gatewayID, createdBy) } return toAPIDeploymentResponse( @@ -442,6 +448,9 @@ func (s *WebSubAPIDeploymentService) restoreWebSubAPIDeployment(apiUUID string, if err := s.gatewayEventsService.BroadcastWebSubAPIDeploymentEvent(targetDeployment.GatewayID, deploymentEvent); err != nil { s.slogger.Warn("Failed to broadcast WebSub API deployment event", "error", err) } + + // Backfill existing active API keys to the gateway (see BackfillAPIKeysToGateway). + coreservice.BackfillAPIKeysToGateway(s.apiKeyRepo, s.gatewayRepo, s.gatewayEventsService, s.slogger, apiUUID, targetDeployment.GatewayID, "") } return toAPIDeploymentResponse(