Skip to content
Open
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
8 changes: 4 additions & 4 deletions gateway/build-manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ policies:
version: v1.0.1
gomodule: github.com/wso2/gateway-controllers/policies/analytics-header-filter@v1
- name: api-key-auth
version: v1.0.3
version: v1.0.4
gomodule: github.com/wso2/gateway-controllers/policies/api-key-auth@v1
- name: aws-bedrock-guardrail
version: v1.0.2
Expand All @@ -16,7 +16,7 @@ policies:
version: v1.0.2
gomodule: github.com/wso2/gateway-controllers/policies/azure-content-safety-content-moderation@v1
- name: basic-auth
version: v1.0.1
version: v1.0.2
gomodule: github.com/wso2/gateway-controllers/policies/basic-auth@v1
- name: basic-ratelimit
version: v1.0.2
Expand All @@ -43,7 +43,7 @@ policies:
version: v1.0.2
gomodule: github.com/wso2/gateway-controllers/policies/json-xml-mediator@v1
- name: jwt-auth
version: v1.0.4
version: v1.0.5
gomodule: github.com/wso2/gateway-controllers/policies/jwt-auth@v1
- name: llm-cost
version: v1.0.3
Expand Down Expand Up @@ -112,7 +112,7 @@ policies:
version: v1.0.2
gomodule: github.com/wso2/gateway-controllers/policies/sentence-count-guardrail@v1
- name: set-headers
version: v1.0.1
version: v1.1.0
gomodule: github.com/wso2/gateway-controllers/policies/set-headers@v1
- name: subscription-validation
version: v1.0.2
Expand Down
21 changes: 21 additions & 0 deletions gateway/gateway-controller/pkg/controlplane/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ type ControlPlaneClient interface {
GetAPIMConfig() *utils.APIMConfig
}

// secretSyncer is the narrow interface the sync loop needs from the secrets service.
// *secrets.SecretService satisfies this interface.
type secretSyncer interface {
UpsertFromPlatform(handle, displayName, plaintext string) error
}

