Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions platform-api/internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -294,6 +296,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server,
gatewayRepo,
orgRepo,
artifactRepo,
apiKeyRepo,
gatewayEventsService,
cfg,
slogger,
Expand Down
160 changes: 136 additions & 24 deletions platform-api/internal/service/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Comment thread
DinithHerath marked this conversation as resolved.
"artifactId", artifactUUID, "gatewayId", gatewayID, "error", err)
}
return
Comment thread
DinithHerath marked this conversation as resolved.
}

// 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 {
Comment thread
DinithHerath marked this conversation as resolved.
slogger.Warn("Failed to resolve gateway for API key backfill target filtering",
"gatewayId", gatewayID, "error", gwErr)
}
} else if gw != nil {
gatewayName = gw.Name
}
Comment thread
DinithHerath marked this conversation as resolved.
}

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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
98 changes: 90 additions & 8 deletions platform-api/internal/service/artifact_dp_apikey_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"})
Expand Down Expand Up @@ -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"})
Expand All @@ -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))
}
}
Loading
Loading