// Client manages the WebSocket connection to the control plane
type Client struct {
config config.ControlPlaneConfig
Expand Down Expand Up @@ -134,6 +140,8 @@ type Client struct {
gatewayPath string // cached gateway path from well-known discovery
syncOnce sync.Once // ensures deployment sync runs only on first connect
isFirstConnect atomic.Bool // true on first connect, flipped to false after
secretSyncer secretSyncer
secretHashCache sync.Map // handle → last-known Platform API hash (string)
}

// NewClient creates a new control plane client
Expand Down Expand Up @@ -212,6 +220,12 @@ func NewClient(

client.isFirstConnect.Store(true)

// If the secretResolver also satisfies secretSyncer, store it so syncSecrets can
// upsert Platform API-sourced secrets into local encrypted storage.
if ss, ok := secretResolver.(secretSyncer); ok {
client.secretSyncer = ss
}

policyVersionResolver := utils.NewLoadedPolicyVersionResolver(policyDefinitions)
policyValidator := config.NewPolicyValidator(policyDefinitions)
client.llmDeploymentService = utils.NewLLMDeploymentService(
Expand Down Expand Up @@ -439,6 +453,9 @@ func (c *Client) Connect() error {
c.wg.Add(1)
go func(gwID string) {
defer c.wg.Done()
// Sync secrets before deployments so {{ secret "..." }} placeholders
// in API configs resolve correctly during the first render pass.
c.syncSecrets()
c.syncDeployments(gwID)
// Bottom-up sync: push gateway-created APIs to on-prem control plane
if c.IsOnPrem() {
Expand All @@ -465,6 +482,10 @@ func (c *Client) Connect() error {
c.logger.Error("Failed to sync artifacts to on-prem APIM", slog.Any("error", err))
}
}
// Re-sync secrets on reconnect so any rotated or newly added secrets
// are picked up. Hash-based change detection ensures only changed
// secrets trigger a plaintext fetch.
c.syncSecrets()
c.syncSubscriptionPlans(gwID)
c.syncSubscriptionsForExistingAPIs(gwID)
// Sync API keys for LlmProvider, LlmProxy, and RestApi artifacts.
Expand Down
159 changes: 159 additions & 0 deletions gateway/gateway-controller/pkg/controlplane/sync_secrets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/*
* 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 controlplane

import (
"log/slog"
)

// syncSecrets pulls secrets from the Platform API and upserts them into local
// encrypted storage so {{ secret "handle" }} placeholders resolve at render time.
//
// Startup (empty cache): single bulk request with ?includeValues=true — the Platform
// API decrypts all referenced secrets server-side and returns plaintext in one response,
// avoiding N per-secret round trips.
//
// Reconnect (warm cache): metadata-only request, then per-secret /value calls only
// for secrets whose hash has changed since last sync.
func (c *Client) syncSecrets() {
if c.apiUtilsService == nil {
c.logger.Debug("Skipping secret sync: apiUtilsService is nil")
return
}
if c.secretSyncer == nil {
c.logger.Debug("Skipping secret sync: secretSyncer is nil")
return
}

// Determine whether the hash cache is empty (startup / first connect).
cacheEmpty := true
c.secretHashCache.Range(func(_, _ any) bool {
cacheEmpty = false
return false
})

if cacheEmpty {
c.syncSecretsBulk()
} else {
c.syncSecretsIncremental()
}
}

// syncSecretsBulk is used on startup when the local hash cache is empty.
// Fetches all referenced secrets with decrypted values in a single request.
func (c *Client) syncSecretsBulk() {
c.logger.Info("Starting bulk Platform API secret sync (startup)")

metas, err := c.apiUtilsService.FetchPlatformSecrets(nil, true)
if err != nil {
c.logger.Error("Failed to bulk fetch platform secrets", slog.Any("error", err))
return
}

synced, skipped, failed := 0, 0, 0

for _, meta := range metas {
if meta.Status != "ACTIVE" {
skipped++
continue
}

if meta.Value == nil {
c.logger.Warn("Bulk fetch returned no value for secret — skipping",
slog.String("handle", meta.Handle),
)
failed++
continue
}

if err := c.secretSyncer.UpsertFromPlatform(meta.Handle, meta.DisplayName, *meta.Value); err != nil {
c.logger.Error("Failed to upsert secret from platform",
slog.String("handle", meta.Handle),
slog.Any("error", err),
)
failed++
continue
}

c.secretHashCache.Store(meta.Handle, meta.Hash)
synced++
}

c.logger.Info("Bulk Platform API secret sync complete",
slog.Int("synced", synced),
slog.Int("skipped", skipped),
slog.Int("failed", failed),
)
}

// syncSecretsIncremental is used on reconnect when the local hash cache is warm.
// Fetches metadata only, then fetches plaintext only for secrets whose hash changed.
func (c *Client) syncSecretsIncremental() {
c.logger.Info("Starting incremental Platform API secret sync (reconnect)")

metas, err := c.apiUtilsService.FetchPlatformSecrets(nil, false)
if err != nil {
c.logger.Error("Failed to fetch platform secrets metadata", slog.Any("error", err))
return
}

synced, skipped, failed := 0, 0, 0

for _, meta := range metas {
if meta.Status != "ACTIVE" {
skipped++
continue
}

// Skip if hash unchanged since last sync.
if cached, ok := c.secretHashCache.Load(meta.Handle); ok && cached.(string) == meta.Hash {
skipped++
continue
}

plaintext, err := c.apiUtilsService.FetchPlatformSecretValue(meta.ID)
if err != nil {
c.logger.Error("Failed to fetch platform secret value",
slog.String("secret_id", meta.ID),
slog.String("handle", meta.Handle),
slog.Any("error", err),
)
failed++
continue
}

if err := c.secretSyncer.UpsertFromPlatform(meta.Handle, meta.DisplayName, plaintext); err != nil {
c.logger.Error("Failed to upsert secret from platform",
slog.String("handle", meta.Handle),
slog.Any("error", err),
)
failed++
continue
}

c.secretHashCache.Store(meta.Handle, meta.Hash)
synced++
}

c.logger.Info("Incremental Platform API secret sync complete",
slog.Int("synced", synced),
slog.Int("skipped", skipped),
slog.Int("failed", failed),
)
}
32 changes: 32 additions & 0 deletions gateway/gateway-controller/pkg/secrets/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,38 @@ func (s *SecretService) UpdateSecret(handle string, params SecretParams) (*model
return updatedSecret, nil
}

// UpsertFromPlatform stores a secret whose plaintext was fetched from the Platform API.
// If the handle already exists the value is re-encrypted and updated; otherwise a new
// secret is created. This is used by the GW controller sync loop.
func (s *SecretService) UpsertFromPlatform(handle, displayName, plaintext string) error {
payload, err := s.providerManager.Encrypt([]byte(plaintext))
if err != nil {
return fmt.Errorf("encryption failed: %w", err)
}
ciphertext := encryption.MarshalPayload(payload)

secret := &models.Secret{
Handle: handle,
DisplayName: displayName,
Ciphertext: []byte(ciphertext),
}

_, err = s.storage.UpdateSecret(secret)
if err == nil {
return nil
}
if !storage.IsNotFoundError(err) {
return err
}
if err := s.storage.SaveSecret(secret); err != nil {
if storage.IsConflictError(err) {
return nil
}
return err
}
return nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Delete permanently removes a secret
func (s *SecretService) Delete(id string, correlationID string) error {
s.logger.Info("Deleting secret",
Expand Down
Loading
Loading