diff --git a/gateway/gateway-controller/pkg/controlplane/client.go b/gateway/gateway-controller/pkg/controlplane/client.go
index e50480a59d..686157fa0f 100644
--- a/gateway/gateway-controller/pkg/controlplane/client.go
+++ b/gateway/gateway-controller/pkg/controlplane/client.go
@@ -110,6 +110,7 @@ type ControlPlaneClient interface {
// *secrets.SecretService satisfies this interface.
type secretSyncer interface {
UpsertFromPlatform(handle, displayName, plaintext string) error
+ Delete(handle, correlationID string) error
}
// WebhookSecretSnapshotRefresher is the extension point through which an
@@ -158,6 +159,7 @@ type Client struct {
webhookSecretSnapshotManager WebhookSecretSnapshotRefresher
secretSyncer secretSyncer
secretHashCache sync.Map // handle → last-known Platform API hash (string)
+ secretRevisionCache sync.Map // handle → last-applied event Revision (int64); never cleared on evict, so a stale event can't undo a later one
eventGatewayHooks ControlPlaneEventGatewayHooks
// DP->CP push retry tuning.
@@ -1342,9 +1344,19 @@ func (c *Client) handleMessage(messageType int, message []byte) {
return
}
- // Parse as generic event to extract type
+ // Parse as generic event to extract type. UseNumber() keeps JSON numbers as
+ // json.Number (exact decimal text) instead of the default float64 — float64
+ // only has ~53 bits of integer precision, which silently rounds a UnixNano
+ // revision (~60 bits) to the nearest ~256ns at this magnitude. Two events for
+ // the same handle within that window would decode to the identical revision,
+ // letting a stale, reordered event's revision compare as "not older" than the
+ // newer one already applied (see isStaleSecretEvent). utils.MapToStruct's own
+ // marshal/unmarshal re-encodes json.Number as the original digits verbatim, so
+ // this one change is sufficient — no downstream struct/comparison needs to change.
var event map[string]interface{}
- if err := json.Unmarshal(message, &event); err != nil {
+ dec := json.NewDecoder(bytes.NewReader(message))
+ dec.UseNumber()
+ if err := dec.Decode(&event); err != nil {
c.logger.Error("Failed to parse WebSocket message",
slog.Any("error", err),
slog.String("message", string(message)),
@@ -1440,6 +1452,10 @@ func (c *Client) handleMessage(messageType int, message []byte) {
c.dispatchEventGatewayHook(event["type"], func(h ControlPlaneEventGatewayHooks) { h.HandleWebBrokerAPIDeleted(c, event) })
case "application.updated":
c.handleApplicationUpdatedEvent(event)
+ case "secret.updated":
+ c.handleSecretUpdatedEvent(event)
+ case "secret.deleted":
+ c.handleSecretDeletedEvent(event)
default:
c.logger.Info("Received unknown event type (will be processed when handlers are implemented)",
slog.String("type", eventType),
@@ -3745,6 +3761,126 @@ func (c *Client) handleSubscriptionPlanDeletedEvent(event map[string]interface{}
}
}
+// handleSecretUpdatedEvent processes secret.updated events, pushed when a secret is
+// rotated. It re-fetches the plaintext over the authenticated internal secret-value
+// endpoint (the event payload never carries it) and upserts it into local storage, so
+// {{ secret "handle" }} placeholders resolve to the new value immediately instead of
+// waiting for the next reconnect's incremental sync.
+func (c *Client) handleSecretUpdatedEvent(event map[string]interface{}) {
+ baseLogger := c.logger
+ if c.apiUtilsService == nil || c.secretSyncer == nil {
+ baseLogger.Debug("Skipping secret.updated event: secret sync not configured")
+ return
+ }
+
+ var updated SecretUpdatedEvent
+ if err := utils.MapToStruct(event, &updated); err != nil {
+ baseLogger.Error("Failed to parse secret.updated event", slog.Any("error", err))
+ return
+ }
+ payload := updated.Payload
+ if payload.Handle == "" {
+ baseLogger.Error("secret.updated event missing handle")
+ return
+ }
+ logger := baseLogger.With(
+ slog.String("correlation_id", updated.CorrelationID),
+ slog.String("secret_handle", payload.Handle),
+ )
+
+ c.applySecretUpdatedPayload(payload, logger, c.apiUtilsService.FetchPlatformSecretValue)
+}
+
+// applySecretUpdatedPayload is the testable core of handleSecretUpdatedEvent: it
+// fetches the rotated plaintext via fetchValue and upserts it into local storage.
+// Extracted so unit tests can stub fetchValue instead of the concrete
+// *utils.APIUtilsService (mirroring syncSecretsIncrementalFromMetas in
+// sync_secrets_test.go, which works around the same constraint).
+func (c *Client) applySecretUpdatedPayload(payload SecretUpdatedEventPayload, logger *slog.Logger, fetchValue func(handle string) (string, error)) {
+ if c.isStaleSecretEvent(payload.Handle, payload.Revision, logger) {
+ return
+ }
+
+ plaintext, err := fetchValue(payload.Handle)
+ if err != nil {
+ logger.Error("Failed to fetch rotated secret value", slog.Any("error", err))
+ return
+ }
+
+ if err := c.secretSyncer.UpsertFromPlatform(payload.Handle, payload.DisplayName, plaintext); err != nil {
+ logger.Error("Failed to upsert rotated secret", slog.Any("error", err))
+ return
+ }
+
+ c.secretHashCache.Store(payload.Handle, payload.Hash)
+ c.secretRevisionCache.Store(payload.Handle, payload.Revision)
+ logger.Info("Applied secret rotation from secret.updated event")
+}
+
+// isStaleSecretEvent reports whether revision is older than the last revision this
+// gateway already applied for handle. The comparison is strict-less-than so a
+// redelivery of the same event (revision equal to the cached one — e.g. at-least-once
+// retry from the EventHub) still applies normally, preserving existing idempotency.
+// The cache entry is never cleared on eviction (see handleSecretDeletedEvent), so a
+// deletion followed by the same handle being reused by a later create, followed by a
+// stale, redelivered copy of the original deletion, cannot evict the newly created
+// secret: the stale deletion's revision is lower than the new secret's.
+func (c *Client) isStaleSecretEvent(handle string, revision int64, logger *slog.Logger) bool {
+ if cached, ok := c.secretRevisionCache.Load(handle); ok {
+ if lastApplied, ok := cached.(int64); ok && revision < lastApplied {
+ logger.Warn("Ignoring stale secret event",
+ slog.Int64("event_revision", revision),
+ slog.Int64("last_applied_revision", lastApplied),
+ )
+ return true
+ }
+ }
+ return false
+}
+
+// handleSecretDeletedEvent processes secret.deleted events, pushed when a secret is
+// permanently deleted. Deletion only succeeds once no artifact — current config or
+// any deployed snapshot, on any gateway — still references the handle, so evicting
+// the local copy here is always safe.
+func (c *Client) handleSecretDeletedEvent(event map[string]interface{}) {
+ baseLogger := c.logger
+ if c.secretSyncer == nil {
+ baseLogger.Debug("Skipping secret.deleted event: secret sync not configured")
+ return
+ }
+
+ var deleted SecretDeletedEvent
+ if err := utils.MapToStruct(event, &deleted); err != nil {
+ baseLogger.Error("Failed to parse secret.deleted event", slog.Any("error", err))
+ return
+ }
+ payload := deleted.Payload
+ if payload.Handle == "" {
+ baseLogger.Error("secret.deleted event missing handle")
+ return
+ }
+ logger := baseLogger.With(
+ slog.String("correlation_id", deleted.CorrelationID),
+ slog.String("secret_handle", payload.Handle),
+ )
+
+ if c.isStaleSecretEvent(payload.Handle, payload.Revision, logger) {
+ return
+ }
+
+ if err := c.secretSyncer.Delete(payload.Handle, deleted.CorrelationID); err != nil {
+ logger.Warn("Failed to evict deleted secret from local store", slog.Any("error", err))
+ return
+ }
+ c.secretHashCache.Delete(payload.Handle)
+ // Deliberately Store, not Delete: a later secret.updated for the same handle (the
+ // handle reused by a newly created secret) must still be able to detect a
+ // subsequently-redelivered copy of *this* deletion event as stale. Clearing the
+ // cache entry here would let that stale redelivery through and evict the new secret.
+ c.secretRevisionCache.Store(payload.Handle, payload.Revision)
+ logger.Info("Evicted deleted secret from local store")
+}
+
// setState updates the connection state
func (c *Client) setState(newState State) {
c.state.mu.Lock()
diff --git a/gateway/gateway-controller/pkg/controlplane/events.go b/gateway/gateway-controller/pkg/controlplane/events.go
index ecde9ae796..23726eeba8 100644
--- a/gateway/gateway-controller/pkg/controlplane/events.go
+++ b/gateway/gateway-controller/pkg/controlplane/events.go
@@ -399,6 +399,46 @@ type SubscriptionPlanDeletedEvent struct {
CorrelationID string `json:"correlationId"`
}
+// SecretUpdatedEventPayload represents the payload of a secret.updated event, fired
+// when a secret is rotated. It never carries the plaintext value — Hash is the
+// HMAC-SHA256 change-detection digest, safe to transmit since it cannot be reversed
+// into the plaintext. The receiving handler fetches the fresh plaintext separately
+// over the authenticated internal secret-value endpoint.
+type SecretUpdatedEventPayload struct {
+ Handle string `json:"handle"`
+ DisplayName string `json:"name"`
+ Hash string `json:"hash"`
+ // Revision orders events for the same handle so a redelivered or reordered
+ // event cannot undo a change already applied locally. See Client.secretRevisionCache.
+ Revision int64 `json:"revision"`
+}
+
+// SecretUpdatedEvent represents the complete secret.updated event.
+type SecretUpdatedEvent struct {
+ Type string `json:"type"`
+ Payload SecretUpdatedEventPayload `json:"payload"`
+ Timestamp string `json:"timestamp"`
+ CorrelationID string `json:"correlationId"`
+}
+
+// SecretDeletedEventPayload represents the payload of a secret.deleted event,
+// fired when a secret is permanently deleted.
+type SecretDeletedEventPayload struct {
+ Handle string `json:"handle"`
+ // Revision — see SecretUpdatedEventPayload.Revision. Compared against the same
+ // cache so a late deletion cannot evict a secret that was recreated under the
+ // same handle after it.
+ Revision int64 `json:"revision"`
+}
+
+// SecretDeletedEvent represents the complete secret.deleted event.
+type SecretDeletedEvent struct {
+ Type string `json:"type"`
+ Payload SecretDeletedEventPayload `json:"payload"`
+ Timestamp string `json:"timestamp"`
+ CorrelationID string `json:"correlationId"`
+}
+
// ApplicationKeyMappingPayload represents a single application to API key mapping entry.
type ApplicationKeyMappingPayload struct {
ApiKeyUuid string `json:"apiKeyUuid"`
diff --git a/gateway/gateway-controller/pkg/controlplane/sync_secrets.go b/gateway/gateway-controller/pkg/controlplane/sync_secrets.go
index 285f08fc2c..43976309d5 100644
--- a/gateway/gateway-controller/pkg/controlplane/sync_secrets.go
+++ b/gateway/gateway-controller/pkg/controlplane/sync_secrets.go
@@ -20,8 +20,10 @@ package controlplane
import (
"log/slog"
+ "time"
"github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants"
+ "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils"
)
// syncSecrets pulls secrets from the Platform API and upserts them into local
@@ -73,12 +75,14 @@ func (c *Client) syncSecretsBulk() {
}
synced, skipped, failed := 0, 0, 0
+ activeHandles := make(map[string]struct{}, len(metas))
for _, meta := range metas {
if meta.Status != "ACTIVE" {
skipped++
continue
}
+ activeHandles[meta.Handle] = struct{}{}
if meta.Value == nil {
c.logger.Warn("Bulk fetch returned no value for secret — skipping",
@@ -101,10 +105,13 @@ func (c *Client) syncSecretsBulk() {
synced++
}
+ evicted := c.evictSecretsNotIn(activeHandles)
+
c.logger.Info("Bulk Platform API secret sync complete",
slog.Int("synced", synced),
slog.Int("skipped", skipped),
slog.Int("failed", failed),
+ slog.Int("evicted", evicted),
)
}
@@ -120,12 +127,14 @@ func (c *Client) syncSecretsIncremental() {
}
synced, skipped, failed := 0, 0, 0
+ activeHandles := make(map[string]struct{}, len(metas))
for _, meta := range metas {
if meta.Status != "ACTIVE" {
skipped++
continue
}
+ activeHandles[meta.Handle] = struct{}{}
// Skip if hash unchanged since last sync.
if cached, ok := c.secretHashCache.Load(meta.Handle); ok && cached.(string) == meta.Hash {
@@ -156,13 +165,60 @@ func (c *Client) syncSecretsIncremental() {
synced++
}
+ evicted := c.evictSecretsNotIn(activeHandles)
+
c.logger.Info("Incremental Platform API secret sync complete",
slog.Int("synced", synced),
slog.Int("skipped", skipped),
slog.Int("failed", failed),
+ slog.Int("evicted", evicted),
)
}
+// evictSecretsNotIn is the poll-based recovery path for the same eviction that
+// handleSecretDeletedEvent applies live: a gateway that is disconnected at the
+// moment a secret is deleted only receives that secret.deleted WebSocket event if
+// it's connected when the event is broadcast. This diffs the
+// latest Platform API response against secretHashCache so that, on the next
+// reconnect/poll, any cached handle that is no longer ACTIVE (permanently deleted,
+// or flipped to a non-ACTIVE status) gets evicted from local storage even though
+// the live event was missed.
+//
+// activeHandles is the set of handles the just-completed poll returned with
+// status ACTIVE; every other handle currently in secretHashCache is stale.
+func (c *Client) evictSecretsNotIn(activeHandles map[string]struct{}) int {
+ var stale []string
+ c.secretHashCache.Range(func(key, _ any) bool {
+ handle, ok := key.(string)
+ if !ok {
+ return true
+ }
+ if _, ok := activeHandles[handle]; !ok {
+ stale = append(stale, handle)
+ }
+ return true
+ })
+
+ for _, handle := range stale {
+ correlationID := utils.GenerateDeterministicUUIDv7(handle, time.Now())
+ if err := c.secretSyncer.Delete(handle, correlationID); err != nil {
+ c.logger.Warn("Failed to evict stale secret from local store",
+ slog.String("secret_handle", handle),
+ slog.String("correlation_id", correlationID),
+ slog.Any("error", err),
+ )
+ continue
+ }
+ c.secretHashCache.Delete(handle)
+ c.logger.Info("Evicted stale secret from local store during poll sync",
+ slog.String("secret_handle", handle),
+ slog.String("correlation_id", correlationID),
+ )
+ }
+
+ return len(stale)
+}
+
// syncSecretRefsFromYAML extracts {{ secret "handle" }} placeholders from the
// supplied YAML, then fetches and upserts any handle that is not already in the
// local hash cache. This is called from deployment event handlers so that secrets
diff --git a/gateway/gateway-controller/pkg/controlplane/sync_secrets_test.go b/gateway/gateway-controller/pkg/controlplane/sync_secrets_test.go
index 2e787bc2e5..db57bcfc42 100644
--- a/gateway/gateway-controller/pkg/controlplane/sync_secrets_test.go
+++ b/gateway/gateway-controller/pkg/controlplane/sync_secrets_test.go
@@ -19,12 +19,19 @@
package controlplane
import (
+ "encoding/json"
"errors"
+ "fmt"
"log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
"testing"
"time"
+ "github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
"github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils"
)
@@ -39,8 +46,10 @@ import (
// wrapper on the Client for testing.
type mockSecretSyncer struct {
- upserted map[string]string // handle → plaintext
- err error // if non-nil, UpsertFromPlatform returns this
+ upserted map[string]string // handle → plaintext
+ deleted []string // handles passed to Delete, in call order
+ err error // if non-nil, UpsertFromPlatform returns this
+ deleteErr error // if non-nil, Delete returns this
}
func newMockSecretSyncer() *mockSecretSyncer {
@@ -55,6 +64,15 @@ func (m *mockSecretSyncer) UpsertFromPlatform(handle, _, plaintext string) error
return nil
}
+func (m *mockSecretSyncer) Delete(handle, _ string) error {
+ if m.deleteErr != nil {
+ return m.deleteErr
+ }
+ m.deleted = append(m.deleted, handle)
+ delete(m.upserted, handle)
+ return nil
+}
+
// stubClient builds the minimal Client needed for syncSecrets* methods.
// It does NOT call NewClient (which dials a real control-plane), so it is
// purely in-memory.
@@ -503,3 +521,596 @@ func TestSecretHashCache_IsolatedPerHandle(t *testing.T) {
// Stub to make compilation succeed — the real time.Time argument is used by
// syncSecretsIncremental but not needed by our extracted helpers.
var _ = time.Now
+
+// ---------------------------------------------------------------------------
+// secret.updated / secret.deleted push-event handlers
+// ---------------------------------------------------------------------------
+
+func TestApplySecretUpdatedPayload_FetchesAndUpserts_CachesHash(t *testing.T) {
+ syncer := newMockSecretSyncer()
+ c := stubClient(syncer)
+
+ payload := SecretUpdatedEventPayload{Handle: "openai-key", DisplayName: "OpenAI Key", Hash: "hmac-sha256:new"}
+ fetchValue := func(handle string) (string, error) {
+ assert.Equal(t, "openai-key", handle)
+ return "sk-rotated", nil
+ }
+
+ c.applySecretUpdatedPayload(payload, slog.Default(), fetchValue)
+
+ assert.Equal(t, "sk-rotated", syncer.upserted["openai-key"])
+ cached, ok := c.secretHashCache.Load("openai-key")
+ assert.True(t, ok)
+ assert.Equal(t, "hmac-sha256:new", cached)
+}
+
+func TestApplySecretUpdatedPayload_FetchError_DoesNotUpsertOrCacheHash(t *testing.T) {
+ syncer := newMockSecretSyncer()
+ c := stubClient(syncer)
+
+ payload := SecretUpdatedEventPayload{Handle: "openai-key", Hash: "hmac-sha256:new"}
+ fetchValue := func(handle string) (string, error) { return "", errors.New("upstream unreachable") }
+
+ c.applySecretUpdatedPayload(payload, slog.Default(), fetchValue)
+
+ assert.Empty(t, syncer.upserted, "must not upsert when the value fetch fails")
+ _, ok := c.secretHashCache.Load("openai-key")
+ assert.False(t, ok, "must not cache the new hash when the value fetch fails")
+}
+
+func TestApplySecretUpdatedPayload_UpsertError_DoesNotCacheHash(t *testing.T) {
+ syncer := newMockSecretSyncer()
+ syncer.err = errors.New("storage full")
+ c := stubClient(syncer)
+
+ payload := SecretUpdatedEventPayload{Handle: "openai-key", Hash: "hmac-sha256:new"}
+ fetchValue := func(handle string) (string, error) { return "sk-rotated", nil }
+
+ c.applySecretUpdatedPayload(payload, slog.Default(), fetchValue)
+
+ _, ok := c.secretHashCache.Load("openai-key")
+ assert.False(t, ok, "must not cache the new hash when the local upsert fails")
+}
+
+func TestHandleSecretUpdatedEvent_MissingHandle_NoFetchAttempted(t *testing.T) {
+ syncer := newMockSecretSyncer()
+ c := stubClient(syncer)
+ c.apiUtilsService = &utils.APIUtilsService{} // non-nil so the guard under test is the handle check, not this one
+
+ event := map[string]interface{}{
+ "type": "secret.updated",
+ "correlationId": "corr-1",
+ "payload": map[string]interface{}{"handle": "", "hash": "hmac-sha256:x"},
+ }
+
+ assert.NotPanics(t, func() { c.handleSecretUpdatedEvent(event) })
+ assert.Empty(t, syncer.upserted)
+}
+
+func TestHandleSecretUpdatedEvent_NilDependencies_NoPanic(t *testing.T) {
+ c := stubClient(newMockSecretSyncer())
+ // apiUtilsService left nil (zero value of *utils.APIUtilsService)
+
+ event := map[string]interface{}{
+ "type": "secret.updated",
+ "correlationId": "corr-1",
+ "payload": map[string]interface{}{"handle": "openai-key", "hash": "hmac-sha256:x"},
+ }
+
+ assert.NotPanics(t, func() { c.handleSecretUpdatedEvent(event) })
+}
+
+func TestHandleSecretDeletedEvent_EvictsFromLocalStoreAndHashCache(t *testing.T) {
+ syncer := newMockSecretSyncer()
+ syncer.upserted["old-key"] = "sk-stale"
+ c := stubClient(syncer)
+ populateCache(c, map[string]string{"old-key": "hmac-sha256:stale"})
+
+ event := map[string]interface{}{
+ "type": "secret.deleted",
+ "correlationId": "corr-2",
+ "payload": map[string]interface{}{"handle": "old-key"},
+ }
+
+ c.handleSecretDeletedEvent(event)
+
+ assert.Equal(t, []string{"old-key"}, syncer.deleted)
+ assert.NotContains(t, syncer.upserted, "old-key", "local copy must be evicted")
+ _, ok := c.secretHashCache.Load("old-key")
+ assert.False(t, ok, "hash cache entry must be cleared on eviction")
+}
+
+func TestHandleSecretDeletedEvent_MissingHandle_NoEviction(t *testing.T) {
+ syncer := newMockSecretSyncer()
+ c := stubClient(syncer)
+
+ event := map[string]interface{}{
+ "type": "secret.deleted",
+ "correlationId": "corr-2",
+ "payload": map[string]interface{}{"handle": ""},
+ }
+
+ assert.NotPanics(t, func() { c.handleSecretDeletedEvent(event) })
+ assert.Empty(t, syncer.deleted)
+}
+
+func TestHandleSecretDeletedEvent_NilSyncer_NoPanic(t *testing.T) {
+ c := &Client{logger: slog.Default()} // secretSyncer left nil
+
+ event := map[string]interface{}{
+ "type": "secret.deleted",
+ "correlationId": "corr-2",
+ "payload": map[string]interface{}{"handle": "old-key"},
+ }
+
+ assert.NotPanics(t, func() { c.handleSecretDeletedEvent(event) })
+}
+
+func TestHandleSecretDeletedEvent_DeleteError_HashCacheNotCleared(t *testing.T) {
+ syncer := newMockSecretSyncer()
+ syncer.deleteErr = errors.New("storage locked")
+ c := stubClient(syncer)
+ populateCache(c, map[string]string{"old-key": "hmac-sha256:stale"})
+
+ event := map[string]interface{}{
+ "type": "secret.deleted",
+ "correlationId": "corr-2",
+ "payload": map[string]interface{}{"handle": "old-key"},
+ }
+
+ c.handleSecretDeletedEvent(event)
+
+ _, ok := c.secretHashCache.Load("old-key")
+ assert.True(t, ok, "hash cache must be left intact when eviction fails, so a later retry doesn't skip it")
+}
+
+// ---------------------------------------------------------------------------
+// Revision-based staleness rejection (out-of-order / redelivered events)
+// ---------------------------------------------------------------------------
+
+func TestApplySecretUpdatedPayload_StaleRevision_Ignored(t *testing.T) {
+ syncer := newMockSecretSyncer()
+ c := stubClient(syncer)
+ c.secretRevisionCache.Store("openai-key", int64(10))
+
+ payload := SecretUpdatedEventPayload{Handle: "openai-key", Hash: "hmac-sha256:new", Revision: 5}
+ fetchValue := func(handle string) (string, error) {
+ t.Fatal("fetchValue must not be called for a stale event")
+ return "", nil
+ }
+
+ c.applySecretUpdatedPayload(payload, slog.Default(), fetchValue)
+
+ assert.Empty(t, syncer.upserted, "a stale update must not be applied")
+ cached, _ := c.secretRevisionCache.Load("openai-key")
+ assert.Equal(t, int64(10), cached, "the cached revision must not regress")
+}
+
+func TestApplySecretUpdatedPayload_NewerRevision_AppliedAndRevisionAdvanced(t *testing.T) {
+ syncer := newMockSecretSyncer()
+ c := stubClient(syncer)
+ c.secretRevisionCache.Store("openai-key", int64(5))
+
+ payload := SecretUpdatedEventPayload{Handle: "openai-key", Hash: "hmac-sha256:new", Revision: 10}
+ fetchValue := func(handle string) (string, error) { return "sk-rotated", nil }
+
+ c.applySecretUpdatedPayload(payload, slog.Default(), fetchValue)
+
+ assert.Equal(t, "sk-rotated", syncer.upserted["openai-key"])
+ cached, _ := c.secretRevisionCache.Load("openai-key")
+ assert.Equal(t, int64(10), cached)
+}
+
+func TestApplySecretUpdatedPayload_EqualRevision_StillApplied(t *testing.T) {
+ // A redelivery of the exact same event (e.g. an at-least-once retry from the
+ // EventHub) must still be applied — only strictly older revisions are stale.
+ syncer := newMockSecretSyncer()
+ c := stubClient(syncer)
+ c.secretRevisionCache.Store("openai-key", int64(10))
+
+ payload := SecretUpdatedEventPayload{Handle: "openai-key", Hash: "hmac-sha256:new", Revision: 10}
+ fetchValue := func(handle string) (string, error) { return "sk-rotated", nil }
+
+ c.applySecretUpdatedPayload(payload, slog.Default(), fetchValue)
+
+ assert.Equal(t, "sk-rotated", syncer.upserted["openai-key"], "redelivery of the same revision must still apply")
+}
+
+func TestHandleSecretDeletedEvent_StaleRevision_NotEvicted(t *testing.T) {
+ syncer := newMockSecretSyncer()
+ syncer.upserted["old-key"] = "sk-active"
+ c := stubClient(syncer)
+ populateCache(c, map[string]string{"old-key": "hmac-sha256:active"})
+ c.secretRevisionCache.Store("old-key", int64(10))
+
+ event := map[string]interface{}{
+ "type": "secret.deleted",
+ "correlationId": "corr-stale",
+ "payload": map[string]interface{}{"handle": "old-key", "revision": float64(5)},
+ }
+
+ c.handleSecretDeletedEvent(event)
+
+ assert.Empty(t, syncer.deleted, "a stale deletion must not evict")
+ assert.Contains(t, syncer.upserted, "old-key")
+ _, ok := c.secretHashCache.Load("old-key")
+ assert.True(t, ok, "hash cache must be left intact for a stale deletion")
+}
+
+// TestSecretLifecycle_DeleteThenRecreate_StaleDeleteRedelivery_DoesNotEvict covers
+// the exact sequence a reviewer flagged as unprotected: update (rev 1) establishes the
+// secret, delete (rev 2) evicts it, the same handle is reused by a freshly created
+// secret (rev 3), and then the rev-2 deletion is redelivered late (a realistic outcome
+// of common/eventhub's poll-based, at-least-once delivery with no cross-replica
+// ordering guarantee). The redelivered deletion must be rejected as stale rather than
+// evicting the new secret.
+func TestSecretLifecycle_DeleteThenRecreate_StaleDeleteRedelivery_DoesNotEvict(t *testing.T) {
+ syncer := newMockSecretSyncer()
+ c := stubClient(syncer)
+
+ // rev 1: initial update establishes the secret.
+ updatePayload := func(revision int64, value string) SecretUpdatedEventPayload {
+ return SecretUpdatedEventPayload{Handle: "openai-key", DisplayName: "OpenAI Key", Hash: "hmac-sha256:" + value, Revision: revision}
+ }
+ fetchValue := func(value string) func(string) (string, error) {
+ return func(string) (string, error) { return value, nil }
+ }
+
+ c.applySecretUpdatedPayload(updatePayload(1, "v1"), slog.Default(), fetchValue("sk-v1"))
+ assert.Equal(t, "sk-v1", syncer.upserted["openai-key"])
+
+ // rev 2: deletion evicts the secret.
+ deleteEvent := func(revision int) map[string]interface{} {
+ return map[string]interface{}{
+ "type": "secret.deleted",
+ "correlationId": "corr-delete",
+ "payload": map[string]interface{}{"handle": "openai-key", "revision": float64(revision)},
+ }
+ }
+ c.handleSecretDeletedEvent(deleteEvent(2))
+ assert.NotContains(t, syncer.upserted, "openai-key", "secret must be evicted after deletion")
+
+ // rev 3: the handle is reused by a freshly created secret.
+ c.applySecretUpdatedPayload(updatePayload(3, "v3"), slog.Default(), fetchValue("sk-v3"))
+ assert.Equal(t, "sk-v3", syncer.upserted["openai-key"], "the new secret under the reused handle must be applied")
+
+ // The rev-2 deletion is redelivered (late retry / at-least-once redelivery). It
+ // must be recognized as stale relative to rev 3 and must NOT re-evict.
+ c.handleSecretDeletedEvent(deleteEvent(2))
+ assert.Equal(t, "sk-v3", syncer.upserted["openai-key"], "a stale, redelivered deletion must not evict the newly created secret")
+
+ cached, ok := c.secretRevisionCache.Load("openai-key")
+ assert.True(t, ok)
+ assert.Equal(t, int64(3), cached, "cached revision must remain at the new secret's revision")
+}
+
+// ---------------------------------------------------------------------------
+// Revision precision through the real WebSocket JSON decode (handleMessage)
+//
+// These go through client.handleMessage with raw JSON bytes rather than a
+// hand-built map[string]interface{}, because the precision bug they guard
+// against lives specifically in that decode step: handleMessage used to parse
+// the message with plain json.Unmarshal into map[string]interface{}, which
+// decodes JSON numbers as float64. float64 has ~53 bits of integer precision,
+// but a UnixNano revision is ~60 bits, so two revisions within ~256ns of each
+// other at that magnitude decoded to the identical value — verified empirically
+// before the fix. A test built from Go int64 literals instead of raw JSON text
+// would never exercise that decode path and would pass whether or not the bug
+// was present.
+// ---------------------------------------------------------------------------
+
+func deletedMessage(handle string, revision int64) []byte {
+ return []byte(fmt.Sprintf(
+ `{"type":"secret.deleted","correlationId":"corr-precision","payload":{"handle":%q,"revision":%d}}`,
+ handle, revision,
+ ))
+}
+
+func TestHandleMessage_SecretDeleted_AdjacentRevisions_ReorderedStaleRejected(t *testing.T) {
+ syncer := newMockSecretSyncer()
+ syncer.upserted["openai-key"] = "sk-active"
+ c := stubClient(syncer)
+ populateCache(c, map[string]string{"openai-key": "hmac-sha256:active"})
+
+ // Realistic UnixNano magnitude (~10^18). newRevision and staleRevision differ
+ // by 100ns — inside the ~256ns window that collided under the float64 bug.
+ const newRevision int64 = 1735900000123456700
+ const staleRevision int64 = newRevision - 100
+
+ c.handleMessage(websocket.TextMessage, deletedMessage("openai-key", newRevision))
+ assert.Equal(t, []string{"openai-key"}, syncer.deleted, "the newer deletion must evict")
+
+ // The older, adjacent revision arrives late (reordered/redelivered). It must
+ // be rejected as stale, not re-applied.
+ c.handleMessage(websocket.TextMessage, deletedMessage("openai-key", staleRevision))
+ assert.Equal(t, []string{"openai-key"}, syncer.deleted,
+ "a stale reordered deletion adjacent in time to the last-applied one must not evict again")
+
+ cached, ok := c.secretRevisionCache.Load("openai-key")
+ assert.True(t, ok)
+ assert.Equal(t, newRevision, cached, "cached revision must remain exactly the newer value, not a float64-rounded approximation")
+}
+
+func TestHandleMessage_SecretDeleted_EqualRevisionRedelivery_StillApplied(t *testing.T) {
+ syncer := newMockSecretSyncer()
+ syncer.upserted["openai-key"] = "sk-active"
+ c := stubClient(syncer)
+ populateCache(c, map[string]string{"openai-key": "hmac-sha256:active"})
+
+ const revision int64 = 1735900000123456700
+
+ c.handleMessage(websocket.TextMessage, deletedMessage("openai-key", revision))
+ c.handleMessage(websocket.TextMessage, deletedMessage("openai-key", revision))
+
+ assert.Equal(t, []string{"openai-key", "openai-key"}, syncer.deleted,
+ "an exact redelivery of the same revision must still be applied (idempotent), not rejected as stale")
+}
+
+// ---------------------------------------------------------------------------
+// Poll-based eviction (evictSecretsNotIn, and its wiring into
+// syncSecretsBulk / syncSecretsIncremental)
+//
+// A gateway that is disconnected at the moment a secret is deleted only learns
+// about it via the live secret.deleted event if it's connected when the event is
+// broadcast. evictSecretsNotIn is the poll-based recovery path: on the next
+// reconnect/poll it diffs the Platform API response against secretHashCache and
+// evicts anything no longer ACTIVE, using the same secretSyncer.Delete +
+// secretHashCache.Delete calls handleSecretDeletedEvent uses for the live path.
+// ---------------------------------------------------------------------------
+
+func TestEvictSecretsNotIn_HandleMissingFromActiveSet_Evicted(t *testing.T) {
+ syncer := newMockSecretSyncer()
+ c := stubClient(syncer)
+ populateCache(c, map[string]string{"gone-handle": "hmac-sha256:old"})
+
+ evicted := c.evictSecretsNotIn(map[string]struct{}{})
+
+ assert.Equal(t, 1, evicted)
+ assert.Equal(t, []string{"gone-handle"}, syncer.deleted)
+ _, ok := c.secretHashCache.Load("gone-handle")
+ assert.False(t, ok, "evicted handle must be removed from secretHashCache")
+}
+
+func TestEvictSecretsNotIn_HandleFlippedNonActive_Evicted(t *testing.T) {
+ // A handle still present in the Platform API response but no longer ACTIVE
+ // (e.g. DEPRECATED) is represented the same way as a missing handle: it's
+ // simply absent from activeHandles, since the caller only adds ACTIVE handles.
+ syncer := newMockSecretSyncer()
+ c := stubClient(syncer)
+ populateCache(c, map[string]string{"deprecated-handle": "hmac-sha256:x"})
+
+ evicted := c.evictSecretsNotIn(map[string]struct{}{})
+
+ assert.Equal(t, 1, evicted)
+ assert.True(t, syncer.wasDeleted("deprecated-handle"))
+ _, ok := c.secretHashCache.Load("deprecated-handle")
+ assert.False(t, ok)
+}
+
+func TestEvictSecretsNotIn_UnrelatedActiveHandle_LeftAlone(t *testing.T) {
+ syncer := newMockSecretSyncer()
+ c := stubClient(syncer)
+ populateCache(c, map[string]string{"stable-handle": "hmac-sha256:stable"})
+
+ evicted := c.evictSecretsNotIn(map[string]struct{}{"stable-handle": {}})
+
+ assert.Equal(t, 0, evicted)
+ assert.Empty(t, syncer.deleted)
+ cached, ok := c.secretHashCache.Load("stable-handle")
+ require.True(t, ok, "unrelated ACTIVE, unchanged handle must remain cached")
+ assert.Equal(t, "hmac-sha256:stable", cached)
+}
+
+func TestEvictSecretsNotIn_MixedSet(t *testing.T) {
+ syncer := newMockSecretSyncer()
+ c := stubClient(syncer)
+ populateCache(c, map[string]string{
+ "gone-handle": "hmac-sha256:old",
+ "deprecated-handle": "hmac-sha256:x",
+ "stable-handle": "hmac-sha256:stable",
+ })
+
+ evicted := c.evictSecretsNotIn(map[string]struct{}{"stable-handle": {}})
+
+ assert.Equal(t, 2, evicted)
+ assert.True(t, syncer.wasDeleted("gone-handle"))
+ assert.True(t, syncer.wasDeleted("deprecated-handle"))
+ assert.False(t, syncer.wasDeleted("stable-handle"))
+
+ _, stableOk := c.secretHashCache.Load("stable-handle")
+ assert.True(t, stableOk)
+}
+
+func TestEvictSecretsNotIn_DeleteError_HashCacheNotCleared(t *testing.T) {
+ syncer := newMockSecretSyncer()
+ syncer.deleteErr = errors.New("storage locked")
+ c := stubClient(syncer)
+ populateCache(c, map[string]string{"gone-handle": "hmac-sha256:old"})
+
+ evicted := c.evictSecretsNotIn(map[string]struct{}{})
+
+ assert.Equal(t, 1, evicted, "the handle is still counted as stale even though eviction failed")
+ _, ok := c.secretHashCache.Load("gone-handle")
+ assert.True(t, ok, "hash cache must be left intact so a later poll retries the eviction")
+}
+
+// wasDeleted reports whether Delete was called for handle, for readability in
+// the mixed-set assertions above.
+func (m *mockSecretSyncer) wasDeleted(handle string) bool {
+ for _, h := range m.deleted {
+ if h == handle {
+ return true
+ }
+ }
+ return false
+}
+
+// --- End-to-end coverage through the real HTTP-backed sync methods ---
+
+// platformSecretJSON mirrors PlatformSecretMeta's wire shape for building test
+// server responses.
+type platformSecretJSON struct {
+ ID string `json:"uuid"`
+ Handle string `json:"handle"`
+ DisplayName string `json:"name"`
+ Hash string `json:"hash"`
+ Status string `json:"status"`
+ Value *string `json:"value,omitempty"`
+}
+
+// newSecretSyncHTTPClient spins up an httptest TLS server serving
+// GET /api/internal/v1/secrets (and, if valueHandler is non-nil,
+// GET /api/internal/v1/secrets/{handle}/value) and returns a *Client wired to
+// it via a real *utils.APIUtilsService, with a mockSecretSyncer installed.
+func newSecretSyncHTTPClient(t *testing.T, listHandler http.HandlerFunc, valueHandler http.HandlerFunc) (*Client, *mockSecretSyncer) {
+ t.Helper()
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/api/internal/v1/secrets", listHandler)
+ if valueHandler != nil {
+ mux.HandleFunc("/api/internal/v1/secrets/", func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasSuffix(r.URL.Path, "/value") {
+ valueHandler(w, r)
+ return
+ }
+ http.NotFound(w, r)
+ })
+ }
+
+ server := httptest.NewTLSServer(mux)
+ t.Cleanup(server.Close)
+
+ syncer := newMockSecretSyncer()
+ c := stubClient(syncer)
+ c.apiUtilsService = utils.NewAPIUtilsService(utils.PlatformAPIConfig{
+ BaseURL: server.URL + "/api/internal/v1",
+ InsecureSkipVerify: true,
+ }, slog.Default())
+
+ return c, syncer
+}
+
+func writeSecretsList(t *testing.T, w http.ResponseWriter, secrets []platformSecretJSON) {
+ t.Helper()
+ w.Header().Set("Content-Type", "application/json")
+ require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"list": secrets, "count": len(secrets)}))
+}
+
+// TestSyncSecretsIncremental_EndToEnd_EvictsHandleMissingFromResponse proves the
+// real syncSecretsIncremental wiring — not just evictSecretsNotIn in isolation —
+// evicts a handle that was cached ACTIVE previously but has since been
+// permanently deleted (absent from the poll response entirely).
+func TestSyncSecretsIncremental_EndToEnd_EvictsHandleMissingFromResponse(t *testing.T) {
+ c, syncer := newSecretSyncHTTPClient(t, func(w http.ResponseWriter, r *http.Request) {
+ writeSecretsList(t, w, []platformSecretJSON{})
+ }, nil)
+ populateCache(c, map[string]string{"gone-handle": "hmac-sha256:old"})
+
+ c.syncSecretsIncremental()
+
+ assert.True(t, syncer.wasDeleted("gone-handle"))
+ _, ok := c.secretHashCache.Load("gone-handle")
+ assert.False(t, ok)
+}
+
+// TestSyncSecretsIncremental_EndToEnd_EvictsHandleFlippedToDeprecated proves a
+// handle still present in the response but flipped to DEPRECATED is evicted.
+func TestSyncSecretsIncremental_EndToEnd_EvictsHandleFlippedToDeprecated(t *testing.T) {
+ c, syncer := newSecretSyncHTTPClient(t, func(w http.ResponseWriter, r *http.Request) {
+ writeSecretsList(t, w, []platformSecretJSON{
+ {ID: "uuid-1", Handle: "deprecated-handle", Hash: "hmac-sha256:x", Status: "DEPRECATED"},
+ })
+ }, nil)
+ populateCache(c, map[string]string{"deprecated-handle": "hmac-sha256:x"})
+
+ c.syncSecretsIncremental()
+
+ assert.True(t, syncer.wasDeleted("deprecated-handle"))
+ _, ok := c.secretHashCache.Load("deprecated-handle")
+ assert.False(t, ok)
+}
+
+// TestSyncSecretsIncremental_EndToEnd_UnrelatedActiveUnchangedHandleLeftAlone
+// proves a cached handle that's still ACTIVE with an unchanged hash is neither
+// evicted nor re-upserted.
+func TestSyncSecretsIncremental_EndToEnd_UnrelatedActiveUnchangedHandleLeftAlone(t *testing.T) {
+ c, syncer := newSecretSyncHTTPClient(t, func(w http.ResponseWriter, r *http.Request) {
+ writeSecretsList(t, w, []platformSecretJSON{
+ {ID: "uuid-2", Handle: "stable-handle", Hash: "hmac-sha256:stable", Status: "ACTIVE"},
+ })
+ }, nil)
+ populateCache(c, map[string]string{"stable-handle": "hmac-sha256:stable"})
+
+ c.syncSecretsIncremental()
+
+ assert.False(t, syncer.wasDeleted("stable-handle"))
+ assert.NotContains(t, syncer.upserted, "stable-handle", "unchanged hash must be skipped, not re-upserted")
+ cached, ok := c.secretHashCache.Load("stable-handle")
+ require.True(t, ok)
+ assert.Equal(t, "hmac-sha256:stable", cached)
+}
+
+// TestSyncSecretsIncremental_EndToEnd_MixedBatch exercises deletion, deprecation,
+// an untouched ACTIVE handle, and a changed ACTIVE handle together in one poll.
+func TestSyncSecretsIncremental_EndToEnd_MixedBatch(t *testing.T) {
+ c, syncer := newSecretSyncHTTPClient(t,
+ func(w http.ResponseWriter, r *http.Request) {
+ writeSecretsList(t, w, []platformSecretJSON{
+ {ID: "uuid-2", Handle: "stable-handle", Hash: "hmac-sha256:stable", Status: "ACTIVE"},
+ {ID: "uuid-3", Handle: "deprecated-handle", Hash: "hmac-sha256:x", Status: "DEPRECATED"},
+ {ID: "uuid-4", Handle: "changed-handle", Hash: "hmac-sha256:new", Status: "ACTIVE"},
+ })
+ },
+ func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]string{"value": "new-plaintext"})
+ },
+ )
+ populateCache(c, map[string]string{
+ "stable-handle": "hmac-sha256:stable",
+ "deprecated-handle": "hmac-sha256:x",
+ "gone-handle": "hmac-sha256:old", // absent from the response entirely
+ "changed-handle": "hmac-sha256:old",
+ })
+
+ c.syncSecretsIncremental()
+
+ assert.True(t, syncer.wasDeleted("gone-handle"))
+ assert.True(t, syncer.wasDeleted("deprecated-handle"))
+ assert.False(t, syncer.wasDeleted("stable-handle"))
+ assert.False(t, syncer.wasDeleted("changed-handle"))
+
+ _, goneOk := c.secretHashCache.Load("gone-handle")
+ assert.False(t, goneOk)
+ _, depOk := c.secretHashCache.Load("deprecated-handle")
+ assert.False(t, depOk)
+
+ changedCached, ok := c.secretHashCache.Load("changed-handle")
+ require.True(t, ok)
+ assert.Equal(t, "hmac-sha256:new", changedCached)
+ assert.Equal(t, "new-plaintext", syncer.upserted["changed-handle"])
+}
+
+// TestSyncSecretsBulk_EndToEnd_EvictsPreExistingCacheNotInResponse covers the
+// (mostly defensive) eviction path in the bulk/startup sync: any handle already
+// in secretHashCache before the bulk fetch runs that the Platform API no longer
+// returns as ACTIVE gets evicted too, for consistency with the incremental path.
+func TestSyncSecretsBulk_EndToEnd_EvictsPreExistingCacheNotInResponse(t *testing.T) {
+ value := "plaintext-value"
+ c, syncer := newSecretSyncHTTPClient(t, func(w http.ResponseWriter, r *http.Request) {
+ writeSecretsList(t, w, []platformSecretJSON{
+ {ID: "uuid-1", Handle: "active-handle", Hash: "hmac-sha256:1", Status: "ACTIVE", Value: &value},
+ })
+ }, nil)
+ populateCache(c, map[string]string{"stale-handle": "hmac-sha256:old"})
+
+ c.syncSecretsBulk()
+
+ assert.True(t, syncer.wasDeleted("stale-handle"))
+ _, staleOk := c.secretHashCache.Load("stale-handle")
+ assert.False(t, staleOk)
+
+ activeCached, ok := c.secretHashCache.Load("active-handle")
+ require.True(t, ok)
+ assert.Equal(t, "hmac-sha256:1", activeCached)
+}
diff --git a/platform-api/internal/constants/constants.go b/platform-api/internal/constants/constants.go
index 00330f75ae..04a7cd40d0 100644
--- a/platform-api/internal/constants/constants.go
+++ b/platform-api/internal/constants/constants.go
@@ -24,6 +24,23 @@ import "regexp"
// ref-extraction (repository) and ref-validation (service) always match the same set.
var SecretPlaceholderRe = regexp.MustCompile(`\{\{\s*secret\s+\\?"([^"\\]+)\\?"\s*\}\}`)
+// SecretHandlePattern mirrors the AI Workspace UI's client-side slug pattern
+// (CreateSecret.tsx HANDLE_PATTERN) — lowercase letters, digits, and single
+// hyphens, no leading/trailing/doubled hyphens. Enforced server-side so a
+// non-UI caller (curl, future CLI) cannot create a handle that the UI would
+// never generate — in particular, one containing "/" would be permanently
+// unreachable via GET/PUT/DELETE /secrets/{secretId} (the router treats "/"
+// as a path-segment boundary), and one containing quotes or "{{"/"}}" could
+// interfere with SecretPlaceholderRe matching.
+var SecretHandlePattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
+
+// SecretHandleMaxLength matches the `handle VARCHAR(40)` column in the secrets
+// table. SQLite does not enforce
+// VARCHAR length limits, so this must be checked in application code — without
+// it, a handle exceeding this length is silently accepted on SQLite but would
+// raise a raw, unfriendly DB constraint error on PostgreSQL.
+const SecretHandleMaxLength = 40
+
// ValidLifecycleStates Valid lifecycle states
var ValidLifecycleStates = map[string]bool{
"STAGED": true,
diff --git a/platform-api/internal/dto/api.go b/platform-api/internal/dto/api.go
index 77f56366d3..ccea01bf28 100644
--- a/platform-api/internal/dto/api.go
+++ b/platform-api/internal/dto/api.go
@@ -21,6 +21,7 @@ import (
"time"
"github.com/wso2/api-platform/platform-api/api"
+ "github.com/wso2/api-platform/platform-api/internal/model"
)
// API represents an API entity in the platform
@@ -156,8 +157,9 @@ type UpstreamYAML struct {
// UpstreamTarget represents a single upstream target (url or ref)
type UpstreamTarget struct {
- URL string `yaml:"url,omitempty"`
- Ref string `yaml:"ref,omitempty"`
+ URL string `yaml:"url,omitempty"`
+ Ref string `yaml:"ref,omitempty"`
+ Auth *model.UpstreamAuth `yaml:"auth,omitempty"`
}
// APIListResponse represents a paginated list of APIs (constitution-compliant)
@@ -166,4 +168,3 @@ type APIListResponse struct {
List []*API `json:"list" yaml:"list"` // Array of API objects
Pagination Pagination `json:"pagination" yaml:"pagination"` // Pagination metadata
}
-
diff --git a/platform-api/internal/dto/secret.go b/platform-api/internal/dto/secret.go
index 68fbd99117..01fc0b04ec 100644
--- a/platform-api/internal/dto/secret.go
+++ b/platform-api/internal/dto/secret.go
@@ -31,10 +31,12 @@ type CreateSecretRequest struct {
// UpdateSecretRequest is the request body for PUT /api/v0.9/secrets/{secretId}.
// Accepts multipart/form-data to support file-based secret values in future.
+// Value is optional: an empty Value updates only DisplayName/Description without
+// rotating the underlying credential or reactivating a deprecated secret.
type UpdateSecretRequest struct {
DisplayName string `form:"displayName"`
Description string `form:"description"`
- Value string `form:"value" binding:"required"`
+ Value string `form:"value"`
}
// SecretResponse is returned on POST and PUT.
@@ -102,3 +104,9 @@ type SecretReferenceDTO struct {
type SecretInUseDetails struct {
References []SecretReferenceDTO `json:"references"`
}
+
+// SecretUsagesResponse is returned by GET /api/v0.9/secrets/{secretId}/usages —
+// the resources that currently reference the secret.
+type SecretUsagesResponse struct {
+ References []SecretReferenceDTO `json:"references"`
+}
diff --git a/platform-api/internal/handler/secret.go b/platform-api/internal/handler/secret.go
index 12cd9ab70a..c7624d7e42 100644
--- a/platform-api/internal/handler/secret.go
+++ b/platform-api/internal/handler/secret.go
@@ -46,6 +46,7 @@ func (h *SecretHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST "+constants.APIBasePath+"/secrets", middleware.MapErrors(h.slogger, h.CreateSecret))
mux.HandleFunc("GET "+constants.APIBasePath+"/secrets", middleware.MapErrors(h.slogger, h.ListSecrets))
mux.HandleFunc("GET "+constants.APIBasePath+"/secrets/{secretId}", middleware.MapErrors(h.slogger, h.GetSecret))
+ mux.HandleFunc("GET "+constants.APIBasePath+"/secrets/{secretId}/usages", middleware.MapErrors(h.slogger, h.GetSecretUsages))
mux.HandleFunc("PUT "+constants.APIBasePath+"/secrets/{secretId}", middleware.MapErrors(h.slogger, h.UpdateSecret))
mux.HandleFunc("DELETE "+constants.APIBasePath+"/secrets/{secretId}", middleware.MapErrors(h.slogger, h.DeleteSecret))
}
@@ -139,6 +140,27 @@ func (h *SecretHandler) GetSecret(w http.ResponseWriter, r *http.Request) error
return nil
}
+func (h *SecretHandler) GetSecretUsages(w http.ResponseWriter, r *http.Request) error {
+ orgID, ok := middleware.GetOrganizationFromRequest(r)
+ if !ok {
+ return apperror.Unauthorized.New().
+ WithLogMessage("organization claim not found in token")
+ }
+
+ handle := r.PathValue("secretId")
+ if handle == "" {
+ return apperror.ValidationFailed.New("Secret name is required")
+ }
+
+ refs, err := h.secretService.GetReferences(orgID, handle)
+ if err != nil {
+ return serviceError(err, "failed to get secret usages")
+ }
+
+ httputil.WriteJSON(w, http.StatusOK, dto.SecretUsagesResponse{References: refs})
+ return nil
+}
+
func (h *SecretHandler) UpdateSecret(w http.ResponseWriter, r *http.Request) error {
orgID, ok := middleware.GetOrganizationFromRequest(r)
if !ok {
@@ -164,9 +186,6 @@ func (h *SecretHandler) UpdateSecret(w http.ResponseWriter, r *http.Request) err
Description: r.FormValue("description"),
Value: r.FormValue("value"),
}
- if req.Value == "" {
- return apperror.ValidationFailed.New("value is required")
- }
resp, err := h.secretService.Update(orgID, handle, userID, &req)
if err != nil {
diff --git a/platform-api/internal/handler/secret_integration_test.go b/platform-api/internal/handler/secret_integration_test.go
index 8bed140fec..5ae2e9aea7 100644
--- a/platform-api/internal/handler/secret_integration_test.go
+++ b/platform-api/internal/handler/secret_integration_test.go
@@ -21,6 +21,7 @@ import (
"bytes"
"database/sql"
"encoding/json"
+ "errors"
"fmt"
"log/slog"
"mime/multipart"
@@ -355,7 +356,11 @@ func TestSecretHandler_Update_200(t *testing.T) {
}
// TC-IT-10b: PUT on a DEPRECATED secret reactivates it (status → ACTIVE) and re-encrypts the value.
-func TestSecretHandler_Update_ReactivatesDeprecatedSecret(t *testing.T) {
+// A deleted-and-unreferenced secret is now permanently removed (see
+// TestSecretHandler_Delete_HardDeletesRow), so there is no longer a row left to
+// reactivate via rotation: a PUT against a deleted handle 404s, and a fresh POST
+// with the same handle is free to create a brand-new secret.
+func TestSecretHandler_Update_DeletedHandleNotFound(t *testing.T) {
tmpDir := t.TempDir()
sqlDB, err := sql.Open("sqlite3", filepath.Join(tmpDir, "test-reactivate.db"))
if err != nil {
@@ -379,7 +384,7 @@ func TestSecretHandler_Update_ReactivatesDeprecatedSecret(t *testing.T) {
NewSecretHandler(svc, identityService, slog.Default()).RegisterRoutes(mux)
r := middleware.NewTestContextMiddleware(mux)
- // Create then soft-delete (deprecate) the secret.
+ // Create then delete the secret.
body, ct := multipartForm(map[string]string{"id": "react-key", "displayName": "React Key", "value": "old-val"})
req, _ := http.NewRequest(http.MethodPost, "/api/v0.9/secrets", body)
req.Header.Set("Content-Type", ct)
@@ -400,14 +405,14 @@ func TestSecretHandler_Update_ReactivatesDeprecatedSecret(t *testing.T) {
t.Fatalf("delete: expected 204, got %d", wDel.Code)
}
- // Confirm it is DEPRECATED before rotation.
- var statusBefore string
- sqlDB.QueryRow(`SELECT status FROM secrets WHERE handle = 'react-key'`).Scan(&statusBefore)
- if statusBefore != "DEPRECATED" {
- t.Fatalf("expected DEPRECATED before rotation, got %s", statusBefore)
+ // Confirm the row is gone entirely.
+ var count int
+ sqlDB.QueryRow(`SELECT COUNT(*) FROM secrets WHERE handle = 'react-key'`).Scan(&count)
+ if count != 0 {
+ t.Fatalf("expected row to be gone after delete, found %d", count)
}
- // Rotate — PUT should reactivate.
+ // Rotate — PUT against the now-nonexistent handle must 404.
putBody, putCT := multipartForm(map[string]string{"value": "new-val"})
putReq, _ := http.NewRequest(http.MethodPut, "/api/v0.9/secrets/react-key", putBody)
putReq.Header.Set("Content-Type", putCT)
@@ -415,23 +420,28 @@ func TestSecretHandler_Update_ReactivatesDeprecatedSecret(t *testing.T) {
putReq.Header.Set("X-Test-User", "alice")
wPut := httptest.NewRecorder()
r.ServeHTTP(wPut, putReq)
- if wPut.Code != http.StatusOK {
- t.Fatalf("rotate: expected 200, got %d: %s", wPut.Code, wPut.Body.String())
+ if wPut.Code != http.StatusNotFound {
+ t.Fatalf("rotate: expected 404, got %d: %s", wPut.Code, wPut.Body.String())
}
- // Status must be ACTIVE and value must decrypt to the new plaintext.
- var statusAfter string
- sqlDB.QueryRow(`SELECT status FROM secrets WHERE handle = 'react-key'`).Scan(&statusAfter)
- if statusAfter != "ACTIVE" {
- t.Errorf("expected ACTIVE after rotation, got %s", statusAfter)
+ // A fresh create with the same handle must succeed — the handle is fully free.
+ recreateBody, recreateCT := multipartForm(map[string]string{"id": "react-key", "displayName": "React Key", "value": "brand-new-val"})
+ recreateReq, _ := http.NewRequest(http.MethodPost, "/api/v0.9/secrets", recreateBody)
+ recreateReq.Header.Set("Content-Type", recreateCT)
+ recreateReq.Header.Set("X-Test-Org", "org-react-it")
+ recreateReq.Header.Set("X-Test-User", "alice")
+ wRecreate := httptest.NewRecorder()
+ r.ServeHTTP(wRecreate, recreateReq)
+ if wRecreate.Code != http.StatusCreated {
+ t.Fatalf("recreate: expected 201, got %d: %s", wRecreate.Code, wRecreate.Body.String())
}
plaintext, err := svc.Decrypt("org-react-it", "react-key")
if err != nil {
- t.Fatalf("Decrypt after reactivation: %v", err)
+ t.Fatalf("Decrypt after recreate: %v", err)
}
- if plaintext != "new-val" {
- t.Errorf("expected plaintext=new-val, got %s", plaintext)
+ if plaintext != "brand-new-val" {
+ t.Errorf("expected plaintext=brand-new-val, got %s", plaintext)
}
}
@@ -643,11 +653,10 @@ func TestSecretHandler_Create_ValueNotInListResponse(t *testing.T) {
}
}
-// TC-60: DELETE soft-deletes — status becomes DEPRECATED, physical row is retained.
-// Verified by: (a) 204 on first delete, (b) Exists() returns false (ACTIVE filter),
-// (c) the DB row still exists with status=DEPRECATED (checked via direct SQL),
-// (d) Decrypt returns "secret is deprecated" — not "not found".
-func TestSecretHandler_Delete_SoftDeletesRow(t *testing.T) {
+// TC-60: DELETE permanently removes an unreferenced secret. Verified by:
+// (a) 204 on first delete, (b) Exists() returns false, (c) the DB row is gone
+// entirely (checked via direct SQL), (d) Decrypt returns "not found".
+func TestSecretHandler_Delete_HardDeletesRow(t *testing.T) {
// Build isolated stack so we can access the DB and service directly.
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test-sd.db")
@@ -696,40 +705,37 @@ func TestSecretHandler_Delete_SoftDeletesRow(t *testing.T) {
t.Fatalf("delete: expected 204, got %d", wDel.Code)
}
- // (b) Exists() returns false — secret no longer ACTIVE
+ // (b) Exists() returns false — secret is gone
exists, err := repo.Exists("org-sd-it", "soft-del-key")
if err != nil {
t.Fatalf("Exists: %v", err)
}
if exists {
- t.Error("Exists should return false after soft-delete")
+ t.Error("Exists should return false after delete")
}
- // (c) Physical row still present with status=DEPRECATED
+ // (c) Physical row is gone entirely
var status string
err = sqlDB.QueryRow(
`SELECT status FROM secrets WHERE organization_uuid = ? AND handle = ?`,
"org-sd-it", "soft-del-key",
).Scan(&status)
- if err != nil {
- t.Fatalf("DB row missing after soft-delete: %v", err)
- }
- if status != "DEPRECATED" {
- t.Errorf("expected status DEPRECATED, got %q", status)
+ if !errors.Is(err, sql.ErrNoRows) {
+ t.Fatalf("expected row to be gone after delete, got err=%v status=%q", err, status)
}
- // (d) Decrypt returns "secret is deprecated", not "not found"
+ // (d) Decrypt returns "not found", not a value
_, decryptErr := svc.Decrypt("org-sd-it", "soft-del-key")
- if decryptErr == nil {
- t.Fatal("expected error decrypting DEPRECATED secret")
- }
- if decryptErr.Error() != "secret is deprecated" {
- t.Errorf("expected 'secret is deprecated', got %q", decryptErr.Error())
+ if !apperror.SecretNotFound.Is(decryptErr) {
+ t.Errorf("expected SecretNotFound, got %v", decryptErr)
}
}
// TC-61: DEPRECATED secret — Decrypt returns error (platform API contract the GW
-// controller relies on to skip /value calls for DEPRECATED items).
+// controller relies on to skip /value calls for DEPRECATED items). A DEPRECATED
+// row is no longer reachable via the Delete flow (which now hard-deletes an
+// unreferenced secret) — this seeds one directly via SQL to exercise Decrypt's
+// defensive status check on its own.
func TestSecretService_Decrypt_DeprecatedSecretReturnsError(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test-dep.db")
@@ -750,7 +756,6 @@ func TestSecretService_Decrypt_DeprecatedSecretReturnsError(t *testing.T) {
repo := repository.NewSecretRepo(db)
svc := service.NewSecretService(repo, v, service.NewIdentityService(repository.NewUserIdentityMappingRepo(db)))
- // Create and then soft-delete a secret
_, err = svc.Create("org-dep-it", "alice", &dto.CreateSecretRequest{
Handle: "dep-secret",
Value: "plaintext",
@@ -758,8 +763,8 @@ func TestSecretService_Decrypt_DeprecatedSecretReturnsError(t *testing.T) {
if err != nil {
t.Fatalf("create: %v", err)
}
- if err = svc.Delete("org-dep-it", "dep-secret", "alice"); err != nil {
- t.Fatalf("delete: %v", err)
+ if _, err := sqlDB.Exec(`UPDATE secrets SET status = 'DEPRECATED' WHERE handle = 'dep-secret'`); err != nil {
+ t.Fatalf("deprecate: %v", err)
}
// Decrypt must return an error for DEPRECATED secret — not the plaintext
diff --git a/platform-api/internal/model/secret.go b/platform-api/internal/model/secret.go
index 77eb601ed6..c847fefae8 100644
--- a/platform-api/internal/model/secret.go
+++ b/platform-api/internal/model/secret.go
@@ -68,3 +68,38 @@ type SecretReference struct {
Handle string `json:"handle"`
Name string `json:"name"`
}
+
+// SecretUpdatedEvent is broadcast to every gateway in the organization when a secret's
+// value is rotated. Hash is the HMAC-SHA256 change-detection digest (see hashSecret) —
+// safe to broadcast, since it never permits recovering the plaintext value. The
+// plaintext itself is deliberately never part of this payload: the EventHub persists
+// events to the shared DB, so gateways instead pull the fresh value over the
+// authenticated internal secret-value endpoint once they receive this notification.
+//
+// Revision is UnixNano() of the secret's updated_at at the moment this event was
+// raised (set by SecretService.Update/Delete from the same value just committed to
+// the DB). It is not a true per-row incrementing counter — that would need a schema
+// migration across three DB engines — but time.Now() on a single writer process is
+// monotonically non-decreasing across successive calls, which is all a gateway needs
+// to detect an event that arrived out of order relative to one it already applied for
+// the same handle. See Client.secretRevisionCache in gateway-controller.
+type SecretUpdatedEvent struct {
+ Handle string `json:"handle"`
+ DisplayName string `json:"name"`
+ Hash string `json:"hash"`
+ Revision int64 `json:"revision"`
+}
+
+// SecretDeletedEvent is broadcast to every gateway in the organization when a
+// secret is permanently deleted. Deletion only succeeds once no artifact — current
+// config or any deployed snapshot, on any gateway — references the handle, so every
+// gateway can safely evict its local copy on receipt.
+//
+// Revision — see SecretUpdatedEvent.Revision. A gateway must keep comparing against
+// it even after evicting the secret locally, so a deletion event that arrives late
+// (after the same handle has already been reused by a subsequent create) is not
+// applied and does not evict the newly created secret.
+type SecretDeletedEvent struct {
+ Handle string `json:"handle"`
+ Revision int64 `json:"revision"`
+}
diff --git a/platform-api/internal/repository/api.go b/platform-api/internal/repository/api.go
index 115179d061..02f15564cb 100644
--- a/platform-api/internal/repository/api.go
+++ b/platform-api/internal/repository/api.go
@@ -441,18 +441,25 @@ func (r *APIRepo) UpdateAPI(api *model.API) error {
return tx.Commit()
}
-// DeleteAPI removes an API and all its configurations
-func (r *APIRepo) DeleteAPI(apiUUID, orgUUID string) error {
+// DeleteAPI removes an API and all its configurations, returning the secret handles
+// it referenced (current config and any deployed snapshots) so the caller can check
+// each for orphan cleanup now that this artifact no longer holds a reference to them.
+func (r *APIRepo) DeleteAPI(apiUUID, orgUUID string) ([]string, error) {
// Start transaction for atomicity
tx, err := r.db.Begin()
if err != nil {
- return err
+ return nil, err
}
defer tx.Rollback()
+ secretHandles, err := secretHandlesForArtifact(tx, r.db, apiUUID)
+ if err != nil {
+ return nil, err
+ }
+
// Delete gateway associations
if _, err := tx.Exec(r.db.Rebind(`DELETE FROM artifact_gateway_mappings WHERE artifact_uuid = ? AND organization_uuid = ?`), apiUUID, orgUUID); err != nil {
- return err
+ return nil, err
}
// Delete in order of dependencies (children first, parent last)
@@ -468,21 +475,24 @@ func (r *APIRepo) DeleteAPI(apiUUID, orgUUID string) error {
switch i {
case 0:
if _, err := tx.Exec(r.db.Rebind(query), apiUUID, orgUUID); err != nil {
- return err
+ return nil, err
}
default:
if _, err := tx.Exec(r.db.Rebind(query), apiUUID); err != nil {
- return err
+ return nil, err
}
}
}
// Delete from artifacts table using artifactRepo
if err := r.artifactRepo.Delete(tx, apiUUID); err != nil {
- return err
+ return nil, err
}
- return tx.Commit()
+ if err := tx.Commit(); err != nil {
+ return nil, err
+ }
+ return secretHandles, nil
}
// CheckAPIExistsByHandleInOrganization checks if an API with the given handle exists within a specific organization
diff --git a/platform-api/internal/repository/api_test.go b/platform-api/internal/repository/api_test.go
index 2aba1fa85b..86a4188c79 100644
--- a/platform-api/internal/repository/api_test.go
+++ b/platform-api/internal/repository/api_test.go
@@ -86,7 +86,7 @@ func TestAPIRepo_CreateAndRead(t *testing.T) {
t.Fatalf("CreateAPI failed: %v", err)
}
defer func() {
- if err := repo.DeleteAPI(api.ID, orgUUID); err != nil {
+ if _, err := repo.DeleteAPI(api.ID, orgUUID); err != nil {
t.Errorf("DeleteAPI cleanup failed: %v", err)
}
}()
@@ -152,7 +152,7 @@ func TestAPIRepo_CreateAPI_SetsUpdatedBy(t *testing.T) {
t.Fatalf("CreateAPI failed: %v", err)
}
defer func() {
- if err := repo.DeleteAPI(api.ID, orgUUID); err != nil {
+ if _, err := repo.DeleteAPI(api.ID, orgUUID); err != nil {
t.Errorf("DeleteAPI cleanup failed: %v", err)
}
}()
@@ -282,7 +282,7 @@ func TestAPIRepo_Update(t *testing.T) {
t.Fatalf("CreateAPI failed: %v", err)
}
defer func() {
- if err := repo.DeleteAPI(api.ID, orgUUID); err != nil {
+ if _, err := repo.DeleteAPI(api.ID, orgUUID); err != nil {
t.Errorf("DeleteAPI cleanup failed: %v", err)
}
}()
@@ -353,7 +353,7 @@ func TestAPIRepo_Delete(t *testing.T) {
t.Fatalf("CreateAPI failed: %v", err)
}
- if err := repo.DeleteAPI(api.ID, orgUUID); err != nil {
+ if _, err := repo.DeleteAPI(api.ID, orgUUID); err != nil {
t.Fatalf("DeleteAPI failed: %v", err)
}
@@ -434,7 +434,7 @@ func TestAPIRepo_DeleteAPIRemovesCustomPolicyUsagesWithoutForeignKeys(t *testing
t.Fatalf("DeleteCustomPolicyIfUnused() before API delete = nil error, want PolicyInUse")
}
- if err := repo.DeleteAPI(api.ID, orgUUID); err != nil {
+ if _, err := repo.DeleteAPI(api.ID, orgUUID); err != nil {
t.Fatalf("DeleteAPI failed: %v", err)
}
@@ -481,7 +481,7 @@ func TestAPIRepo_CheckAPIExistsByNameAndVersionInOrganization(t *testing.T) {
t.Fatalf("CreateAPI failed: %v", err)
}
defer func() {
- if err := repo.DeleteAPI(api.ID, orgUUID); err != nil {
+ if _, err := repo.DeleteAPI(api.ID, orgUUID); err != nil {
t.Errorf("DeleteAPI cleanup failed: %v", err)
}
}()
@@ -541,7 +541,7 @@ func TestAPIRepo_CheckAPIExistsByHandleInOrganization(t *testing.T) {
t.Fatalf("CreateAPI failed: %v", err)
}
defer func() {
- if err := repo.DeleteAPI(api.ID, orgUUID); err != nil {
+ if _, err := repo.DeleteAPI(api.ID, orgUUID); err != nil {
t.Errorf("DeleteAPI cleanup failed: %v", err)
}
}()
@@ -601,7 +601,7 @@ func TestAPIRepo_CreateSetsArtifactKind(t *testing.T) {
t.Fatalf("CreateAPI failed: %v", err)
}
t.Cleanup(func() {
- if err := repo.DeleteAPI(api.ID, orgUUID); err != nil {
+ if _, err := repo.DeleteAPI(api.ID, orgUUID); err != nil {
t.Errorf("DeleteAPI cleanup failed: %v", err)
}
})
@@ -697,7 +697,7 @@ func TestAPIRepo_CreateAndRead_FullConfiguration(t *testing.T) {
t.Fatalf("CreateAPI failed: %v", err)
}
defer func() {
- if err := repo.DeleteAPI(api.ID, orgUUID); err != nil {
+ if _, err := repo.DeleteAPI(api.ID, orgUUID); err != nil {
t.Errorf("DeleteAPI cleanup failed: %v", err)
}
}()
diff --git a/platform-api/internal/repository/artifact.go b/platform-api/internal/repository/artifact.go
index f4b06e4bb3..d736026583 100644
--- a/platform-api/internal/repository/artifact.go
+++ b/platform-api/internal/repository/artifact.go
@@ -66,6 +66,9 @@ func (r *ArtifactRepo) Delete(tx *sql.Tx, uuid string) error {
if err := deleteCustomPolicyUsagesTx(tx, r.db, uuid); err != nil {
return err
}
+ if err := deleteArtifactSecretRefs(tx, r.db, uuid); err != nil {
+ return err
+ }
query := `DELETE FROM artifacts WHERE uuid = ?`
result, err := tx.Exec(r.db.Rebind(query), uuid)
if err != nil {
diff --git a/platform-api/internal/repository/artifact_refs.go b/platform-api/internal/repository/artifact_refs.go
index 536eedf20a..1a740b72e1 100644
--- a/platform-api/internal/repository/artifact_refs.go
+++ b/platform-api/internal/repository/artifact_refs.go
@@ -86,3 +86,36 @@ func upsertDeploymentSecretRefs(tx *sql.Tx, db *database.DB, orgID, artifactUUID
}
return nil
}
+
+// secretHandlesForArtifact returns every distinct secret handle referenced by
+// artifactUUID — both its current-config ref (gateway_id='') and any deployed-gateway
+// refs — so a caller can check each for orphan cleanup after the artifact is deleted.
+func secretHandlesForArtifact(tx *sql.Tx, db *database.DB, artifactUUID string) ([]string, error) {
+ rows, err := tx.Query(db.Rebind(`
+ SELECT DISTINCT secret_handle FROM artifact_secret_refs WHERE artifact_uuid = ?
+ `), artifactUUID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var handles []string
+ for rows.Next() {
+ var handle string
+ if err := rows.Scan(&handle); err != nil {
+ return nil, err
+ }
+ handles = append(handles, handle)
+ }
+ return handles, rows.Err()
+}
+
+// deleteArtifactSecretRefs removes every artifact_secret_refs row for artifactUUID.
+// Explicit delete, not relying on the FK cascade — SQL Server declares this table's
+// artifact_uuid FK as ON DELETE NO ACTION (unlike Postgres/SQLite's ON DELETE CASCADE),
+// so without this the artifact delete would fail there once any ref rows exist. Mirrors
+// ArtifactRepo.Delete's existing deleteCustomPolicyUsagesTx call for the same reason.
+func deleteArtifactSecretRefs(tx *sql.Tx, db *database.DB, artifactUUID string) error {
+ _, err := tx.Exec(db.Rebind(`DELETE FROM artifact_secret_refs WHERE artifact_uuid = ?`), artifactUUID)
+ return err
+}
diff --git a/platform-api/internal/repository/interfaces.go b/platform-api/internal/repository/interfaces.go
index 13354e371d..f474722952 100644
--- a/platform-api/internal/repository/interfaces.go
+++ b/platform-api/internal/repository/interfaces.go
@@ -111,7 +111,10 @@ type APIRepository interface {
CountAPIsByOrganizationUUID(orgUUID, projectUUID, search string) (int, error)
GetAPIsByGatewayUUID(gatewayUUID, orgUUID string) ([]*model.API, error)
UpdateAPI(api *model.API) error
- DeleteAPI(apiUUID, orgUUID string) error
+ // DeleteAPI removes the API and returns the secret handles it referenced, so the
+ // caller can check each for orphan cleanup now that this artifact no longer holds
+ // a reference to them.
+ DeleteAPI(apiUUID, orgUUID string) ([]string, error)
// API-Gateway association methods
GetAPIGatewaysWithDetails(apiUUID, orgUUID string) ([]*model.APIGatewayWithDetails, error)
@@ -254,7 +257,10 @@ type LLMProviderRepository interface {
Count(orgUUID string) (int, error)
Update(p *model.LLMProvider) error
UpdateWithCustomPolicyUsages(p *model.LLMProvider, policyUUIDs []string) error
- Delete(providerID, orgUUID string) error
+ // Delete removes the provider and returns the secret handles it referenced, so the
+ // caller can check each for orphan cleanup now that this artifact no longer holds
+ // a reference to them.
+ Delete(providerID, orgUUID string) ([]string, error)
Exists(providerID, orgUUID string) (bool, error)
// EnsureGatewayAssociation creates a gateway association for the provider if one
// does not already exist and resolves the metadata to use for the deployment.
@@ -287,7 +293,10 @@ type LLMProxyRepository interface {
CountByProject(orgUUID, projectUUID string) (int, error)
CountByProvider(orgUUID, providerID string) (int, error)
Update(p *model.LLMProxy) error
- Delete(proxyID, orgUUID string) error
+ // Delete removes the proxy and returns the secret handles it referenced, so the
+ // caller can check each for orphan cleanup now that this artifact no longer holds
+ // a reference to them.
+ Delete(proxyID, orgUUID string) ([]string, error)
Exists(proxyID, orgUUID string) (bool, error)
// EnsureGatewayAssociation creates a gateway association for the proxy if one does
// not already exist and resolves the metadata to use for the deployment.
@@ -304,7 +313,10 @@ type MCPProxyRepository interface {
Count(orgUUID string) (int, error)
CountByProject(orgUUID, projectUUID string) (int, error)
Update(p *model.MCPProxy) error
- Delete(handle, orgUUID string) error
+ // Delete removes the proxy and returns the secret handles it referenced, so the
+ // caller can check each for orphan cleanup now that this artifact no longer holds
+ // a reference to them.
+ Delete(handle, orgUUID string) ([]string, error)
Exists(handle, orgUUID string) (bool, error)
EnsureGatewayAssociation(proxyUUID, gatewayUUID, orgUUID, createdBy, deployMetadata string, metadataProvided bool) (string, error)
}
@@ -352,7 +364,7 @@ type SecretRepository interface {
ListByHandles(orgID string, handles []string, updatedAfter *time.Time) ([]*model.Secret, error)
Count(orgID string) (int, error)
Update(s *model.Secret) error
- FindRefsAndSoftDelete(orgID, handle, updatedBy string) ([]model.SecretReference, error)
+ FindRefsAndDelete(orgID, handle string) ([]model.SecretReference, error)
FindRefs(orgID, handle string) ([]model.SecretReference, error)
Exists(orgID, handle string) (bool, error)
}
diff --git a/platform-api/internal/repository/llm.go b/platform-api/internal/repository/llm.go
index fb1809bbc1..0519a6d417 100644
--- a/platform-api/internal/repository/llm.go
+++ b/platform-api/internal/repository/llm.go
@@ -1178,10 +1178,13 @@ func (r *LLMProviderRepo) update(p *model.LLMProvider, policyUUIDs []string, rec
return nil
}
-func (r *LLMProviderRepo) Delete(providerID, orgUUID string) error {
+// Delete removes the LLM provider and returns the secret handles it referenced
+// (current config and any deployed snapshots), so the caller can check each for
+// orphan cleanup now that this artifact no longer holds a reference to them.
+func (r *LLMProviderRepo) Delete(providerID, orgUUID string) ([]string, error) {
tx, err := r.db.Begin()
if err != nil {
- return err
+ return nil, err
}
defer tx.Rollback()
@@ -1193,25 +1196,30 @@ func (r *LLMProviderRepo) Delete(providerID, orgUUID string) error {
err = tx.QueryRow(r.db.Rebind(query), providerID, orgUUID).Scan(&providerUUID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
- return sql.ErrNoRows
+ return nil, sql.ErrNoRows
}
- return err
+ return nil, err
+ }
+
+ secretHandles, err := secretHandlesForArtifact(tx, r.db, providerUUID)
+ if err != nil {
+ return nil, err
}
// Delete from llm_providers first, then artifacts
_, err = tx.Exec(r.db.Rebind(`DELETE FROM llm_providers WHERE uuid = ?`), providerUUID)
if err != nil {
- return err
+ return nil, err
}
if err := r.artifactRepo.Delete(tx, providerUUID); err != nil {
- return err
+ return nil, err
}
if err := tx.Commit(); err != nil {
- return err
+ return nil, err
}
- return nil
+ return secretHandles, nil
}
func (r *LLMProviderRepo) Exists(providerID, orgUUID string) (bool, error) {
@@ -1600,10 +1608,13 @@ func (r *LLMProxyRepo) EnsureGatewayAssociation(proxyUUID, gatewayUUID, orgUUID,
return ensureArtifactGatewayAssociation(r.db, proxyUUID, gatewayUUID, orgUUID, createdBy, deployMetadata, metadataProvided)
}
-func (r *LLMProxyRepo) Delete(proxyID, orgUUID string) error {
+// Delete removes the LLM proxy and returns the secret handles it referenced
+// (current config and any deployed snapshots), so the caller can check each for
+// orphan cleanup now that this artifact no longer holds a reference to them.
+func (r *LLMProxyRepo) Delete(proxyID, orgUUID string) ([]string, error) {
tx, err := r.db.Begin()
if err != nil {
- return err
+ return nil, err
}
defer tx.Rollback()
@@ -1615,25 +1626,30 @@ func (r *LLMProxyRepo) Delete(proxyID, orgUUID string) error {
err = tx.QueryRow(r.db.Rebind(query), proxyID, orgUUID).Scan(&proxyUUID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
- return sql.ErrNoRows
+ return nil, sql.ErrNoRows
}
- return err
+ return nil, err
+ }
+
+ secretHandles, err := secretHandlesForArtifact(tx, r.db, proxyUUID)
+ if err != nil {
+ return nil, err
}
// Delete from llm_proxies first, then artifacts using artifactRepo
_, err = tx.Exec(r.db.Rebind(`DELETE FROM llm_proxies WHERE uuid = ?`), proxyUUID)
if err != nil {
- return err
+ return nil, err
}
if err := r.artifactRepo.Delete(tx, proxyUUID); err != nil {
- return err
+ return nil, err
}
if err := tx.Commit(); err != nil {
- return err
+ return nil, err
}
- return nil
+ return secretHandles, nil
}
func (r *LLMProxyRepo) Exists(proxyID, orgUUID string) (bool, error) {
diff --git a/platform-api/internal/repository/llm_test.go b/platform-api/internal/repository/llm_test.go
index db8e92b1c1..72e2281ef8 100644
--- a/platform-api/internal/repository/llm_test.go
+++ b/platform-api/internal/repository/llm_test.go
@@ -149,7 +149,7 @@ func TestLLMProviderRepoDeleteRemovesCustomPolicyUsagesWithoutForeignKeys(t *tes
t.Fatalf("DeleteCustomPolicyIfUnused() before provider delete = nil error, want PolicyInUse")
}
- if err := providerRepo.Delete(provider.ID, orgUUID); err != nil {
+ if _, err := providerRepo.Delete(provider.ID, orgUUID); err != nil {
t.Fatalf("delete provider: %v", err)
}
diff --git a/platform-api/internal/repository/mcp.go b/platform-api/internal/repository/mcp.go
index 2991c9f22f..b0507f60d4 100644
--- a/platform-api/internal/repository/mcp.go
+++ b/platform-api/internal/repository/mcp.go
@@ -377,11 +377,14 @@ func (r *MCPProxyRepo) EnsureGatewayAssociation(proxyUUID, gatewayUUID, orgUUID,
return ensureArtifactGatewayAssociation(r.db, proxyUUID, gatewayUUID, orgUUID, createdBy, deployMetadata, metadataProvided)
}
-// Delete deletes an MCP proxy by its handle and organization UUID
-func (r *MCPProxyRepo) Delete(handle, orgUUID string) error {
+// Delete deletes an MCP proxy by its handle and organization UUID, returning the
+// secret handles it referenced (current config and any deployed snapshots) so the
+// caller can check each for orphan cleanup now that this artifact no longer holds
+// a reference to them.
+func (r *MCPProxyRepo) Delete(handle, orgUUID string) ([]string, error) {
tx, err := r.db.Begin()
if err != nil {
- return err
+ return nil, err
}
defer tx.Rollback()
@@ -393,25 +396,30 @@ func (r *MCPProxyRepo) Delete(handle, orgUUID string) error {
err = tx.QueryRow(r.db.Rebind(query), handle, orgUUID).Scan(&proxyUUID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
- return sql.ErrNoRows
+ return nil, sql.ErrNoRows
}
- return err
+ return nil, err
+ }
+
+ secretHandles, err := secretHandlesForArtifact(tx, r.db, proxyUUID)
+ if err != nil {
+ return nil, err
}
// Delete from mcp_proxies first, then artifacts using artifactRepo
_, err = tx.Exec(r.db.Rebind(`DELETE FROM mcp_proxies WHERE uuid = ?`), proxyUUID)
if err != nil {
- return err
+ return nil, err
}
if err := r.artifactRepo.Delete(tx, proxyUUID); err != nil {
- return err
+ return nil, err
}
if err := tx.Commit(); err != nil {
- return err
+ return nil, err
}
- return nil
+ return secretHandles, nil
}
// Exists checks if an MCP proxy exists by its handle and organization UUID
diff --git a/platform-api/internal/repository/secret.go b/platform-api/internal/repository/secret.go
index ba4ef8b939..0813d377be 100644
--- a/platform-api/internal/repository/secret.go
+++ b/platform-api/internal/repository/secret.go
@@ -230,10 +230,10 @@ func (r *SecretRepo) Update(s *model.Secret) error {
return nil
}
-// FindRefsAndSoftDelete checks for active artifact references and deprecates the
+// FindRefsAndDelete checks for active artifact references and permanently deletes the
// secret in a single transaction, eliminating the TOCTOU window.
-// Returns the references without deprecating if any are found.
-func (r *SecretRepo) FindRefsAndSoftDelete(orgID, handle, updatedBy string) ([]model.SecretReference, error) {
+// Returns the references without deleting if any are found.
+func (r *SecretRepo) FindRefsAndDelete(orgID, handle string) ([]model.SecretReference, error) {
tx, err := r.db.Begin()
if err != nil {
return nil, fmt.Errorf("failed to begin transaction: %w", err)
@@ -241,9 +241,18 @@ func (r *SecretRepo) FindRefsAndSoftDelete(orgID, handle, updatedBy string) ([]m
defer tx.Rollback() //nolint:errcheck
var lockQuery string
- if r.db.Driver() == "postgres" || r.db.Driver() == "postgresql" {
+ switch r.db.Driver() {
+ case "postgres", "postgresql":
lockQuery = `SELECT uuid FROM secrets WHERE organization_uuid = $1 AND handle = $2 LIMIT 1 FOR UPDATE`
- } else {
+ case database.DriverSQLServer:
+ // T-SQL has no LIMIT clause — SELECT TOP (1) is the equivalent (fixing an
+ // invalid-syntax error previously surfaced to callers as a generic 500).
+ // WITH (UPDLOCK, ROWLOCK) is T-SQL's counterpart to Postgres's FOR UPDATE:
+ // without it, a plain SELECT takes no lock held for the transaction's
+ // lifetime, so two concurrent deletes/updates on the same secret would not
+ // serialize against each other on SQL Server the way they do on Postgres.
+ lockQuery = r.db.Rebind(`SELECT TOP (1) uuid FROM secrets WITH (UPDLOCK, ROWLOCK) WHERE organization_uuid = ? AND handle = ?`)
+ default:
lockQuery = r.db.Rebind(`SELECT uuid FROM secrets WHERE organization_uuid = ? AND handle = ? LIMIT 1`)
}
var lockedID string
@@ -289,14 +298,12 @@ func (r *SecretRepo) FindRefsAndSoftDelete(orgID, handle, updatedBy string) ([]m
return refs, nil
}
- deleteQuery := r.db.Rebind(`
- UPDATE secrets
- SET status = 'DEPRECATED', updated_at = ?, updated_by = ?
- WHERE organization_uuid = ? AND handle = ?
- `)
- result, err := tx.Exec(deleteQuery, time.Now().UTC(), updatedBy, orgID, handle)
+ // secret_scopes cascades from secrets.uuid on every dialect; artifact_secret_refs
+ // has no rows for this handle at this point (the zero-refs check above just passed).
+ deleteQuery := r.db.Rebind(`DELETE FROM secrets WHERE organization_uuid = ? AND handle = ?`)
+ result, err := tx.Exec(deleteQuery, orgID, handle)
if err != nil {
- return nil, fmt.Errorf("failed to deprecate secret: %w", err)
+ return nil, fmt.Errorf("failed to delete secret: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
diff --git a/platform-api/internal/repository/secret_test.go b/platform-api/internal/repository/secret_test.go
index f69831fb3d..a1c3e9ba64 100644
--- a/platform-api/internal/repository/secret_test.go
+++ b/platform-api/internal/repository/secret_test.go
@@ -184,7 +184,7 @@ func TestSecretRepo_Update(t *testing.T) {
}
}
-func TestSecretRepo_SoftDelete(t *testing.T) {
+func TestSecretRepo_HardDelete(t *testing.T) {
db, cleanup := setupTestDB(t)
t.Cleanup(cleanup)
@@ -204,21 +204,25 @@ func TestSecretRepo_SoftDelete(t *testing.T) {
t.Fatalf("Create: %v", err)
}
- refs, err := repo.FindRefsAndSoftDelete(orgID, "deletable", "admin")
+ refs, err := repo.FindRefsAndDelete(orgID, "deletable")
if err != nil {
- t.Fatalf("FindRefsAndSoftDelete: %v", err)
+ t.Fatalf("FindRefsAndDelete: %v", err)
}
if len(refs) > 0 {
t.Fatalf("expected no refs blocking delete, got %v", refs)
}
- // Exists should return false after soft-delete (status=DEPRECATED)
+ // The row must be gone entirely, not merely deprecated.
+ if _, err := repo.GetByHandle(orgID, "deletable"); !apperror.SecretNotFound.Is(err) {
+ t.Errorf("GetByHandle after delete: got %v, want SecretNotFound", err)
+ }
+
exists, err := repo.Exists(orgID, "deletable")
if err != nil {
t.Fatalf("Exists: %v", err)
}
if exists {
- t.Error("expected secret to be inactive after soft-delete")
+ t.Error("expected secret to be gone after delete")
}
}
@@ -862,11 +866,11 @@ func TestSecretRepo_Create_UniqueConstraint_409(t *testing.T) {
}
}
-// TestSecretRepo_FindRefsAndSoftDelete_Transactional verifies the transactional
-// behaviour of FindRefsAndSoftDelete (scenario 87):
-// - When refs exist the secret is NOT deprecated and the refs are returned.
-// - After refs are removed a second call DOES deprecate the secret.
-func TestSecretRepo_FindRefsAndSoftDelete_Transactional(t *testing.T) {
+// TestSecretRepo_FindRefsAndDelete_Transactional verifies the transactional
+// behaviour of FindRefsAndDelete (scenario 87):
+// - When refs exist the secret is NOT deleted and the refs are returned.
+// - After refs are removed a second call DOES permanently delete the secret.
+func TestSecretRepo_FindRefsAndDelete_Transactional(t *testing.T) {
db, cleanup := setupTestDB(t)
t.Cleanup(cleanup)
@@ -900,10 +904,10 @@ func TestSecretRepo_FindRefsAndSoftDelete_Transactional(t *testing.T) {
t.Fatalf("insert ref: %v", err)
}
- // (a) With refs present: FindRefsAndSoftDelete must return refs and must NOT deprecate.
- refs, err := repo.FindRefsAndSoftDelete(orgID, "txn-secret", "admin")
+ // (a) With refs present: FindRefsAndDelete must return refs and must NOT delete.
+ refs, err := repo.FindRefsAndDelete(orgID, "txn-secret")
if err != nil {
- t.Fatalf("FindRefsAndSoftDelete (with refs): %v", err)
+ t.Fatalf("FindRefsAndDelete (with refs): %v", err)
}
if len(refs) == 0 {
t.Fatal("expected refs to block deletion, got none")
@@ -918,27 +922,31 @@ func TestSecretRepo_FindRefsAndSoftDelete_Transactional(t *testing.T) {
t.Errorf("secret should still be ACTIVE, got %q", got.Status)
}
- // (b) Remove the ref, then FindRefsAndSoftDelete must deprecate the secret.
+ // (b) Remove the ref, then FindRefsAndDelete must permanently delete the secret.
_, err = db.Exec(`DELETE FROM artifact_secret_refs WHERE organization_uuid = ? AND artifact_uuid = 'art-txn-001'`, orgID)
if err != nil {
t.Fatalf("delete ref: %v", err)
}
- refs, err = repo.FindRefsAndSoftDelete(orgID, "txn-secret", "admin")
+ refs, err = repo.FindRefsAndDelete(orgID, "txn-secret")
if err != nil {
- t.Fatalf("FindRefsAndSoftDelete (no refs): %v", err)
+ t.Fatalf("FindRefsAndDelete (no refs): %v", err)
}
if len(refs) != 0 {
t.Errorf("expected no refs after removal, got %d", len(refs))
}
- // Secret must now be DEPRECATED (active Exists returns false).
+ // The row must be gone entirely, not merely deprecated.
+ if _, err := repo.GetByHandle(orgID, "txn-secret"); !apperror.SecretNotFound.Is(err) {
+ t.Errorf("GetByHandle after delete: got %v, want SecretNotFound", err)
+ }
+
exists, err := repo.Exists(orgID, "txn-secret")
if err != nil {
t.Fatalf("Exists: %v", err)
}
if exists {
- t.Error("expected secret to be DEPRECATED (inactive) after successful soft-delete")
+ t.Error("expected secret to be gone after successful delete")
}
}
diff --git a/platform-api/internal/server/scope_route_coverage_test.go b/platform-api/internal/server/scope_route_coverage_test.go
index 0056cdbec7..6e9d5e67e6 100644
--- a/platform-api/internal/server/scope_route_coverage_test.go
+++ b/platform-api/internal/server/scope_route_coverage_test.go
@@ -130,6 +130,7 @@ func TestSecretsRoutesAreRegisteredOnTheBasePath(t *testing.T) {
{http.MethodGet, constants.APIBasePath + "/secrets"},
{http.MethodPost, constants.APIBasePath + "/secrets"},
{http.MethodGet, constants.APIBasePath + "/secrets/s-1"},
+ {http.MethodGet, constants.APIBasePath + "/secrets/s-1/usages"},
{http.MethodPut, constants.APIBasePath + "/secrets/s-1"},
{http.MethodDelete, constants.APIBasePath + "/secrets/s-1"},
} {
diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go
index fd376a7f13..4d9dee1a13 100644
--- a/platform-api/internal/server/server.go
+++ b/platform-api/internal/server/server.go
@@ -322,7 +322,8 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger,
if vaultErr != nil {
return nil, fmt.Errorf("failed to initialize secret vault: %w", vaultErr)
}
- secretService := service.NewSecretService(secretRepo, secretVault, identityService)
+ secretService := service.NewSecretService(secretRepo, secretVault, identityService).
+ WithGatewayBroadcast(gatewayRepo, gatewayEventsService)
// Initialize handlers
orgHandler := handler.NewOrganizationHandler(orgService, identityService, cfg.Auth.Authorization.Mode, slogger)
diff --git a/platform-api/internal/service/api.go b/platform-api/internal/service/api.go
index 46696c3ee1..5bade0ed51 100644
--- a/platform-api/internal/service/api.go
+++ b/platform-api/internal/service/api.go
@@ -473,9 +473,13 @@ func (s *APIService) DeleteAPI(apiUUID, orgUUID, deletedBy string) error {
}
// Delete API from repository (this also deletes associations)
- if err := s.apiRepo.DeleteAPI(apiUUID, orgUUID); err != nil {
+ referencedSecrets, err := s.apiRepo.DeleteAPI(apiUUID, orgUUID)
+ if err != nil {
return fmt.Errorf("failed to delete api: %w", err)
}
+ if s.secretService != nil {
+ s.secretService.CleanupOrphanedSecrets(orgUUID, referencedSecrets, deletedBy)
+ }
_ = s.auditRepo.Record("DELETE", apiUUID, "rest_api", orgUUID, deletedBy)
diff --git a/platform-api/internal/service/api_secret_integration_test.go b/platform-api/internal/service/api_secret_integration_test.go
index 7d337cd078..3de2c4e707 100644
--- a/platform-api/internal/service/api_secret_integration_test.go
+++ b/platform-api/internal/service/api_secret_integration_test.go
@@ -215,12 +215,10 @@ func TestAPIServiceSecretLifecycle_Integration(t *testing.T) {
t.Fatalf("UpdateAPI (rotate) failed: %v", err)
}
- secretA, err := secretSvc.Get(apiSecretITOrgUUID, "it-secret-a")
- if err != nil {
- t.Fatalf("failed to fetch secret A after rotation: %v", err)
- }
- if secretA.Status != string(model.SecretStatusDeprecated) {
- t.Errorf("expected secret A to be deprecated after rotation, got status=%q", secretA.Status)
+ // Secret A is no longer referenced by anything after rotation, so rotation
+ // cleanup permanently deletes it rather than merely deprecating it.
+ if _, err := secretSvc.Get(apiSecretITOrgUUID, "it-secret-a"); !apperror.SecretNotFound.Is(err) {
+ t.Errorf("expected secret A to be permanently deleted after rotation, got: %v", err)
}
secretB, err := secretSvc.Get(apiSecretITOrgUUID, "it-secret-b")
if err != nil {
@@ -229,10 +227,6 @@ func TestAPIServiceSecretLifecycle_Integration(t *testing.T) {
if secretB.Status != string(model.SecretStatusActive) {
t.Errorf("expected secret B to remain active after rotation, got status=%q", secretB.Status)
}
- // Secret A is no longer referenced, so it can now be hard-deleted.
- if err := secretSvc.Delete(apiSecretITOrgUUID, "it-secret-a", "alice"); err != nil {
- t.Errorf("expected secret A to be deletable after rotation freed it, got: %v", err)
- }
// --- Validation: a placeholder in upstream.auth that doesn't resolve is rejected ---
badUpstreamReq := &api.RESTAPI{
@@ -272,3 +266,51 @@ func TestAPIServiceSecretLifecycle_Integration(t *testing.T) {
t.Errorf("expected a validation error for missing secret ref, got: %v", err)
}
}
+
+// TestAPIServiceDeleteAPI_CleansUpOrphanedSecret_Integration proves deleting a
+// REST API permanently removes a secret it solely referenced, against a real
+// DB exercising the actual artifact_secret_refs tracking.
+func TestAPIServiceDeleteAPI_CleansUpOrphanedSecret_Integration(t *testing.T) {
+ apiSvc, secretSvc, cleanup := setupAPISecretTestEnv(t)
+ defer cleanup()
+
+ createTestSecret(t, secretSvc, apiSecretITOrgUUID, "del-solo-secret", "sk-solo-token")
+
+ createReq := &api.CreateRESTAPIRequest{
+ DisplayName: "IT REST API To Delete",
+ Context: "/it-rest-api-delete",
+ Version: "v1",
+ ProjectId: "api-secret-it-proj",
+ Upstream: api.Upstream{
+ Main: api.UpstreamDefinition{
+ Url: utils.StringPtrIfNotEmpty("https://backend.internal/api"),
+ Auth: &api.UpstreamAuth{
+ Type: upstreamAuthTypePtr("bearer"),
+ Header: ptr("Authorization"),
+ Value: ptr(`{{ secret "del-solo-secret" }}`),
+ },
+ },
+ },
+ }
+ created, err := apiSvc.CreateAPI(createReq, apiSecretITOrgUUID, "alice")
+ if err != nil {
+ t.Fatalf("CreateAPI failed: %v", err)
+ }
+ apiUUID := created.Id
+ if apiUUID == nil || *apiUUID == "" {
+ t.Fatal("expected CreateAPI to return an id")
+ }
+
+ // Sanity check: the secret is blocked from direct deletion while the API references it.
+ if err := secretSvc.Delete(apiSecretITOrgUUID, "del-solo-secret", "alice"); err == nil {
+ t.Fatal("expected secret deletion to be blocked while the API references it")
+ }
+
+ if err := apiSvc.DeleteAPIByHandle(*apiUUID, apiSecretITOrgUUID, "alice"); err != nil {
+ t.Fatalf("DeleteAPIByHandle failed: %v", err)
+ }
+
+ if _, err := secretSvc.Get(apiSecretITOrgUUID, "del-solo-secret"); !apperror.SecretNotFound.Is(err) {
+ t.Errorf("expected orphaned secret to be permanently deleted after API deletion, got: %v", err)
+ }
+}
diff --git a/platform-api/internal/service/api_test.go b/platform-api/internal/service/api_test.go
index e74b70e333..75da950f38 100644
--- a/platform-api/internal/service/api_test.go
+++ b/platform-api/internal/service/api_test.go
@@ -755,8 +755,8 @@ func TestAPIServiceUpdate_CleansUpRotatedSecret(t *testing.T) {
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
- if secretRepo.secrets["old-handle"].Status != model.SecretStatusDeprecated {
- t.Errorf("expected old secret to be deprecated, got status=%v", secretRepo.secrets["old-handle"].Status)
+ if _, ok := secretRepo.secrets["old-handle"]; ok {
+ t.Error("expected old secret to be permanently deleted after rotation cleanup")
}
}
diff --git a/platform-api/internal/service/gateway_events.go b/platform-api/internal/service/gateway_events.go
index 095bb0aea8..d9ad7551fb 100644
--- a/platform-api/internal/service/gateway_events.go
+++ b/platform-api/internal/service/gateway_events.go
@@ -79,6 +79,15 @@ const (
EventTypeSubscriptionPlanCreated = "subscriptionPlan.created"
EventTypeSubscriptionPlanUpdated = "subscriptionPlan.updated"
EventTypeSubscriptionPlanDeleted = "subscriptionPlan.deleted"
+
+ // EventTypeSecretUpdated fires on rotation (PUT /secrets/:handle). There is no
+ // EventTypeSecretCreated: a freshly created secret is not yet referenced by any
+ // deployed artifact, so no connected gateway has anything to refresh — the first
+ // time a gateway needs it, the artifact-deploy path's syncSecretRefsFromYAML picks
+ // it up already. EventTypeSecretDeleted fires on delete, which permanently
+ // removes the secret once no artifact references the handle.
+ EventTypeSecretUpdated = "secret.updated"
+ EventTypeSecretDeleted = "secret.deleted"
)
// GatewayEventsService handles broadcasting events to connected gateways via EventHub.
@@ -256,6 +265,16 @@ func (s *GatewayEventsService) BroadcastSubscriptionPlanDeletedEvent(gatewayID s
return s.broadcastEvent(gatewayID, EventTypeSubscriptionPlanDeleted, event)
}
+// BroadcastSecretUpdatedEvent sends a secret.updated event to target gateway.
+func (s *GatewayEventsService) BroadcastSecretUpdatedEvent(gatewayID string, event *model.SecretUpdatedEvent) error {
+ return s.broadcastEvent(gatewayID, EventTypeSecretUpdated, event)
+}
+
+// BroadcastSecretDeletedEvent sends a secret.deleted event to target gateway.
+func (s *GatewayEventsService) BroadcastSecretDeletedEvent(gatewayID string, event *model.SecretDeletedEvent) error {
+ return s.broadcastEvent(gatewayID, EventTypeSecretDeleted, event)
+}
+
// broadcastEvent is the generic helper for broadcasting gateway events without a userId.
func (s *GatewayEventsService) broadcastEvent(gatewayID, eventType string, payload interface{}) error {
return s.broadcastEventWithUserID(gatewayID, "", eventType, payload)
diff --git a/platform-api/internal/service/llm.go b/platform-api/internal/service/llm.go
index eece1699a3..2759e19e72 100644
--- a/platform-api/internal/service/llm.go
+++ b/platform-api/internal/service/llm.go
@@ -1320,12 +1320,16 @@ func (s *LLMProviderService) Delete(orgUUID, handle, deletedBy string) error {
}
}
- if err := s.repo.Delete(handle, orgUUID); err != nil {
+ referencedSecrets, err := s.repo.Delete(handle, orgUUID)
+ if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return apperror.LLMProviderNotFound.New()
}
return fmt.Errorf("failed to delete provider: %w", err)
}
+ if s.secretService != nil {
+ s.secretService.CleanupOrphanedSecrets(orgUUID, referencedSecrets, deletedBy)
+ }
_ = s.auditRepo.Record("DELETE", provider.UUID, "llm_provider", orgUUID, deletedBy)
@@ -1918,12 +1922,16 @@ func (s *LLMProxyService) Delete(orgUUID, handle, deletedBy string) error {
}
}
- if err := s.repo.Delete(handle, orgUUID); err != nil {
+ referencedSecrets, err := s.repo.Delete(handle, orgUUID)
+ if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return apperror.LLMProxyNotFound.New()
}
return fmt.Errorf("failed to delete proxy: %w", err)
}
+ if s.secretService != nil {
+ s.secretService.CleanupOrphanedSecrets(orgUUID, referencedSecrets, deletedBy)
+ }
_ = s.auditRepo.Record("DELETE", proxy.UUID, "llm_proxy", orgUUID, deletedBy)
// Send deletion events to all gateways in the organization
diff --git a/platform-api/internal/service/llm_test.go b/platform-api/internal/service/llm_test.go
index e28bc25b0a..d2a45791e4 100644
--- a/platform-api/internal/service/llm_test.go
+++ b/platform-api/internal/service/llm_test.go
@@ -1012,6 +1012,7 @@ type mockLLMProviderRepo struct {
existsResult bool
countResult int
getByIDFunc func(providerID, orgUUID string) (*model.LLMProvider, error)
+ deleteFunc func(providerID, orgUUID string) ([]string, error)
createCalled bool
created *model.LLMProvider
updated *model.LLMProvider
@@ -1047,6 +1048,13 @@ func (m *mockLLMProviderRepo) Update(p *model.LLMProvider) error {
return nil
}
+func (m *mockLLMProviderRepo) Delete(providerID, orgUUID string) ([]string, error) {
+ if m.deleteFunc != nil {
+ return m.deleteFunc(providerID, orgUUID)
+ }
+ return nil, nil
+}
+
func (m *mockLLMProviderRepo) UpdateWithCustomPolicyUsages(p *model.LLMProvider, _ []string) error {
return m.Update(p)
}
@@ -1088,6 +1096,7 @@ type mockLLMProxyRepo struct {
listByProviderItems []*model.LLMProxy
lastListProviderUUID string
getByIDFunc func(proxyID, orgUUID string) (*model.LLMProxy, error)
+ deleteFunc func(proxyID, orgUUID string) ([]string, error)
created *model.LLMProxy
updated *model.LLMProxy
}
@@ -1117,6 +1126,13 @@ func (m *mockLLMProxyRepo) Update(p *model.LLMProxy) error {
return nil
}
+func (m *mockLLMProxyRepo) Delete(proxyID, orgUUID string) ([]string, error) {
+ if m.deleteFunc != nil {
+ return m.deleteFunc(proxyID, orgUUID)
+ }
+ return nil, nil
+}
+
func (m *mockLLMProxyRepo) ListByProvider(orgUUID, providerUUID string, limit, offset int) ([]*model.LLMProxy, error) {
m.lastListProviderUUID = providerUUID
return m.listByProviderItems, nil
@@ -1784,14 +1800,72 @@ func TestLLMProviderServiceUpdate_CleansUpRotatedSecret(t *testing.T) {
if _, err := service.Update("org-1", "provider-1", "alice", request); err != nil {
t.Fatalf("expected no error, got: %v", err)
}
- if secretRepo.secrets["old-handle"].Status != model.SecretStatusDeprecated {
- t.Fatalf("expected old secret to be deprecated, got status=%v", secretRepo.secrets["old-handle"].Status)
+ if _, ok := secretRepo.secrets["old-handle"]; ok {
+ t.Fatal("expected old secret to be permanently deleted after rotation cleanup")
}
if secretRepo.secrets["new-handle"].Status != model.SecretStatusActive {
t.Fatalf("expected new secret to remain active, got status=%v", secretRepo.secrets["new-handle"].Status)
}
}
+// TestLLMProviderServiceDelete_CleansUpOrphanedSecret proves deleting a provider
+// permanently removes a secret it referenced, once the provider (and thus the
+// artifact_secret_refs row backing that reference) is gone and nothing else
+// references the handle.
+func TestLLMProviderServiceDelete_CleansUpOrphanedSecret(t *testing.T) {
+ providerRepo := &mockLLMProviderRepo{
+ getByIDFunc: func(providerID, orgUUID string) (*model.LLMProvider, error) {
+ return &model.LLMProvider{UUID: "prov-uuid", ID: providerID}, nil
+ },
+ deleteFunc: func(providerID, orgUUID string) ([]string, error) {
+ return []string{"solo-handle"}, nil
+ },
+ }
+ secretRepo := newMockRepo()
+ secretRepo.secrets["solo-handle"] = &model.Secret{Handle: "solo-handle", Status: model.SecretStatusActive}
+ secretService := NewSecretService(secretRepo, &mockVault{}, newTestIdentityService())
+
+ service := NewLLMProviderService(providerRepo, nil, nil, nil, nil, nil, nil, slog.Default(), &noopAuditRepo{}, &config.Server{}, newTestIdentityService())
+ service.SetSecretService(secretService)
+
+ if err := service.Delete("org-1", "provider-1", "alice"); err != nil {
+ t.Fatalf("expected no error, got: %v", err)
+ }
+ if _, ok := secretRepo.secrets["solo-handle"]; ok {
+ t.Error("expected orphaned secret to be permanently deleted after provider deletion")
+ }
+}
+
+// TestLLMProviderServiceDelete_DoesNotCleanUpSecretStillReferencedElsewhere proves
+// the orphan cleanup is reference-checked, not an unconditional delete of every
+// handle the provider happened to use.
+func TestLLMProviderServiceDelete_DoesNotCleanUpSecretStillReferencedElsewhere(t *testing.T) {
+ providerRepo := &mockLLMProviderRepo{
+ getByIDFunc: func(providerID, orgUUID string) (*model.LLMProvider, error) {
+ return &model.LLMProvider{UUID: "prov-uuid", ID: providerID}, nil
+ },
+ deleteFunc: func(providerID, orgUUID string) ([]string, error) {
+ return []string{"shared-handle"}, nil
+ },
+ }
+ secretRepo := newMockRepo()
+ secretRepo.secrets["shared-handle"] = &model.Secret{Handle: "shared-handle", Status: model.SecretStatusActive}
+ secretRepo.findRefsFn = func(orgID, handle string) ([]model.SecretReference, error) {
+ return []model.SecretReference{{Handle: "other-proxy", Name: "Other Proxy", Type: "LlmProxy"}}, nil
+ }
+ secretService := NewSecretService(secretRepo, &mockVault{}, newTestIdentityService())
+
+ service := NewLLMProviderService(providerRepo, nil, nil, nil, nil, nil, nil, slog.Default(), &noopAuditRepo{}, &config.Server{}, newTestIdentityService())
+ service.SetSecretService(secretService)
+
+ if err := service.Delete("org-1", "provider-1", "alice"); err != nil {
+ t.Fatalf("expected no error, got: %v", err)
+ }
+ if _, ok := secretRepo.secrets["shared-handle"]; !ok {
+ t.Error("expected secret still referenced elsewhere to survive provider deletion")
+ }
+}
+
func TestLLMProxyServiceUpdate_CleansUpRotatedSecret(t *testing.T) {
now := time.Now()
proxyRepo := &mockLLMProxyRepo{}
@@ -1842,8 +1916,8 @@ func TestLLMProxyServiceUpdate_CleansUpRotatedSecret(t *testing.T) {
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
- if secretRepo.secrets["old-handle"].Status != model.SecretStatusDeprecated {
- t.Fatalf("expected old secret to be deprecated, got status=%v", secretRepo.secrets["old-handle"].Status)
+ if _, ok := secretRepo.secrets["old-handle"]; ok {
+ t.Fatal("expected old secret to be permanently deleted after rotation cleanup")
}
}
diff --git a/platform-api/internal/service/mcp.go b/platform-api/internal/service/mcp.go
index dd498fb6a2..aae0a687fb 100644
--- a/platform-api/internal/service/mcp.go
+++ b/platform-api/internal/service/mcp.go
@@ -522,12 +522,16 @@ func (s *MCPProxyService) Delete(orgUUID, handle, deletedBy string) error {
}
}
- if err := s.repo.Delete(handle, orgUUID); err != nil {
+ referencedSecrets, err := s.repo.Delete(handle, orgUUID)
+ if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return apperror.MCPProxyNotFound.Wrap(err)
}
return fmt.Errorf("failed to delete MCP proxy: %w", err)
}
+ if s.secretService != nil {
+ s.secretService.CleanupOrphanedSecrets(orgUUID, referencedSecrets, deletedBy)
+ }
_ = s.auditRepo.Record("DELETE", mcpProxy.UUID, "mcp_proxy", orgUUID, deletedBy)
// Send deletion events to all gateways in the organization
diff --git a/platform-api/internal/service/mcp_secret_integration_test.go b/platform-api/internal/service/mcp_secret_integration_test.go
index 98fd8bd980..8a55996aef 100644
--- a/platform-api/internal/service/mcp_secret_integration_test.go
+++ b/platform-api/internal/service/mcp_secret_integration_test.go
@@ -32,6 +32,7 @@ import (
"github.com/wso2/api-platform/platform-api/api"
"github.com/wso2/api-platform/platform-api/config"
+ "github.com/wso2/api-platform/platform-api/internal/apperror"
"github.com/wso2/api-platform/platform-api/internal/database"
"github.com/wso2/api-platform/platform-api/internal/model"
"github.com/wso2/api-platform/platform-api/internal/repository"
@@ -123,12 +124,10 @@ func TestMCPProxyServiceUpdate_CleansUpRotatedSecret_Integration(t *testing.T) {
t.Fatalf("Update (rotate) failed: %v", err)
}
- secretA, err := secretSvc.Get(mcpSecretITOrgUUID, "mcp-it-secret-a")
- if err != nil {
- t.Fatalf("failed to fetch secret A after rotation: %v", err)
- }
- if secretA.Status != string(model.SecretStatusDeprecated) {
- t.Errorf("expected secret A to be deprecated after rotation, got status=%q", secretA.Status)
+ // Secret A is no longer referenced by anything after rotation, so rotation
+ // cleanup permanently deletes it rather than merely deprecating it.
+ if _, err := secretSvc.Get(mcpSecretITOrgUUID, "mcp-it-secret-a"); !apperror.SecretNotFound.Is(err) {
+ t.Errorf("expected secret A to be permanently deleted after rotation, got: %v", err)
}
secretB, err := secretSvc.Get(mcpSecretITOrgUUID, "mcp-it-secret-b")
if err != nil {
@@ -138,3 +137,77 @@ func TestMCPProxyServiceUpdate_CleansUpRotatedSecret_Integration(t *testing.T) {
t.Errorf("expected secret B to remain active after rotation, got status=%q", secretB.Status)
}
}
+
+// TestMCPProxyServiceDelete_CleansUpOrphanedSecret_Integration proves deleting
+// an MCP proxy permanently removes a secret it solely referenced, against a
+// real DB exercising the actual artifact_secret_refs tracking.
+func TestMCPProxyServiceDelete_CleansUpOrphanedSecret_Integration(t *testing.T) {
+ tmpDir := t.TempDir()
+ dbPath := filepath.Join(tmpDir, "mcp-secret-delete-it.db")
+ sqlDB, err := sql.Open("sqlite3", dbPath)
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ defer sqlDB.Close()
+ sqlDB.Exec("PRAGMA foreign_keys = ON")
+ db := &database.DB{DB: sqlDB}
+
+ schemaPath := filepath.Join("..", "database", "schema.sqlite.sql")
+ schema, err := os.ReadFile(schemaPath)
+ if err != nil {
+ t.Fatalf("read schema: %v", err)
+ }
+ if _, err = db.Exec(string(schema)); err != nil {
+ t.Fatalf("apply schema: %v", err)
+ }
+ const orgUUID = "org-mcp-secret-delete-it"
+ if _, err = db.Exec(`INSERT INTO organizations (uuid, handle, display_name, region, idp_organization_ref_uuid, created_at, updated_at)
+ VALUES (?, 'mcp-secret-delete-it-org', 'MCP Secret Delete IT Org', 'default', 'idp-ref', datetime('now'), datetime('now'))`, orgUUID); err != nil {
+ t.Fatalf("insert org: %v", err)
+ }
+
+ v, err := vault.NewInHouseVault([]byte("12345678901234567890123456789012"))
+ if err != nil {
+ t.Fatalf("create vault: %v", err)
+ }
+ identity := NewIdentityService(repository.NewUserIdentityMappingRepo(db))
+ secretRepo := repository.NewSecretRepo(db)
+ secretSvc := NewSecretService(secretRepo, v, identity)
+
+ createTestSecret(t, secretSvc, orgUUID, "mcp-del-solo-secret", "sk-mcp-solo-token")
+
+ mcpRepo := repository.NewMCPProxyRepo(db)
+ mcpSvc := NewMCPProxyService(mcpRepo, nil, nil, nil, nil, slog.Default(), repository.NewAuditRepo(db), &config.Server{}, identity)
+ mcpSvc.WithSecretService(secretSvc)
+
+ created, err := mcpSvc.Create(orgUUID, "alice", &api.MCPProxy{
+ DisplayName: "IT MCP Proxy To Delete",
+ Version: "v1.0",
+ Upstream: api.Upstream{
+ Main: api.UpstreamDefinition{
+ Url: utils.StringPtrIfNotEmpty("https://mcp-backend.internal"),
+ Auth: &api.UpstreamAuth{
+ Type: upstreamAuthTypePtr("bearer"),
+ Header: ptr("Authorization"),
+ Value: ptr(`{{ secret "mcp-del-solo-secret" }}`),
+ },
+ },
+ },
+ })
+ if err != nil {
+ t.Fatalf("Create failed: %v", err)
+ }
+
+ // Sanity check: the secret is blocked from direct deletion while the proxy references it.
+ if err := secretSvc.Delete(orgUUID, "mcp-del-solo-secret", "alice"); err == nil {
+ t.Fatal("expected secret deletion to be blocked while the proxy references it")
+ }
+
+ if err := mcpSvc.Delete(orgUUID, *created.Id, "alice"); err != nil {
+ t.Fatalf("Delete failed: %v", err)
+ }
+
+ if _, err := secretSvc.Get(orgUUID, "mcp-del-solo-secret"); !apperror.SecretNotFound.Is(err) {
+ t.Errorf("expected orphaned secret to be permanently deleted after proxy deletion, got: %v", err)
+ }
+}
diff --git a/platform-api/internal/service/mcp_secret_resolution_test.go b/platform-api/internal/service/mcp_secret_resolution_test.go
new file mode 100644
index 0000000000..94c4ca319c
--- /dev/null
+++ b/platform-api/internal/service/mcp_secret_resolution_test.go
@@ -0,0 +1,128 @@
+/*
+ * Copyright (c) 2026, 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"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "github.com/wso2/api-platform/platform-api/api"
+ "github.com/wso2/api-platform/platform-api/config"
+ "github.com/wso2/api-platform/platform-api/internal/dto"
+ "github.com/wso2/api-platform/platform-api/internal/model"
+)
+
+// TestFetchServerInfo_ProxyIdRefetch_ResolvesSecretHandle verifies the exact bug
+// fixed in FetchServerInfo's proxyId branch: the stored upstream auth value is a
+// {{ secret "handle" }} placeholder, not the plaintext credential, and it must be
+// resolved through the secret store before being sent to the target MCP server as
+// the actual header value — never sent to the upstream as the literal placeholder
+// text, and never appear anywhere in the response returned to the caller.
+func TestFetchServerInfo_ProxyIdRefetch_ResolvesSecretHandle(t *testing.T) {
+ const orgID = "org-1"
+ const plaintextCredential = "Bearer super-secret-live-token"
+
+ // Real SecretService over the lightweight mock vault/repo already used by
+ // secret_service_test.go, so Create/Decrypt round-trip through the same
+ // encrypt/decrypt code path FetchServerInfo itself calls.
+ vault := &mockVault{}
+ secretRepo := newMockRepo()
+ secretService := NewSecretService(secretRepo, vault, newTestIdentityService())
+
+ created, err := secretService.Create(orgID, "tester", &dto.CreateSecretRequest{
+ Handle: "upstream-auth-handle",
+ DisplayName: "Upstream auth",
+ Value: plaintextCredential,
+ Type: model.SecretTypeGeneric,
+ })
+ require.NoError(t, err)
+ require.Equal(t, "upstream-auth-handle", created.Handle)
+
+ var receivedAuthHeader string
+ mockMCPServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodHead {
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+
+ var body struct {
+ Method string `json:"method"`
+ ID *int `json:"id"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&body)
+
+ // Captured on every JSON-RPC call — initialize is the first one that
+ // carries the custom auth header, so this is where the leak (or lack of
+ // one) would show up.
+ if h := r.Header.Get("Authorization"); h != "" {
+ receivedAuthHeader = h
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ switch body.Method {
+ case "initialize":
+ _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{},"serverInfo":{"name":"test","version":"1.0.0"}}}`))
+ case "notifications/initialized":
+ w.WriteHeader(http.StatusAccepted)
+ case "tools/list":
+ _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}`))
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }))
+ defer mockMCPServer.Close()
+
+ repo := &mockMCPProxyRepository{getByHandleResult: &model.MCPProxy{
+ Handle: "mcp-proxy-1",
+ Configuration: model.MCPProxyConfiguration{
+ Upstream: model.UpstreamConfig{
+ Main: &model.UpstreamEndpoint{
+ URL: mockMCPServer.URL,
+ Auth: &model.UpstreamAuth{
+ Type: "header",
+ Header: "Authorization",
+ // The stored value is always a placeholder, never plaintext.
+ Value: `{{ secret "upstream-auth-handle" }}`,
+ },
+ },
+ },
+ },
+ }}
+
+ mcpService := NewMCPProxyService(repo, nil, nil, nil, nil, newTestLogger(), &noopAuditRepo{}, &config.Server{}, newTestIdentityService())
+ mcpService.WithSecretService(secretService)
+
+ proxyID := "mcp-proxy-1"
+ resp, err := mcpService.FetchServerInfo(orgID, &api.MCPServerInfoFetchRequest{ProxyId: &proxyID})
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+
+ require.Equal(t, plaintextCredential, receivedAuthHeader,
+ "the upstream MCP server must receive the resolved plaintext credential, not the raw secret placeholder")
+ require.NotContains(t, receivedAuthHeader, "{{ secret",
+ "the placeholder text must never be sent to the upstream server as-is")
+
+ // The resolved plaintext must never appear anywhere in the client-facing response.
+ responseJSON, err := json.Marshal(resp)
+ require.NoError(t, err)
+ require.NotContains(t, string(responseJSON), plaintextCredential,
+ "the resolved credential must never be echoed back in the fetch-server-info response")
+}
diff --git a/platform-api/internal/service/secret_service.go b/platform-api/internal/service/secret_service.go
index 89691e3678..7fe0a9cb1d 100644
--- a/platform-api/internal/service/secret_service.go
+++ b/platform-api/internal/service/secret_service.go
@@ -44,13 +44,28 @@ func (e *SecretInUseError) Error() string {
}
type SecretService struct {
- repo repository.SecretRepository
- vault vault.SecretVault
- identity *IdentityService
+ repo repository.SecretRepository
+ vault vault.SecretVault
+ identity *IdentityService
+ gatewayRepo repository.GatewayRepository
+ gatewayEvents *GatewayEventsService
+ slogger *slog.Logger
}
func NewSecretService(repo repository.SecretRepository, v vault.SecretVault, identity *IdentityService) *SecretService {
- return &SecretService{repo: repo, vault: v, identity: identity}
+ return &SecretService{repo: repo, vault: v, identity: identity, slogger: slog.Default()}
+}
+
+// WithGatewayBroadcast enables best-effort secret.updated/secret.deleted
+// notifications to every gateway in the organization on rotate/delete (see
+// broadcastSecretEvent). Optional: a SecretService built without this call simply
+// skips broadcasting — kept as a post-construction setter rather than a constructor
+// parameter so the many existing unit tests that build a bare SecretService don't
+// all need updating for a purely additive, best-effort side channel.
+func (s *SecretService) WithGatewayBroadcast(gatewayRepo repository.GatewayRepository, gatewayEvents *GatewayEventsService) *SecretService {
+ s.gatewayRepo = gatewayRepo
+ s.gatewayEvents = gatewayEvents
+ return s
}
// toSecretResponse converts secret via secretToResponse and resolves its
@@ -89,6 +104,10 @@ func (s *SecretService) toSecretSummary(secret *model.Secret) (*dto.SecretSummar
}
func (s *SecretService) Create(orgID, createdBy string, req *dto.CreateSecretRequest) (*dto.SecretResponse, error) {
+ if err := validateSecretHandle(req.Handle); err != nil {
+ return nil, err
+ }
+
secretType := req.Type
if secretType == "" {
secretType = model.SecretTypeGeneric
@@ -178,41 +197,146 @@ func (s *SecretService) Update(orgID, handle, updatedBy string, req *dto.UpdateS
return nil, err
}
- ciphertext, err := s.vault.Encrypt(context.Background(), req.Value)
- if err != nil {
- return nil, fmt.Errorf("failed to encrypt secret: %w", err)
- }
-
if req.DisplayName != "" {
existing.DisplayName = req.DisplayName
}
if req.Description != "" {
existing.Description = req.Description
}
- existing.Ciphertext = ciphertext
- existing.Hash = hashSecret(s.vault.HashKey(), req.Value)
+ // Value is optional: a metadata-only edit (no value) must not touch the
+ // ciphertext/hash or reactivate a deprecated secret — only an explicit
+ // rotation is an intent to put the secret back into service.
+ if req.Value != "" {
+ ciphertext, err := s.vault.Encrypt(context.Background(), req.Value)
+ if err != nil {
+ return nil, fmt.Errorf("failed to encrypt secret: %w", err)
+ }
+ existing.Ciphertext = ciphertext
+ existing.Hash = hashSecret(s.vault.HashKey(), req.Value)
+ existing.Status = model.SecretStatusActive
+ }
existing.UpdatedBy = updatedBy
- // Rotation is an explicit intent to put the secret back into service.
- existing.Status = model.SecretStatusActive
if err := s.repo.Update(existing); err != nil {
return nil, fmt.Errorf("failed to update secret: %w", err)
}
- return s.toSecretResponse(existing)
+ // Build the response before broadcasting: toSecretResponse can still fail
+ // (identity-mapping lookups), and the caller must not be told the rotation
+ // failed after gateways have already been notified of it. If this errors,
+ // the DB commit above still stands — a retry will simply broadcast then.
+ resp, err := s.toSecretResponse(existing)
+ if err != nil {
+ return nil, err
+ }
+
+ s.broadcastSecretEvent(orgID, "updated", &model.SecretUpdatedEvent{
+ Handle: existing.Handle,
+ DisplayName: existing.DisplayName,
+ Hash: existing.Hash,
+ // existing.UpdatedAt was just set in-place by s.repo.Update above — see
+ // model.SecretUpdatedEvent.Revision for why UnixNano() is a safe ordering token.
+ Revision: existing.UpdatedAt.UnixNano(),
+ })
+
+ return resp, nil
+}
+
+// GetReferences returns the resources that currently reference handle, so a
+// caller can show why a secret is in use before attempting a delete. Existence
+// is checked first so an unknown handle reports SecretNotFound rather than an
+// empty usages list indistinguishable from "not referenced".
+func (s *SecretService) GetReferences(orgID, handle string) ([]dto.SecretReferenceDTO, error) {
+ if _, err := s.repo.GetByHandle(orgID, handle); err != nil {
+ return nil, err
+ }
+
+ refs, err := s.repo.FindRefs(orgID, handle)
+ if err != nil {
+ return nil, fmt.Errorf("failed to find secret references: %w", err)
+ }
+
+ result := make([]dto.SecretReferenceDTO, 0, len(refs))
+ for _, ref := range refs {
+ result = append(result, dto.SecretReferenceDTO{Type: ref.Type, Handle: ref.Handle, Name: ref.Name})
+ }
+ return result, nil
}
func (s *SecretService) Delete(orgID, handle, updatedBy string) error {
- refs, err := s.repo.FindRefsAndSoftDelete(orgID, handle, updatedBy)
+ // Captured before the delete so the broadcast Revision reflects the same
+ // moment as the DB write.
+ deletedAt := time.Now().UTC()
+
+ refs, err := s.repo.FindRefsAndDelete(orgID, handle)
if err != nil {
return fmt.Errorf("failed to delete secret: %w", err)
}
if len(refs) > 0 {
return &SecretInUseError{References: refs}
}
+
+ s.broadcastSecretEvent(orgID, "deleted", &model.SecretDeletedEvent{
+ Handle: handle,
+ Revision: deletedAt.UnixNano(),
+ })
+
return nil
}
+// CleanupOrphanedSecrets permanently deletes any of the given handles that are no
+// longer referenced by anything, once the resource that used to hold one of these
+// references (an LLM provider/proxy or MCP proxy) has already been deleted. Best-effort:
+// a handle still referenced by some other resource, already gone, or any other error is
+// logged and skipped rather than failing the caller's delete — the resource is already
+// durably deleted by the time this runs.
+func (s *SecretService) CleanupOrphanedSecrets(orgID string, handles []string, deletedBy string) {
+ for _, handle := range handles {
+ if err := s.Delete(orgID, handle, deletedBy); err != nil {
+ var inUseErr *SecretInUseError
+ if errors.As(err, &inUseErr) || apperror.SecretNotFound.Is(err) {
+ continue // still referenced elsewhere, or already gone — expected, not a failure
+ }
+ s.slogger.Warn("failed to clean up orphaned secret after resource deletion",
+ "handle", handle, "orgId", orgID, "err", err)
+ }
+ }
+}
+
+// broadcastSecretEvent sends a secret.* event to every gateway in the organization.
+// Best-effort: a load or delivery failure is logged and swallowed rather than failing
+// the rotate/delete call — the change is already durably committed, and a gateway that
+// misses the push still catches up via its own poll-based incremental sync (see
+// docs/specs/secrets-management.md §6.5-6.7). Also a safe no-op when WithGatewayBroadcast
+// was never called (e.g. in unit tests).
+func (s *SecretService) broadcastSecretEvent(orgUUID, action string, payload interface{}) {
+ if s.gatewayEvents == nil || s.gatewayRepo == nil {
+ return
+ }
+ gateways, err := s.gatewayRepo.GetByOrganizationID(orgUUID)
+ if err != nil {
+ s.slogger.Warn("Failed to load gateways for secret broadcast",
+ "orgId", orgUUID, "action", action, "error", err)
+ return
+ }
+ for _, gw := range gateways {
+ if gw == nil || gw.ID == "" {
+ continue
+ }
+ var broadcastErr error
+ switch action {
+ case "updated":
+ broadcastErr = s.gatewayEvents.BroadcastSecretUpdatedEvent(gw.ID, payload.(*model.SecretUpdatedEvent))
+ case "deleted":
+ broadcastErr = s.gatewayEvents.BroadcastSecretDeletedEvent(gw.ID, payload.(*model.SecretDeletedEvent))
+ }
+ if broadcastErr != nil {
+ s.slogger.Warn("Failed to broadcast secret event",
+ "gatewayId", gw.ID, "action", action, "error", broadcastErr)
+ }
+ }
+}
+
// extractSecretHandle returns the handle embedded in a {{ secret "handle" }}
// placeholder, or "" if value is empty, plaintext, or otherwise not a placeholder.
func extractSecretHandle(value string) string {
@@ -269,7 +393,9 @@ func (s *SecretService) cleanupRotatedSecret(orgUUID, oldValue, newValue, update
}
// ValidateSecretRefs checks that every {{ secret "handle" }} placeholder in configText
-// resolves to an active org-scoped secret.
+// resolves to an active org-scoped secret. Missing and deprecated handles are reported
+// separately (§5.6: a deprecated secret exists but cannot be referenced by new/updated
+// resources) so the caller isn't told a real, existing-but-retired handle "does not exist".
func (s *SecretService) ValidateSecretRefs(orgID, configText string) error {
matches := constants.SecretPlaceholderRe.FindAllStringSubmatch(configText, -1)
if len(matches) == 0 {
@@ -278,6 +404,7 @@ func (s *SecretService) ValidateSecretRefs(orgID, configText string) error {
seen := make(map[string]struct{})
var missing []string
+ var deprecated []string
for _, m := range matches {
handle := m[1]
@@ -286,18 +413,50 @@ func (s *SecretService) ValidateSecretRefs(orgID, configText string) error {
}
seen[handle] = struct{}{}
- found, err := s.repo.Exists(orgID, handle)
+ secret, err := s.repo.GetByHandle(orgID, handle)
if err != nil {
+ if apperror.SecretNotFound.Is(err) {
+ missing = append(missing, handle)
+ continue
+ }
return fmt.Errorf("failed to check existence of secret %q: %w", handle, err)
}
- if !found {
- missing = append(missing, handle)
+ if secret.Status == model.SecretStatusDeprecated {
+ deprecated = append(deprecated, handle)
}
}
+ if len(missing) == 0 && len(deprecated) == 0 {
+ return nil
+ }
+
+ var parts []string
if len(missing) > 0 {
+ parts = append(parts, fmt.Sprintf("do not exist: %s", strings.Join(missing, ", ")))
+ }
+ if len(deprecated) > 0 {
+ parts = append(parts, fmt.Sprintf("are deprecated and cannot be referenced by new or updated resources: %s", strings.Join(deprecated, ", ")))
+ }
+ return apperror.ValidationFailed.New(fmt.Sprintf(
+ "The following referenced secrets %s.", strings.Join(parts, "; ")))
+}
+
+// validateSecretHandle enforces the same handle shape the AI Workspace UI generates
+// (constants.SecretHandlePattern) and the DB column's length limit
+// (constants.SecretHandleMaxLength), so a non-UI caller cannot create a secret the
+// UI itself could never produce — see constants.SecretHandlePattern's doc comment
+// for why this matters (unreachable-via-router handles, placeholder-regex interference).
+func validateSecretHandle(handle string) error {
+ if handle == "" {
+ return apperror.ValidationFailed.New("A secret handle is required.")
+ }
+ if len(handle) > constants.SecretHandleMaxLength {
return apperror.ValidationFailed.New(fmt.Sprintf(
- "The following referenced secrets do not exist: %s.", strings.Join(missing, ", ")))
+ "Secret handle must not exceed %d characters.", constants.SecretHandleMaxLength))
+ }
+ if !constants.SecretHandlePattern.MatchString(handle) {
+ return apperror.ValidationFailed.New(
+ "Secret handle may only contain lowercase letters, numbers, and single hyphens (no leading, trailing, or doubled hyphens).")
}
return nil
}
diff --git a/platform-api/internal/service/secret_service_broadcast_test.go b/platform-api/internal/service/secret_service_broadcast_test.go
new file mode 100644
index 0000000000..3308379700
--- /dev/null
+++ b/platform-api/internal/service/secret_service_broadcast_test.go
@@ -0,0 +1,352 @@
+/*
+ * Copyright (c) 2026, 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
+
+// Covers the platform-api-side publish path left uncovered when secret.updated /
+// secret.deleted broadcasting was added (see docs/specs/secrets-management-test-scenarios.csv
+// rows 121-122, previously marked GAP): SecretService.Update / Delete calling
+// broadcastSecretEvent -> GatewayEventsService -> EventHub once WithGatewayBroadcast is
+// wired. The receiving side (gateway-controller's handleSecretUpdatedEvent /
+// handleSecretDeletedEvent) already has full coverage in
+// gateway/gateway-controller/pkg/controlplane/sync_secrets_test.go (rows 124-132).
+
+import (
+ "encoding/json"
+ "errors"
+ "log/slog"
+ "testing"
+
+ "github.com/wso2/api-platform/common/eventhub"
+ "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"
+)
+
+// ---- fake EventHub -----------------------------------------------------------
+
+// fakeEventHub is a minimal eventhub.EventHub double that records every published
+// event per gateway. Only PublishEvent is exercised by GatewayEventsService's
+// broadcastEvent path; the rest satisfy the interface as no-ops.
+type fakeEventHub struct {
+ publishFn func(gatewayID string, event eventhub.Event) error
+ published map[string][]eventhub.Event
+}
+
+func newFakeEventHub() *fakeEventHub {
+ return &fakeEventHub{published: make(map[string][]eventhub.Event)}
+}
+
+func (f *fakeEventHub) Initialize() error { return nil }
+func (f *fakeEventHub) RegisterGateway(_ string) error { return nil }
+func (f *fakeEventHub) UnsubscribeAll(_ string) error { return nil }
+func (f *fakeEventHub) CleanUpEvents() error { return nil }
+func (f *fakeEventHub) Close() error { return nil }
+func (f *fakeEventHub) Subscribe(_ string) (<-chan eventhub.Event, error) {
+ return nil, nil
+}
+func (f *fakeEventHub) Unsubscribe(_ string, _ <-chan eventhub.Event) error {
+ return nil
+}
+
+func (f *fakeEventHub) PublishEvent(gatewayID string, event eventhub.Event) error {
+ if f.publishFn != nil {
+ if err := f.publishFn(gatewayID, event); err != nil {
+ return err
+ }
+ }
+ f.published[gatewayID] = append(f.published[gatewayID], event)
+ return nil
+}
+
+// decodedPayload unmarshals the dto.GatewayEventDTO carried in a hub event's
+// EventData, returning the event's Type string and its raw Payload for a
+// caller-supplied struct to further unmarshal.
+func decodedPayload(t *testing.T, evt eventhub.Event) (string, json.RawMessage) {
+ t.Helper()
+ var wrapper struct {
+ Type string `json:"type"`
+ Payload json.RawMessage `json:"payload"`
+ }
+ if err := json.Unmarshal([]byte(evt.EventData), &wrapper); err != nil {
+ t.Fatalf("failed to unmarshal event data: %v", err)
+ }
+ return wrapper.Type, wrapper.Payload
+}
+
+// ---- fake gateway repo --------------------------------------------------------
+
+// mockGatewayRepoForBroadcast embeds the full GatewayRepository interface (nil by
+// default for everything) and overrides only GetByOrganizationID, the one method
+// broadcastSecretEvent actually calls.
+type mockGatewayRepoForBroadcast struct {
+ repository.GatewayRepository
+ getByOrgFn func(orgID string) ([]*model.Gateway, error)
+}
+
+func (m *mockGatewayRepoForBroadcast) GetByOrganizationID(orgID string) ([]*model.Gateway, error) {
+ return m.getByOrgFn(orgID)
+}
+
+// ---- helpers -------------------------------------------------------------------
+
+func newBroadcastWiredSecretService(repo *mockSecretRepo, gwRepo repository.GatewayRepository, hub eventhub.EventHub) *SecretService {
+ events := NewGatewayEventsService(hub, nil, slog.Default())
+ return NewSecretService(repo, &mockVault{}, newTestIdentityService()).
+ WithGatewayBroadcast(gwRepo, events)
+}
+
+func twoGateways() *mockGatewayRepoForBroadcast {
+ return &mockGatewayRepoForBroadcast{
+ getByOrgFn: func(orgID string) ([]*model.Gateway, error) {
+ return []*model.Gateway{{ID: "gw-a"}, {ID: "gw-b"}}, nil
+ },
+ }
+}
+
+// ---- Update -> secret.updated --------------------------------------------------
+
+func TestSecretService_Update_BroadcastsSecretUpdatedEventToAllGateways(t *testing.T) {
+ repo := newMockRepo()
+ repo.secrets["openai-key"] = &model.Secret{Handle: "openai-key", DisplayName: "OpenAI Key", Status: model.SecretStatusActive}
+
+ hub := newFakeEventHub()
+ svc := newBroadcastWiredSecretService(repo, twoGateways(), hub)
+
+ if _, err := svc.Update("org1", "openai-key", "alice", &dto.UpdateSecretRequest{Value: "sk-rotated"}); err != nil {
+ t.Fatalf("Update: %v", err)
+ }
+
+ for _, gw := range []string{"gw-a", "gw-b"} {
+ events := hub.published[gw]
+ if len(events) != 1 {
+ t.Fatalf("gateway %s: got %d published events, want 1", gw, len(events))
+ }
+ eventType, payload := decodedPayload(t, events[0])
+ if eventType != EventTypeSecretUpdated {
+ t.Errorf("gateway %s: event type = %q, want %q", gw, eventType, EventTypeSecretUpdated)
+ }
+ var updated model.SecretUpdatedEvent
+ if err := json.Unmarshal(payload, &updated); err != nil {
+ t.Fatalf("unmarshal payload: %v", err)
+ }
+ if updated.Handle != "openai-key" {
+ t.Errorf("gateway %s: payload handle = %q, want %q", gw, updated.Handle, "openai-key")
+ }
+ if updated.Hash != repo.secrets["openai-key"].Hash {
+ t.Errorf("gateway %s: payload hash = %q, want the freshly rotated hash %q", gw, updated.Hash, repo.secrets["openai-key"].Hash)
+ }
+ wantAction := "UPDATE"
+ if events[0].Action != wantAction {
+ t.Errorf("gateway %s: hub action = %q, want %q", gw, events[0].Action, wantAction)
+ }
+ }
+}
+
+// ---- Delete -> secret.deleted ------------------------------------------------
+
+func TestSecretService_Delete_BroadcastsSecretDeletedEventToAllGateways(t *testing.T) {
+ repo := newMockRepo()
+ repo.secrets["unused-key"] = &model.Secret{Handle: "unused-key", Status: model.SecretStatusActive}
+
+ hub := newFakeEventHub()
+ svc := newBroadcastWiredSecretService(repo, twoGateways(), hub)
+
+ if err := svc.Delete("org1", "unused-key", "alice"); err != nil {
+ t.Fatalf("Delete: %v", err)
+ }
+
+ for _, gw := range []string{"gw-a", "gw-b"} {
+ events := hub.published[gw]
+ if len(events) != 1 {
+ t.Fatalf("gateway %s: got %d published events, want 1", gw, len(events))
+ }
+ eventType, payload := decodedPayload(t, events[0])
+ if eventType != EventTypeSecretDeleted {
+ t.Errorf("gateway %s: event type = %q, want %q", gw, eventType, EventTypeSecretDeleted)
+ }
+ var deleted model.SecretDeletedEvent
+ if err := json.Unmarshal(payload, &deleted); err != nil {
+ t.Fatalf("unmarshal payload: %v", err)
+ }
+ if deleted.Handle != "unused-key" {
+ t.Errorf("gateway %s: payload handle = %q, want %q", gw, deleted.Handle, "unused-key")
+ }
+ wantAction := "DELETE"
+ if events[0].Action != wantAction {
+ t.Errorf("gateway %s: hub action = %q, want %q", gw, events[0].Action, wantAction)
+ }
+ }
+}
+
+// TestSecretService_Delete_BlockedWhenInUse_DoesNotBroadcast confirms the 409
+// in-use path returns before broadcastSecretEvent is ever reached — a secret that
+// was not actually deleted must not tell any gateway to evict it.
+func TestSecretService_Delete_BlockedWhenInUse_DoesNotBroadcast(t *testing.T) {
+ repo := newMockRepo()
+ repo.secrets["in-use"] = &model.Secret{Handle: "in-use", Status: model.SecretStatusActive}
+ repo.findRefsFn = func(orgID, handle string) ([]model.SecretReference, error) {
+ return []model.SecretReference{{Handle: "my-api", Name: "My API", Type: "RestApi"}}, nil
+ }
+
+ hub := newFakeEventHub()
+ svc := newBroadcastWiredSecretService(repo, twoGateways(), hub)
+
+ err := svc.Delete("org1", "in-use", "alice")
+ var inUseErr *SecretInUseError
+ if !errors.As(err, &inUseErr) {
+ t.Fatalf("expected SecretInUseError, got %T: %v", err, err)
+ }
+
+ if total := len(hub.published["gw-a"]) + len(hub.published["gw-b"]); total != 0 {
+ t.Errorf("expected no events published when delete is blocked, got %d", total)
+ }
+}
+
+// ---- Best-effort: broadcast failures never fail the caller's request -----------
+
+func TestSecretService_Update_GatewayRepoError_DoesNotFailUpdate(t *testing.T) {
+ repo := newMockRepo()
+ repo.secrets["k1"] = &model.Secret{Handle: "k1", Status: model.SecretStatusActive}
+
+ gwRepo := &mockGatewayRepoForBroadcast{
+ getByOrgFn: func(orgID string) ([]*model.Gateway, error) {
+ return nil, errors.New("db unavailable")
+ },
+ }
+ svc := newBroadcastWiredSecretService(repo, gwRepo, newFakeEventHub())
+
+ if _, err := svc.Update("org1", "k1", "alice", &dto.UpdateSecretRequest{Value: "new-val"}); err != nil {
+ t.Fatalf("Update must succeed even when loading gateways for broadcast fails, got: %v", err)
+ }
+ if repo.secrets["k1"].Status != model.SecretStatusActive {
+ t.Error("secret's own state must be unaffected by a broadcast-side failure")
+ }
+}
+
+func TestSecretService_Update_PublishEventError_DoesNotFailUpdate_OtherGatewayStillNotified(t *testing.T) {
+ repo := newMockRepo()
+ repo.secrets["k1"] = &model.Secret{Handle: "k1", Status: model.SecretStatusActive}
+
+ hub := newFakeEventHub()
+ hub.publishFn = func(gatewayID string, _ eventhub.Event) error {
+ if gatewayID == "gw-a" {
+ return errors.New("websocket delivery failed")
+ }
+ return nil
+ }
+ svc := newBroadcastWiredSecretService(repo, twoGateways(), hub)
+
+ if _, err := svc.Update("org1", "k1", "alice", &dto.UpdateSecretRequest{Value: "new-val"}); err != nil {
+ t.Fatalf("Update must succeed even when one gateway's publish fails, got: %v", err)
+ }
+ if len(hub.published["gw-a"]) != 0 {
+ t.Errorf("gw-a's failed publish must not be recorded as delivered, got %d", len(hub.published["gw-a"]))
+ }
+ if len(hub.published["gw-b"]) != 1 {
+ t.Errorf("gw-b must still receive its event independently of gw-a's failure, got %d", len(hub.published["gw-b"]))
+ }
+}
+
+// TestSecretService_Update_SkipsNilOrEmptyIDGateways confirms a malformed gateway
+// entry (nil, or missing its ID) is skipped rather than passed to the event hub with
+// an empty gateway ID.
+func TestSecretService_Update_SkipsNilOrEmptyIDGateways(t *testing.T) {
+ repo := newMockRepo()
+ repo.secrets["k1"] = &model.Secret{Handle: "k1", Status: model.SecretStatusActive}
+
+ hub := newFakeEventHub()
+ gwRepo := &mockGatewayRepoForBroadcast{
+ getByOrgFn: func(orgID string) ([]*model.Gateway, error) {
+ return []*model.Gateway{nil, {ID: ""}, {ID: "gw-a"}}, nil
+ },
+ }
+ svc := newBroadcastWiredSecretService(repo, gwRepo, hub)
+
+ if _, err := svc.Update("org1", "k1", "alice", &dto.UpdateSecretRequest{Value: "new-val"}); err != nil {
+ t.Fatalf("Update: %v", err)
+ }
+ if len(hub.published[""]) != 0 {
+ t.Errorf("no event should be published under an empty gateway ID, got %d", len(hub.published[""]))
+ }
+ if len(hub.published["gw-a"]) != 1 {
+ t.Errorf("the one well-formed gateway must still receive its event, got %d", len(hub.published["gw-a"]))
+ }
+}
+
+// ---- Update: broadcast must not fire before the response is confirmed ---------
+
+// failingIdentityRepo fails GetSubByUUID for a specific uuid, letting a test force
+// SecretService.toSecretResponse to fail after the DB commit but before broadcast.
+type failingIdentityRepo struct {
+ failFor string
+}
+
+func (r failingIdentityRepo) GetOrCreateUUID(identity string) (string, error) {
+ return identity, nil
+}
+
+func (r failingIdentityRepo) GetSubByUUID(uuid string) (string, bool, error) {
+ if uuid == r.failFor {
+ return "", false, errors.New("identity lookup unavailable")
+ }
+ return uuid, true, nil
+}
+
+func (r failingIdentityRepo) GetSubsByUUIDs(uuids []string) (map[string]string, error) {
+ result := make(map[string]string, len(uuids))
+ for _, id := range uuids {
+ if id == r.failFor {
+ return nil, errors.New("identity lookup unavailable")
+ }
+ if id != "" {
+ result[id] = id
+ }
+ }
+ return result, nil
+}
+
+// TestSecretService_Update_ResponseBuildFailure_DoesNotBroadcast guards the
+// ordering fix in Update(): broadcastSecretEvent must run only after
+// toSecretResponse has succeeded, so an identity-lookup failure can't report
+// "rotation failed" to the caller after gateways have already been told about it.
+func TestSecretService_Update_ResponseBuildFailure_DoesNotBroadcast(t *testing.T) {
+ repo := newMockRepo()
+ repo.secrets["k1"] = &model.Secret{Handle: "k1", Status: model.SecretStatusActive, UpdatedBy: "alice"}
+
+ hub := newFakeEventHub()
+ events := NewGatewayEventsService(hub, nil, slog.Default())
+ identity := NewIdentityService(failingIdentityRepo{failFor: "alice"})
+ svc := NewSecretService(repo, &mockVault{}, identity).WithGatewayBroadcast(twoGateways(), events)
+
+ _, err := svc.Update("org1", "k1", "alice", &dto.UpdateSecretRequest{Value: "new-val"})
+ if err == nil {
+ t.Fatal("expected Update to fail when the response cannot be built")
+ }
+
+ // The DB commit already happened — the caller error must not mean the rotation
+ // didn't happen, only that we couldn't confirm it back to them synchronously.
+ if repo.secrets["k1"].Hash == "" {
+ t.Error("expected the DB row to still reflect the rotation despite the response-build failure")
+ }
+
+ // The whole point of the ordering fix: no gateway should have been told about a
+ // rotation that was reported back to the caller as a failure.
+ if total := len(hub.published["gw-a"]) + len(hub.published["gw-b"]); total != 0 {
+ t.Errorf("expected no broadcast when toSecretResponse fails, got %d events", total)
+ }
+}
diff --git a/platform-api/internal/service/secret_service_test.go b/platform-api/internal/service/secret_service_test.go
index 152a63793a..0e0943121f 100644
--- a/platform-api/internal/service/secret_service_test.go
+++ b/platform-api/internal/service/secret_service_test.go
@@ -20,6 +20,7 @@ package service
import (
"context"
"errors"
+ "strings"
"testing"
"time"
@@ -64,14 +65,14 @@ type mockSecretRepo struct {
secrets map[string]*model.Secret
- createFn func(*model.Secret) error
- existsFn func(orgID, handle string) (bool, error)
- getByHandleFn func(orgID, handle string) (*model.Secret, error)
- updateFn func(*model.Secret) error
- findRefsAndSoftDeleteFn func(orgID, handle, by string) ([]model.SecretReference, error)
- findRefsFn func(orgID, handle string) ([]model.SecretReference, error)
- listFn func(orgID string, limit, offset int, after *time.Time) ([]*model.Secret, error)
- countFn func(orgID string) (int, error)
+ createFn func(*model.Secret) error
+ existsFn func(orgID, handle string) (bool, error)
+ getByHandleFn func(orgID, handle string) (*model.Secret, error)
+ updateFn func(*model.Secret) error
+ findRefsAndDeleteFn func(orgID, handle string) ([]model.SecretReference, error)
+ findRefsFn func(orgID, handle string) ([]model.SecretReference, error)
+ listFn func(orgID string, limit, offset int, after *time.Time) ([]*model.Secret, error)
+ countFn func(orgID string) (int, error)
}
func newMockRepo() *mockSecretRepo {
@@ -122,9 +123,9 @@ func (m *mockSecretRepo) Update(s *model.Secret) error {
return nil
}
-func (m *mockSecretRepo) FindRefsAndSoftDelete(orgID, handle, by string) ([]model.SecretReference, error) {
- if m.findRefsAndSoftDeleteFn != nil {
- return m.findRefsAndSoftDeleteFn(orgID, handle, by)
+func (m *mockSecretRepo) FindRefsAndDelete(orgID, handle string) ([]model.SecretReference, error) {
+ if m.findRefsAndDeleteFn != nil {
+ return m.findRefsAndDeleteFn(orgID, handle)
}
if m.findRefsFn != nil {
refs, err := m.findRefsFn(orgID, handle)
@@ -132,11 +133,10 @@ func (m *mockSecretRepo) FindRefsAndSoftDelete(orgID, handle, by string) ([]mode
return refs, err
}
}
- s, ok := m.secrets[handle]
- if !ok {
+ if _, ok := m.secrets[handle]; !ok {
return nil, apperror.SecretNotFound.New()
}
- s.Status = model.SecretStatusDeprecated
+ delete(m.secrets, handle)
return nil, nil
}
@@ -276,6 +276,64 @@ func TestSecretService_Create_InvalidType_ReturnsError(t *testing.T) {
}
}
+func TestSecretService_Create_InvalidHandle_ReturnsError(t *testing.T) {
+ cases := []struct {
+ name string
+ handle string
+ }{
+ {"empty", ""},
+ {"contains space", "bad handle"},
+ {"contains slash", "foo/bar"},
+ {"uppercase", "Bad-Handle"},
+ {"leading hyphen", "-bad"},
+ {"trailing hyphen", "bad-"},
+ {"doubled hyphen", "bad--handle"},
+ {"special characters", "bad!@#$%^&*()"},
+ {"exceeds max length", strings.Repeat("a", 41)},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ repo := newMockRepo()
+ svc := NewSecretService(repo, &mockVault{}, newTestIdentityService())
+
+ _, err := svc.Create("org1", "alice", &dto.CreateSecretRequest{
+ Handle: tc.handle, DisplayName: "Test", Value: "v",
+ })
+ if err == nil {
+ t.Fatalf("expected validation error for handle %q, got nil", tc.handle)
+ }
+ if !apperror.ValidationFailed.Is(err) {
+ t.Errorf("expected VALIDATION_FAILED for handle %q, got: %v", tc.handle, err)
+ }
+ if _, exists := repo.secrets[tc.handle]; exists {
+ t.Errorf("invalid handle %q must not reach repo.Create", tc.handle)
+ }
+ })
+ }
+}
+
+// TestSecretService_Create_ValidHandle_Accepted guards against the handle
+// pattern/length check (added after a handle containing "/" was found to be
+// creatable but then permanently unreachable via GET/PUT/DELETE — the router
+// treats "/" as a path-segment boundary) becoming too strict for handles the
+// UI actually generates.
+func TestSecretService_Create_ValidHandle_Accepted(t *testing.T) {
+ cases := []string{"openai-key", "a", "a1-b2-c3", strings.Repeat("a", 40)}
+ for _, handle := range cases {
+ t.Run(handle, func(t *testing.T) {
+ repo := newMockRepo()
+ svc := NewSecretService(repo, &mockVault{}, newTestIdentityService())
+
+ if _, err := svc.Create("org1", "alice", &dto.CreateSecretRequest{
+ Handle: handle, DisplayName: "Test", Value: "v",
+ }); err != nil {
+ t.Fatalf("unexpected error for valid handle %q: %v", handle, err)
+ }
+ })
+ }
+}
+
// ---- List tests -------------------------------------------------------------
func TestSecretService_List_ReturnsPagination(t *testing.T) {
@@ -347,6 +405,51 @@ func TestSecretService_Get_ReturnsSecret(t *testing.T) {
}
}
+// ---- GetReferences tests -----------------------------------------------------
+
+func TestSecretService_GetReferences_NotFound(t *testing.T) {
+ repo := newMockRepo()
+ svc := NewSecretService(repo, &mockVault{}, newTestIdentityService())
+
+ _, err := svc.GetReferences("org1", "missing")
+ if !apperror.SecretNotFound.Is(err) {
+ t.Errorf("expected ErrSecretNotFound, got %v", err)
+ }
+}
+
+func TestSecretService_GetReferences_ReturnsReferences(t *testing.T) {
+ repo := newMockRepo()
+ repo.secrets["s1"] = &model.Secret{Handle: "s1", Status: model.SecretStatusActive}
+ repo.findRefsFn = func(orgID, handle string) ([]model.SecretReference, error) {
+ return []model.SecretReference{
+ {Handle: "my-api", Name: "My API", Type: "RestApi"},
+ }, nil
+ }
+
+ svc := NewSecretService(repo, &mockVault{}, newTestIdentityService())
+ refs, err := svc.GetReferences("org1", "s1")
+ if err != nil {
+ t.Fatalf("GetReferences: %v", err)
+ }
+ if len(refs) != 1 || refs[0].Handle != "my-api" || refs[0].Type != "RestApi" {
+ t.Errorf("refs = %+v, want a single RestApi reference for my-api", refs)
+ }
+}
+
+func TestSecretService_GetReferences_NoUsages_ReturnsEmpty(t *testing.T) {
+ repo := newMockRepo()
+ repo.secrets["s1"] = &model.Secret{Handle: "s1", Status: model.SecretStatusActive}
+
+ svc := NewSecretService(repo, &mockVault{}, newTestIdentityService())
+ refs, err := svc.GetReferences("org1", "s1")
+ if err != nil {
+ t.Fatalf("GetReferences: %v", err)
+ }
+ if len(refs) != 0 {
+ t.Errorf("refs = %+v, want empty", refs)
+ }
+}
+
// ---- Update tests -----------------------------------------------------------
func TestSecretService_Update_NotFound(t *testing.T) {
@@ -376,6 +479,38 @@ func TestSecretService_Update_EncryptsNewValue(t *testing.T) {
}
}
+func TestSecretService_Update_NoValue_UpdatesMetadataOnly_DoesNotReencryptOrReactivate(t *testing.T) {
+ repo := newMockRepo()
+ repo.secrets["upd"] = &model.Secret{
+ Handle: "upd",
+ DisplayName: "Old Name",
+ Ciphertext: []byte("cipher:original"),
+ Hash: "hmac-sha256:original",
+ Status: model.SecretStatusDeprecated,
+ }
+ svc := NewSecretService(repo, &mockVault{}, newTestIdentityService())
+
+ _, err := svc.Update("org1", "upd", "bob", &dto.UpdateSecretRequest{DisplayName: "New Name"})
+ if err != nil {
+ t.Fatalf("Update: %v", err)
+ }
+
+ got := repo.secrets["upd"]
+ if got.DisplayName != "New Name" {
+ t.Errorf("DisplayName = %q, want %q", got.DisplayName, "New Name")
+ }
+ if string(got.Ciphertext) != "cipher:original" {
+ t.Errorf("Ciphertext changed on a metadata-only update: got %q", got.Ciphertext)
+ }
+ if got.Hash != "hmac-sha256:original" {
+ t.Errorf("Hash changed on a metadata-only update: got %q", got.Hash)
+ }
+ if got.Status != model.SecretStatusDeprecated {
+ t.Errorf("Status = %q, want unchanged %q — a metadata-only update must not reactivate a deprecated secret",
+ got.Status, model.SecretStatusDeprecated)
+ }
+}
+
// ---- Delete tests -----------------------------------------------------------
func TestSecretService_Delete_BlockedWhenInUse(t *testing.T) {
@@ -427,8 +562,8 @@ func TestSecretService_Delete_SucceedsWhenNotInUse(t *testing.T) {
if err := svc.Delete("org1", "unused", "alice"); err != nil {
t.Errorf("Delete: %v", err)
}
- if repo.secrets["unused"].Status != model.SecretStatusDeprecated {
- t.Error("expected status to be DEPRECATED after deletion")
+ if _, ok := repo.secrets["unused"]; ok {
+ t.Error("expected secret to be permanently removed after deletion")
}
}
@@ -509,9 +644,9 @@ func TestSecretService_ValidateSecretRefs_JSONEscapedForm_Missing(t *testing.T)
func TestSecretService_ValidateSecretRefs_DeduplicatesHandles(t *testing.T) {
callCount := 0
repo := newMockRepo()
- repo.existsFn = func(orgID, handle string) (bool, error) {
+ repo.getByHandleFn = func(orgID, handle string) (*model.Secret, error) {
callCount++
- return true, nil
+ return &model.Secret{Handle: handle, Status: model.SecretStatusActive}, nil
}
svc := NewSecretService(repo, &mockVault{}, newTestIdentityService())
@@ -520,7 +655,46 @@ func TestSecretService_ValidateSecretRefs_DeduplicatesHandles(t *testing.T) {
t.Errorf("unexpected error: %v", err)
}
if callCount != 1 {
- t.Errorf("Exists called %d times, want 1 (should deduplicate)", callCount)
+ t.Errorf("GetByHandle called %d times, want 1 (should deduplicate)", callCount)
+ }
+}
+
+// TestSecretService_ValidateSecretRefs_DeprecatedHandle_DistinctMessage guards
+// against a deprecated (existing, retired) secret being reported the same way
+// as a genuinely missing one — see §5.6: a deprecated secret still exists and
+// cannot be referenced by new/updated resources, which is a different failure
+// mode than a typo'd or never-created handle.
+func TestSecretService_ValidateSecretRefs_DeprecatedHandle_DistinctMessage(t *testing.T) {
+ repo := newMockRepo()
+ repo.secrets["retired-key"] = &model.Secret{Handle: "retired-key", Status: model.SecretStatusDeprecated}
+
+ svc := NewSecretService(repo, &mockVault{}, newTestIdentityService())
+ err := svc.ValidateSecretRefs("org1", `{{ secret "retired-key" }}`)
+ if err == nil {
+ t.Fatal("expected error for a deprecated secret reference")
+ }
+ if !apperror.ValidationFailed.Is(err) {
+ t.Errorf("expected VALIDATION_FAILED, got: %v", err)
+ }
+ if strings.Contains(err.Error(), "do not exist") {
+ t.Errorf("a deprecated (existing) secret must not be reported as nonexistent: %v", err)
+ }
+ if !strings.Contains(err.Error(), "deprecated") || !strings.Contains(err.Error(), "retired-key") {
+ t.Errorf("expected message to call out the deprecated handle by name, got: %v", err)
+ }
+}
+
+func TestSecretService_ValidateSecretRefs_MissingAndDeprecated_BothReported(t *testing.T) {
+ repo := newMockRepo()
+ repo.secrets["retired-key"] = &model.Secret{Handle: "retired-key", Status: model.SecretStatusDeprecated}
+
+ svc := NewSecretService(repo, &mockVault{}, newTestIdentityService())
+ err := svc.ValidateSecretRefs("org1", `{{ secret "retired-key" }} {{ secret "ghost-key" }}`)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if !strings.Contains(err.Error(), "retired-key") || !strings.Contains(err.Error(), "ghost-key") {
+ t.Errorf("expected both handles named in the message, got: %v", err)
}
}
diff --git a/platform-api/internal/utils/api.go b/platform-api/internal/utils/api.go
index 3c14ce1982..988f833b05 100644
--- a/platform-api/internal/utils/api.go
+++ b/platform-api/internal/utils/api.go
@@ -498,6 +498,9 @@ func (u *APIUtil) BuildAPIDeploymentYAML(apiModel *model.API) (*dto.APIDeploymen
if apiModel.Configuration.Upstream.Main.Ref != "" {
upstreamYAML.Main.Ref = apiModel.Configuration.Upstream.Main.Ref
}
+ if apiModel.Configuration.Upstream.Main.Auth != nil {
+ upstreamYAML.Main.Auth = apiModel.Configuration.Upstream.Main.Auth
+ }
}
if apiModel.Configuration.Upstream.Sandbox != nil {
upstreamYAML.Sandbox = &dto.UpstreamTarget{}
@@ -507,6 +510,9 @@ func (u *APIUtil) BuildAPIDeploymentYAML(apiModel *model.API) (*dto.APIDeploymen
if apiModel.Configuration.Upstream.Sandbox.Ref != "" {
upstreamYAML.Sandbox.Ref = apiModel.Configuration.Upstream.Sandbox.Ref
}
+ if apiModel.Configuration.Upstream.Sandbox.Auth != nil {
+ upstreamYAML.Sandbox.Auth = apiModel.Configuration.Upstream.Sandbox.Auth
+ }
}
}
diff --git a/platform-api/internal/utils/api_test.go b/platform-api/internal/utils/api_test.go
index 823e1990ac..0ca9cda0b0 100644
--- a/platform-api/internal/utils/api_test.go
+++ b/platform-api/internal/utils/api_test.go
@@ -19,6 +19,7 @@ package utils
import (
"reflect"
+ "strings"
"testing"
"gopkg.in/yaml.v3"
@@ -654,6 +655,81 @@ func TestBuildAPIDeploymentYAML(t *testing.T) {
}
}
+// TestBuildAPIDeploymentYAML_IncludesUpstreamAuth guards against upstream.main/sandbox.auth
+// being silently dropped from the deployment YAML sent to the gateway-controller. A REST
+// API's auth (including a {{ secret "handle" }} placeholder) is stored and redacted/validated
+// correctly end-to-end, but until this test was added nothing verified it actually reached the
+// deployment YAML the gateway-controller resolves placeholders from and renders into Envoy
+// config — the field was simply missing from dto.UpstreamTarget.
+func TestBuildAPIDeploymentYAML_IncludesUpstreamAuth(t *testing.T) {
+ util := &APIUtil{}
+
+ ctx := "/test"
+ apiModel := &model.API{
+ Name: "Test API",
+ Handle: "test-api-handle",
+ Version: "v1.0",
+ Kind: constants.RestApi,
+ Configuration: model.RestAPIConfig{
+ Context: &ctx,
+ Upstream: model.UpstreamConfig{
+ Main: &model.UpstreamEndpoint{
+ URL: "http://backend:8080",
+ Auth: &model.UpstreamAuth{
+ Type: "api-key",
+ Header: "Authorization",
+ Value: `{{ secret "main-handle" }}`,
+ },
+ },
+ Sandbox: &model.UpstreamEndpoint{
+ URL: "http://sandbox-backend:8080",
+ Auth: &model.UpstreamAuth{
+ Type: "api-key",
+ Header: "Authorization",
+ Value: `{{ secret "sandbox-handle" }}`,
+ },
+ },
+ },
+ },
+ ProjectID: "proj-123",
+ }
+
+ deploymentStruct, err := util.BuildAPIDeploymentYAML(apiModel)
+ if err != nil {
+ t.Fatalf("BuildAPIDeploymentYAML() error = %v", err)
+ }
+
+ main := deploymentStruct.Spec.Upstream.Main
+ if main == nil || main.Auth == nil {
+ t.Fatal("expected upstream.main.auth to be set")
+ }
+ if main.Auth.Type != "api-key" || main.Auth.Header != "Authorization" || main.Auth.Value != `{{ secret "main-handle" }}` {
+ t.Errorf("Main.Auth = %+v", main.Auth)
+ }
+
+ sandbox := deploymentStruct.Spec.Upstream.Sandbox
+ if sandbox == nil || sandbox.Auth == nil {
+ t.Fatal("expected upstream.sandbox.auth to be set")
+ }
+ if sandbox.Auth.Type != "api-key" || sandbox.Auth.Header != "Authorization" || sandbox.Auth.Value != `{{ secret "sandbox-handle" }}` {
+ t.Errorf("Sandbox.Auth = %+v", sandbox.Auth)
+ }
+
+ // The whole point: the raw secret placeholder must survive into the marshalled YAML
+ // text, since the gateway-controller's syncSecretRefsFromYAML finds it via a regex
+ // over this exact byte stream (see gateway-controller/pkg/constants.SecretPlaceholderRe).
+ yamlBytes, err := yaml.Marshal(deploymentStruct)
+ if err != nil {
+ t.Fatalf("failed to marshal struct: %v", err)
+ }
+ yamlText := string(yamlBytes)
+ for _, want := range []string{`{{ secret "main-handle" }}`, `{{ secret "sandbox-handle" }}`} {
+ if !strings.Contains(yamlText, want) {
+ t.Errorf("deployment YAML missing placeholder %q; got:\n%s", want, yamlText)
+ }
+ }
+}
+
// TestUpstreamConfigModelToAPI_RedactsAuthValue proves ModelToRESTAPI never
// exposes the real upstream auth credential in a read response — matching the
// LLM Provider/Proxy and MCP Proxy mappers' redaction behaviour.
diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml
index 46f76544e1..3d714bd1af 100644
--- a/platform-api/resources/openapi.yaml
+++ b/platform-api/resources/openapi.yaml
@@ -4716,6 +4716,42 @@ paths:
'500':
$ref: '#/components/responses/InternalServerError'
+ /secrets/{secretId}/usages:
+ get:
+ summary: List a secret's usages
+ description: |
+ Returns the resources (REST APIs, LLM providers, LLM proxies, MCP proxies) that
+ currently reference this secret via a `{{ secret "handle" }}` placeholder.
+ operationId: getSecretUsages
+ security:
+ - OAuth2Security:
+ - ap:secret:read
+ - ap:secret:manage
+ tags:
+ - Secrets
+ parameters:
+ - name: secretId
+ in: path
+ required: true
+ description: The secret handle
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Resources that reference this secret.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SecretUsagesResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '503':
+ $ref: '#/components/responses/ServiceUnavailable'
+ '500':
+ $ref: '#/components/responses/InternalServerError'
+
components:
headers:
Location:
@@ -8614,6 +8650,31 @@ components:
pagination:
$ref: '#/components/schemas/Pagination'
+ SecretReference:
+ type: object
+ description: Identifies a resource that references a secret.
+ properties:
+ type:
+ type: string
+ description: The referencing artifact's kind.
+ enum: [RestApi, LlmProvider, LlmProxy, Mcp]
+ handle:
+ type: string
+ description: The referencing resource's handle.
+ name:
+ type: string
+ description: The referencing resource's display name.
+
+ SecretUsagesResponse:
+ type: object
+ required:
+ - references
+ properties:
+ references:
+ type: array
+ items:
+ $ref: '#/components/schemas/SecretReference'
+
GatewayTokenListResponse:
type: object
required:
diff --git a/portals/ai-workspace/cypress/e2e/001-providers/002-provider-secret-management.cy.js b/portals/ai-workspace/cypress/e2e/001-providers/002-provider-secret-management.cy.js
index 441ff8c384..e603aa1dbc 100644
--- a/portals/ai-workspace/cypress/e2e/001-providers/002-provider-secret-management.cy.js
+++ b/portals/ai-workspace/cypress/e2e/001-providers/002-provider-secret-management.cy.js
@@ -42,14 +42,6 @@ import { appPathPattern } from '../../support/appPath';
// Shared helpers
// ---------------------------------------------------------------------------
-function toSlug(value) {
- return value
- .toLowerCase()
- .trim()
- .replace(/[^a-z0-9]+/g, '-')
- .replace(/^-+|-+$/g, '');
-}
-
function navigateToAddProvider() {
cy.get('[data-cyid="nav-service-provider"]', { timeout: 30000 })
.should('be.visible')
@@ -81,6 +73,12 @@ describe('AI Workspace — LLM provider secret management (create flow)', () =>
const suffix = Date.now().toString().slice(-8);
const orgHandle = Cypress.env('ORG_HANDLE');
const providerName = `E2E Secret Provider ${suffix}`;
+ // Dedicated, compact base for pre-created placeholder secret handles — kept
+ // independent of providerName's length so it always stays well within the
+ // platform-api's 40-character secret handle limit regardless of the display
+ // name above (a slug of providerName alone can already exceed 40 chars once
+ // a suffix like "-tc63-api-key" is appended).
+ const secretHandleBase = `e2e-${suffix}`;
let authToken = '';
let organizationId = '';
@@ -172,7 +170,7 @@ describe('AI Workspace — LLM provider secret management (create flow)', () =>
// TC-58: Re-save provider already using a placeholder → POST /secrets NOT called
// -------------------------------------------------------------------------
it('TC-58: does not create a duplicate secret when the auth value is already a placeholder', () => {
- const existingHandle = `${toSlug(providerName)}-api-key`;
+ const existingHandle = `${secretHandleBase}-api-key`;
// Pre-create the secret via API so there's a real secret backing the placeholder.
cy.request({
method: 'POST',
@@ -267,7 +265,7 @@ describe('AI Workspace — LLM provider secret management (create flow)', () =>
// in the org (encryption proof, not scoped to a single just-created secret).
// -------------------------------------------------------------------------
it('TC-63: GET /secrets never exposes plaintext value for any secret in the org', () => {
- const handle = `${toSlug(providerName)}-tc63-api-key`;
+ const handle = `${secretHandleBase}-tc63-api-key`;
cy.request({
method: 'POST',
@@ -318,6 +316,8 @@ describe('AI Workspace — LLM provider secret management (update flow)', () =>
const suffix = Date.now().toString().slice(-8);
const orgHandle = Cypress.env('ORG_HANDLE');
const providerName = `E2E Secret Update Provider ${suffix}`;
+ // See secretHandleBase in the create-flow describe block above — same reasoning.
+ const secretHandleBase = `e2e-${suffix}`;
const INITIAL_KEY = `sk-update-initial-${suffix}`;
const UPDATED_KEY = `sk-update-new-${suffix}`;
@@ -450,7 +450,7 @@ describe('AI Workspace — LLM provider secret management (update flow)', () =>
// TC-61: Edit credential by typing an explicit placeholder → POST /secrets NOT called
// -------------------------------------------------------------------------
it('TC-61: typing a placeholder value skips secret creation and sends the placeholder directly', () => {
- const explicitHandle = `${toSlug(providerName)}-api-key`;
+ const explicitHandle = `${secretHandleBase}-api-key`;
// Pre-create the secret so the platform-api accepts the placeholder in the PUT.
// cy.request() bypasses cy.intercept(), so this won't affect secretCallCount below.
diff --git a/portals/ai-workspace/cypress/e2e/001-providers/003-llm-proxy-secret-management.cy.js b/portals/ai-workspace/cypress/e2e/001-providers/003-llm-proxy-secret-management.cy.js
index 28c01a0f4d..712bf0d60a 100644
--- a/portals/ai-workspace/cypress/e2e/001-providers/003-llm-proxy-secret-management.cy.js
+++ b/portals/ai-workspace/cypress/e2e/001-providers/003-llm-proxy-secret-management.cy.js
@@ -45,14 +45,6 @@
// Shared helpers
// ---------------------------------------------------------------------------
-function toSlug(value) {
- return value
- .toLowerCase()
- .trim()
- .replace(/[^a-z0-9]+/g, '-')
- .replace(/^-+|-+$/g, '');
-}
-
function loginAndFetchAuthContext(setAuthToken, setOrganizationId) {
cy.login();
cy.request({
@@ -128,6 +120,12 @@ describe('AI Workspace — LLM proxy secret management (create flow)', () => {
const projectName = `E2E Proxy Secret Project ${suffix}`;
const providerName = `E2E Proxy Secret Provider ${suffix}`;
const proxyName = `E2E Proxy Secret Proxy ${suffix}`;
+ // Dedicated, compact base for pre-created placeholder secret handles — kept
+ // independent of proxyName's length so it always stays well within the
+ // platform-api's 40-character secret handle limit regardless of the display
+ // name above (a slug of proxyName alone can already exceed 40 chars once a
+ // suffix like "-provider-api-key" is appended).
+ const secretHandleBase = `e2e-${suffix}`;
let authToken = '';
let organizationId = '';
@@ -222,7 +220,7 @@ describe('AI Workspace — LLM proxy secret management (create flow)', () => {
// TC-2: Re-save proxy already using a placeholder → POST /secrets NOT called
// -------------------------------------------------------------------------
it('TC-2: does not create a duplicate secret when the API key is already a placeholder', () => {
- const existingHandle = `${toSlug(proxyName)}-provider-api-key`;
+ const existingHandle = `${secretHandleBase}-provider-api-key`;
cy.request({
method: 'POST',
url: '/proxy/api/v0.9/secrets',
@@ -310,6 +308,8 @@ describe('AI Workspace — LLM proxy secret management (update flow)', () => {
const projectName = `E2E Proxy Update Project ${suffix}`;
const providerName = `E2E Proxy Update Provider ${suffix}`;
const proxyName = `E2E Proxy Update Proxy ${suffix}`;
+ // See secretHandleBase in the create-flow describe block above — same reasoning.
+ const secretHandleBase = `e2e-${suffix}`;
const INITIAL_KEY = `sk-proxy-update-initial-${suffix}`;
let authToken = '';
@@ -444,7 +444,7 @@ describe('AI Workspace — LLM proxy secret management (update flow)', () => {
// TC-5: Edit API key by typing an explicit placeholder → POST /secrets NOT called
// -------------------------------------------------------------------------
it('TC-5: typing a placeholder value skips secret creation and sends the placeholder directly', () => {
- const explicitHandle = `${toSlug(proxyName)}-provider-api-key`;
+ const explicitHandle = `${secretHandleBase}-provider-api-key`;
cy.request({
method: 'POST',
diff --git a/portals/ai-workspace/src/App.tsx b/portals/ai-workspace/src/App.tsx
index 048ad54ca0..454af7600f 100644
--- a/portals/ai-workspace/src/App.tsx
+++ b/portals/ai-workspace/src/App.tsx
@@ -20,12 +20,13 @@ import {
Routes,
Route,
Navigate,
+ Outlet,
useLocation,
useNavigate,
} from 'react-router-dom';
import AutoLoginPage from './pages/login/AutoLoginPage';
import AppShellMain from './pages/appShell/appShellMain';
-import { AppShellProvider } from './contexts/AppShellContext';
+import { AppShellProvider, useAppShell } from './contexts/AppShellContext';
import { RoleProvider } from './contexts/RoleContext';
import PageErrorBoundary from './Components/common/PageErrorBoundary';
import { AIWorkspaceSnackbarProvider } from './contexts/AIWorkspaceSnackbarContext';
@@ -71,8 +72,12 @@ import CustomPoliciesList from './pages/appShell/appShellPages/gateways/CustomPo
import OrgRegisterPage from './pages/register/OrgRegisterPage';
import Insights from './pages/appShell/appShellPages/insights/Main';
import QuickStart from './pages/appShell/appShellPages/quickStart/Main';
-import Settings, { SettingsIndexRedirect } from './pages/appShell/appShellPages/settings/Main';
+import Settings, { SettingsIndexRedirect, resolveSettingsFallbackPath } from './pages/appShell/appShellPages/settings/Main';
import ProviderTemplatesList from './pages/appShell/appShellPages/providerTemplate/ProviderTemplatesList';
+import SecretsList from './pages/appShell/appShellPages/secret/SecretsList';
+import CreateSecret from './pages/appShell/appShellPages/secret/CreateSecret';
+import SecretOverview from './pages/appShell/appShellPages/secret/SecretOverview';
+import RotateSecret from './pages/appShell/appShellPages/secret/RotateSecret';
import ExternalServersList from './pages/appShell/appShellPages/externalServers/ExternalServersList';
import ExternalServersNew from './pages/appShell/appShellPages/externalServers/ExternalServersNew';
import ExternalServersOverview from './pages/appShell/appShellPages/externalServers/ExternalServersOverview';
@@ -83,6 +88,8 @@ import { LLMProvidersProvider } from './contexts/llmProvider';
import React, { useRef, useState } from 'react';
import { ChoreoUserProvider } from './contexts/ChoreoUserContext';
import { useAppAuth } from './contexts/AppAuthContext';
+import { SCOPES } from './auth/permissions';
+import { buildOrgPath } from './utils/projectRouting';
import { Box, Button, Stack, Typography } from '@wso2/oxygen-ui';
import OoopsImage from './assets/images/Ooops.svg';
@@ -125,6 +132,34 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
return <>{children}>;
}
+/**
+ * Layout-route guard: denies the wrapped route branch (rendered via )
+ * unless the caller holds `scope`, redirecting to a settings section they can
+ * actually access rather than a hardcoded path. Unlike ProtectedRoute
+ * (authentication only), this enforces per-route authorization — needed so a
+ * direct navigation to a URL like /settings/secrets can't render the page for a
+ * caller who lacks the corresponding scope.
+ */
+function RequireScope({ scope }: { scope: string }) {
+ const { hasPermission, isLoading } = useAppAuth();
+ const { currentOrganization } = useAppShell();
+
+ // A hard navigation/refresh straight to a guarded URL mounts this before the
+ // BFF session fetch resolves — hasPermission() reads as false for everything
+ // on that first render. Wait for it to settle before deciding, otherwise a
+ // legitimately-scoped caller gets bounced by this one-shot redirect before
+ // their scopes have even loaded.
+ if (isLoading) {
+ return null;
+ }
+
+ if (!hasPermission(scope)) {
+ return ;
+ }
+
+ return ;
+}
+
// The OIDC ?code= callback is now handled server-side by the BFF at
// /api/auth/callback (which sets the session cookie and 302s back into the app).
// This SPA route only catches stray hits to the legacy /signin path and bounces
@@ -590,6 +625,40 @@ export default function App() {
}
/>
+ }>
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
}>
diff --git a/portals/ai-workspace/src/Components/common/SecretValueField.tsx b/portals/ai-workspace/src/Components/common/SecretValueField.tsx
new file mode 100644
index 0000000000..741b3b010d
--- /dev/null
+++ b/portals/ai-workspace/src/Components/common/SecretValueField.tsx
@@ -0,0 +1,197 @@
+/*
+ * 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.
+ */
+
+import React, { useEffect, useState } from 'react';
+import { Autocomplete, Box, CircularProgress, IconButton, TextField, Typography } from '@wso2/oxygen-ui';
+import { Eye, EyeOff, KeyRound } from '@wso2/oxygen-ui-icons-react';
+import { buildSecretPlaceholder, extractSecretHandle, listSecrets, type SecretMetadata } from '../../apis/secretApis';
+
+type SecretOptionOrText = SecretMetadata | string;
+
+export interface SecretValueFieldProps {
+ value: string;
+ onChange: (value: string) => void;
+ onFocus?: React.FocusEventHandler;
+ onBlur?: React.FocusEventHandler;
+ disabled?: boolean;
+ placeholder?: string;
+ required?: boolean;
+ error?: boolean;
+ helperText?: React.ReactNode;
+ size?: 'small' | 'medium';
+ 'data-cyid'?: string;
+ 'data-testid'?: string;
+}
+
+function getOptionLabel(option: SecretOptionOrText): string {
+ return typeof option === 'string' ? option : option.displayName;
+}
+
+/**
+ * Combobox for an upstream credential's Value field — same free-entry-plus-picker
+ * affordance as GitHub's label picker, applied to secrets: existing secrets are
+ * offered in a dropdown (selecting one substitutes {{ secret "handle" }}), or the
+ * user can type a brand-new plaintext value directly (encrypted into a new secret
+ * by the caller on submit, same as before) or a {{ secret "handle" }} placeholder
+ * by hand, which is passed through unchanged.
+ */
+export default function SecretValueField({
+ value,
+ onChange,
+ onFocus,
+ onBlur,
+ disabled,
+ placeholder,
+ required,
+ error,
+ helperText,
+ size,
+ 'data-cyid': dataCyId,
+ 'data-testid': dataTestId,
+}: SecretValueFieldProps): React.JSX.Element {
+ const [options, setOptions] = useState([]);
+ const [isLoadingOptions, setIsLoadingOptions] = useState(true);
+ const [showValue, setShowValue] = useState(false);
+ const [isInputFocused, setIsInputFocused] = useState(false);
+
+ useEffect(() => {
+ let isMounted = true;
+ (async () => {
+ try {
+ const response = await listSecrets({ limit: 100 });
+ if (!isMounted) return;
+ setOptions((response.list ?? []).filter((s) => s.status === 'ACTIVE' && s.type === 'GENERIC'));
+ } catch {
+ // Best-effort: a dropdown that fails to load still lets the user type a
+ // value directly — this field never blocks on the existing-secrets list.
+ } finally {
+ if (isMounted) setIsLoadingOptions(false);
+ }
+ })();
+ return () => {
+ isMounted = false;
+ };
+ }, []);
+
+ const selectedHandle = extractSecretHandle(value);
+ const selectedOption = selectedHandle ? (options.find((s) => s.id === selectedHandle) ?? null) : null;
+ const autocompleteValue: SecretOptionOrText = selectedOption ?? value;
+
+ // Pre-filtered rather than left to MUI's own filterOptions, so `open` below can
+ // stay false when nothing matches — an open-but-empty popup would otherwise sit
+ // on top of (and swallow clicks meant for) whatever follows this field on the page.
+ const query = value.trim().toLowerCase();
+ const visibleOptions = selectedOption
+ ? []
+ : query
+ ? options.filter((opt) => `${opt.displayName} ${opt.id}`.toLowerCase().includes(query))
+ : options;
+ const isOpen = isInputFocused && visibleOptions.length > 0;
+
+ return (
+
+ freeSolo
+ fullWidth
+ size={size}
+ disabled={disabled}
+ open={isOpen}
+ options={visibleOptions}
+ filterOptions={(opts) => opts}
+ loading={isLoadingOptions}
+ value={autocompleteValue}
+ getOptionLabel={getOptionLabel}
+ isOptionEqualToValue={(option, val) => typeof val !== 'string' && typeof option !== 'string' && option.id === val.id}
+ onChange={(_event, newValue) => {
+ if (newValue === null) {
+ onChange('');
+ } else if (typeof newValue === 'string') {
+ onChange(newValue);
+ } else {
+ onChange(buildSecretPlaceholder(newValue.id));
+ }
+ }}
+ onInputChange={(_event, newInputValue, reason) => {
+ // 'reset' fires on selection/programmatic value changes — already handled by onChange.
+ if (reason === 'input') {
+ onChange(newInputValue);
+ }
+ }}
+ renderOption={(props, option) => {
+ if (typeof option === 'string') return null;
+ return (
+
+
+
+
+ {option.displayName}
+
+
+ {option.id}
+
+
+
+ );
+ }}
+ renderInput={(params) => (
+ ) => {
+ params.inputProps.onFocus?.(event);
+ setIsInputFocused(true);
+ onFocus?.(event);
+ },
+ onBlur: (event: React.FocusEvent) => {
+ params.inputProps.onBlur?.(event);
+ setIsInputFocused(false);
+ onBlur?.(event);
+ },
+ },
+ input: {
+ ...params.InputProps,
+ endAdornment: (
+ <>
+ {isLoadingOptions ? : null}
+ setShowValue((prev) => !prev)}
+ aria-label={showValue ? 'Hide value' : 'Show value'}
+ tabIndex={-1}
+ >
+ {showValue ? : }
+
+ {params.InputProps.endAdornment}
+ >
+ ),
+ },
+ }}
+ />
+ )}
+ />
+ );
+}
diff --git a/portals/ai-workspace/src/apis/secretApis.ts b/portals/ai-workspace/src/apis/secretApis.ts
index 7995a081ba..454e038a16 100644
--- a/portals/ai-workspace/src/apis/secretApis.ts
+++ b/portals/ai-workspace/src/apis/secretApis.ts
@@ -47,6 +47,7 @@ export interface SecretMetadata {
type: SecretType;
provider: string;
status: SecretStatus;
+ createdBy?: string;
createdAt: string;
updatedAt: string;
}
@@ -120,6 +121,15 @@ export async function getSecret(handle: string): Promise {
return get(`/secrets/${handle}`);
}
+/**
+ * Returns the resources (REST APIs, LLM providers, LLM proxies, MCP proxies) that
+ * currently reference this secret via a {{ secret "handle" }} placeholder.
+ */
+export async function getSecretUsages(handle: string): Promise {
+ const response = await get<{ references: SecretReference[] }>(`/secrets/${handle}/usages`);
+ return response.references ?? [];
+}
+
/**
* Rotates a secret's value. All {{ secret "handle" }} references remain valid —
* the gateway picks up the new value on its next sync cycle.
@@ -131,7 +141,7 @@ export async function updateSecret(
): Promise {
const form = new FormData();
form.append('value', request.value);
- if (request.name) form.append('name', request.name);
+ if (request.name) form.append('displayName', request.name);
if (request.description) form.append('description', request.description);
return putForm(`/secrets/${handle}`, form);
}
diff --git a/portals/ai-workspace/src/clients/choreoApiClient.ts b/portals/ai-workspace/src/clients/choreoApiClient.ts
index a6d63667a8..f755387024 100644
--- a/portals/ai-workspace/src/clients/choreoApiClient.ts
+++ b/portals/ai-workspace/src/clients/choreoApiClient.ts
@@ -75,6 +75,9 @@ const buildUrl = (
const full = path.startsWith('http') ? path : `${baseUrl}${path}`;
if (!params || Object.keys(params).length === 0) return full;
+ // `full` may be a relative path (the default PLATFORM_API_BASE_URL is a same-origin
+ // BFF proxy path, not an absolute URL) — the URL constructor requires a base in that
+ // case, or it throws "Failed to construct 'URL': Invalid URL".
const url = new URL(full, window.location.origin);
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null) url.searchParams.append(k, String(v));
diff --git a/portals/ai-workspace/src/hooks/useIsMounted.ts b/portals/ai-workspace/src/hooks/useIsMounted.ts
new file mode 100644
index 0000000000..9b6c650870
--- /dev/null
+++ b/portals/ai-workspace/src/hooks/useIsMounted.ts
@@ -0,0 +1,39 @@
+/*
+ * 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.
+ */
+
+import { useCallback, useEffect, useRef } from 'react';
+
+/**
+ * Returns a stable function that reports whether the calling component is
+ * still mounted. Use it to guard state updates after an `await` — e.g. an API
+ * call whose response resolves after the user has already navigated away —
+ * which would otherwise trigger a "set state on an unmounted component"
+ * warning (and, for update-then-navigate flows, a redundant navigate() call).
+ */
+export default function useIsMounted(): () => boolean {
+ const mountedRef = useRef(true);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ };
+ }, []);
+
+ return useCallback(() => mountedRef.current, []);
+}
diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersNew.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersNew.tsx
index 66bb22a2bc..fe5e468990 100644
--- a/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersNew.tsx
+++ b/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersNew.tsx
@@ -29,7 +29,6 @@ import {
FormControl,
FormLabel,
Grid,
- IconButton,
InputAdornment,
PageContent,
PageTitle,
@@ -41,8 +40,6 @@ import {
import {
ChevronDown,
ChevronLeft,
- Eye,
- EyeOff,
HelpCircle,
} from '@wso2/oxygen-ui-icons-react';
import { FormattedMessage, useIntl } from 'react-intl';
@@ -67,6 +64,7 @@ import ExternalServersCreateForm from './ExternalServersCreateForm';
import ExternalServersValidationDetails from './ExternalServersValidationDetails';
import type { EndpointValidationResponse } from './externalServersValidationTypes';
import { getErrorMessage, getFieldErrors } from '../../../../utils/apiError';
+import SecretValueField from '../../../../Components/common/SecretValueField';
// Backend field names (from CreateMCPServerRequest) mapped onto this form's state keys.
// "displayName" maps to the server name field; the rest match one-to-one.
@@ -144,7 +142,6 @@ export default function ExternalServersNew(): JSX.Element {
const [isCreateStep, setIsCreateStep] = useState(false);
const [authHeaderName, setAuthHeaderName] = useState('');
const [authHeaderValue, setAuthHeaderValue] = useState('');
- const [showAuthHeaderValue, setShowAuthHeaderValue] = useState(false);
const [serverName, setServerName] = useState('');
const [serverVersion, setServerVersion] = useState('');
const [serverDescription, setServerDescription] = useState('');
@@ -538,44 +535,13 @@ export default function ExternalServersNew(): JSX.Element {
defaultMessage="Value"
/>
-
- setAuthHeaderValue(event.target.value)
- }
- slotProps={{
- input: {
- endAdornment: (
-
-
- setShowAuthHeaderValue(
- (prev) => !prev
- )
- }
- aria-label={
- showAuthHeaderValue
- ? 'Hide header value'
- : 'Show header value'
- }
- >
- {showAuthHeaderValue ? (
-
- ) : (
-
- )}
-
-
- ),
- },
- }}
+ onChange={setAuthHeaderValue}
/>
diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersOverview.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersOverview.tsx
index 5d0f6d91e2..0928686b43 100644
--- a/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersOverview.tsx
+++ b/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersOverview.tsx
@@ -58,8 +58,6 @@ import {
Clock,
Copy,
Edit,
- Eye,
- EyeOff,
Trash2,
} from '@wso2/oxygen-ui-icons-react';
import { FormattedMessage } from 'react-intl';
@@ -110,6 +108,7 @@ import {
} from '../../../../utils/artifactDeletion';
import { useAppAuth } from '../../../../contexts/AppAuthContext';
import { NO_PERMISSION_TOOLTIP, SCOPES } from '../../../../auth/permissions';
+import SecretValueField from '../../../../Components/common/SecretValueField';
function getInitials(name: string): string {
const words = name.trim().split(/\s+/);
@@ -261,7 +260,6 @@ export default function ExternalServersOverview(): JSX.Element {
const [endpointUrl, setEndpointUrl] = useState('');
const [authHeaderName, setAuthHeaderName] = useState('');
const [authHeaderValue, setAuthHeaderValue] = useState('');
- const [showAuthHeaderValue, setShowAuthHeaderValue] = useState(false);
const [isCredentialMasked, setIsCredentialMasked] = useState(false);
const [hasCredentialChanged, setHasCredentialChanged] = useState(false);
const [isRefetching, setIsRefetching] = useState(false);
@@ -1306,9 +1304,7 @@ export default function ExternalServersOverview(): JSX.Element {
Value
- {
@@ -1318,38 +1314,11 @@ export default function ExternalServersOverview(): JSX.Element {
setHasCredentialChanged(false);
}
}}
- onChange={(event) => {
- setAuthHeaderValue(event.target.value);
+ onChange={(nextValue) => {
+ setAuthHeaderValue(nextValue);
setHasCredentialChanged(true);
}}
- slotProps={{
- htmlInput: {
- 'data-testid': 'backend-connection-auth-value',
- },
- input: {
- endAdornment: (
-
-
- setShowAuthHeaderValue((prev) => !prev)
- }
- aria-label={
- showAuthHeaderValue
- ? 'Hide header value'
- : 'Show header value'
- }
- >
- {showAuthHeaderValue ? (
-
- ) : (
-
- )}
-
-
- ),
- },
- }}
+ data-testid="backend-connection-auth-value"
/>
diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyNew.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyNew.tsx
index d04cff06a4..54eefcd639 100644
--- a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyNew.tsx
+++ b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyNew.tsx
@@ -72,6 +72,7 @@ import { logger } from '../../../../utils/logger';
import { getErrorMessage, getFieldErrors } from '../../../../utils/apiError';
import { useAppAuth } from '../../../../contexts/AppAuthContext';
import { NO_PERMISSION_TOOLTIP, SCOPES } from '../../../../auth/permissions';
+import SecretValueField from '../../../../Components/common/SecretValueField';
type FormState = {
name: string;
@@ -806,18 +807,14 @@ function LLMProxyNewContent({
gap: 1,
}}
>
-
- setManualApiKeyValue(event.target.value)
- }
+ onChange={(nextValue) => setManualApiKeyValue(nextValue)}
data-cyid="proxy-api-key-input"
/>
{isManualKeyReady && (
diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyProviderTab.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyProviderTab.tsx
index b121a3ee92..054b11d39b 100644
--- a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyProviderTab.tsx
+++ b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyProviderTab.tsx
@@ -24,7 +24,6 @@ import {
MenuItem,
Select,
Stack,
- TextField,
Typography,
} from '@wso2/oxygen-ui';
import { useLLMProviders } from '../../../../contexts/llmProvider';
@@ -36,6 +35,7 @@ import { logger } from '../../../../utils/logger';
import useAIWorkspaceSnackbar from '../../../../hooks/aiWorkspaceSnackbar';
import type { LLMProvider, ProxyApiKeySecurity } from '../../../../utils/types';
import { FormattedMessage } from 'react-intl';
+import SecretValueField from '../../../../Components/common/SecretValueField';
/**
* Provider tab – lets the user select / change the LLM Service Provider
@@ -269,13 +269,11 @@ export default function LLMProxyProviderTab() {
defaultMessage={'API Key'}
/>
- handleApiKeyChange(e.target.value)}
- fullWidth
+ onChange={handleApiKeyChange}
/>
diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/secret/CreateSecret.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/secret/CreateSecret.tsx
new file mode 100644
index 0000000000..02079b25af
--- /dev/null
+++ b/portals/ai-workspace/src/pages/appShell/appShellPages/secret/CreateSecret.tsx
@@ -0,0 +1,283 @@
+/*
+ * 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.
+ */
+
+import React, { useState } from 'react';
+import { Link as RouterLink, useNavigate } from 'react-router-dom';
+import {
+ Box,
+ Button,
+ Chip,
+ FormControl,
+ FormLabel,
+ Grid,
+ IconButton,
+ InputAdornment,
+ PageContent,
+ PageTitle,
+ Stack,
+ TextField,
+ Typography,
+} from '@wso2/oxygen-ui';
+import { ChevronLeft, Eye, EyeOff } from '@wso2/oxygen-ui-icons-react';
+import { FormattedMessage } from 'react-intl';
+import { createSecret, type SecretType } from '../../../../apis/secretApis';
+import { useAppShell } from '../../../../contexts/AppShellContext';
+import { buildOrgPath } from '../../../../utils/projectRouting';
+import useAIWorkspaceSnackbar from '../../../../hooks/aiWorkspaceSnackbar';
+import useIsMounted from '../../../../hooks/useIsMounted';
+import { getErrorMessage } from '../../../../utils/apiError';
+
+const MAX_NAME_LENGTH = 120;
+const MAX_DESCRIPTION_LENGTH = 300;
+const MAX_HANDLE_LENGTH = 40; // matches the `handle VARCHAR(40)` column enforced server-side
+const HANDLE_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
+
+function toHandle(value: string): string {
+ return value
+ .toLowerCase()
+ .trim()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+}
+
+export default function CreateSecret(): React.JSX.Element {
+ const navigate = useNavigate();
+ const { currentOrganization } = useAppShell();
+ const showSnackbar = useAIWorkspaceSnackbar();
+ const isMounted = useIsMounted();
+
+ const [displayName, setDisplayName] = useState('');
+ const [handle, setHandle] = useState('');
+ const [handleTouched, setHandleTouched] = useState(false);
+ const [description, setDescription] = useState('');
+ const [value, setValue] = useState('');
+ const [valueVisible, setValueVisible] = useState(false);
+ // Fixed until certificate secrets are implemented — see the Type field below.
+ const type: SecretType = 'GENERIC';
+ const [nameTouched, setNameTouched] = useState(false);
+ const [valueTouched, setValueTouched] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const listPath = buildOrgPath(currentOrganization, '/settings/secrets');
+
+ const handleNameChange = (nextName: string) => {
+ setDisplayName(nextName);
+ if (!handleTouched) setHandle(toHandle(nextName));
+ };
+
+ const isNameValid = displayName.trim().length > 0 && displayName.length <= MAX_NAME_LENGTH;
+ const isDescriptionValid = description.length <= MAX_DESCRIPTION_LENGTH;
+ const isHandleValid = HANDLE_PATTERN.test(handle) && handle.length <= MAX_HANDLE_LENGTH;
+ const isValueValid = value.trim().length > 0;
+ const isFormValid = isNameValid && isHandleValid && isDescriptionValid && isValueValid;
+
+ const handleSubmit = async (event?: React.FormEvent) => {
+ if (event) event.preventDefault();
+ if (!isFormValid || isSubmitting) return;
+
+ setIsSubmitting(true);
+ try {
+ const secret = await createSecret({
+ id: handle,
+ displayName: displayName.trim(),
+ description: description.trim() || undefined,
+ value,
+ type,
+ });
+ if (!isMounted()) return;
+ showSnackbar('Secret created successfully.', 'success');
+ navigate(`${listPath}/${secret.id}`);
+ } catch (err) {
+ if (!isMounted()) return;
+ showSnackbar(getErrorMessage(err, 'Failed to create secret.'), 'error');
+ } finally {
+ if (isMounted()) setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+ }>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Type
+
+
+
+ Certificate secrets are coming soon.
+
+
+
+
+
+
+
+ Display Name
+ handleNameChange(e.target.value)}
+ onBlur={() => setNameTouched(true)}
+ placeholder="e.g. WSO2 OpenAI API Key"
+ error={nameTouched && !isNameValid}
+ helperText={
+ nameTouched && displayName.trim().length === 0
+ ? 'Display name is required.'
+ : displayName.length > MAX_NAME_LENGTH
+ ? `Must not exceed ${MAX_NAME_LENGTH} characters.`
+ : ''
+ }
+ data-cyid="secret-name-input"
+ />
+
+
+
+
+
+ Handle
+ {
+ setHandleTouched(true);
+ setHandle(e.target.value.toLowerCase());
+ }}
+ placeholder="wso2-openai-key"
+ slotProps={{
+ input: { style: { fontFamily: 'monospace' } },
+ htmlInput: { maxLength: MAX_HANDLE_LENGTH },
+ }}
+ error={handle.length > 0 && !isHandleValid}
+ helperText={
+ handle.length > 0 && !isHandleValid
+ ? 'Lowercase letters, numbers, and single hyphens only.'
+ : 'Immutable after creation. Referenced as {{ secret "' + (handle || 'handle') + '" }}.'
+ }
+ data-cyid="secret-handle-input"
+ />
+
+
+
+
+
+ Description
+ setDescription(e.target.value)}
+ placeholder="e.g. Gemini 1.5 Pro — production project"
+ error={!isDescriptionValid}
+ helperText={
+ !isDescriptionValid
+ ? `Must not exceed ${MAX_DESCRIPTION_LENGTH} characters (${description.length}/${MAX_DESCRIPTION_LENGTH}).`
+ : ''
+ }
+ />
+
+
+
+ {/* TODO(certificate-secrets): once implemented, branch on `type` here —
+ a file-upload control for CERTIFICATE, this text field for GENERIC. */}
+
+
+ Value
+ setValue(e.target.value)}
+ onBlur={() => setValueTouched(true)}
+ placeholder="Paste your credential here"
+ autoComplete="new-password"
+ error={valueTouched && !isValueValid}
+ helperText={valueTouched && !isValueValid ? 'A value is required.' : ''}
+ slotProps={{
+ input: {
+ endAdornment: (
+
+ setValueVisible((v) => !v)}
+ aria-label={valueVisible ? 'Hide value' : 'Show value'}
+ >
+ {valueVisible ? : }
+
+
+ ),
+ },
+ }}
+ data-cyid="secret-value-input"
+ />
+
+
+
+
+
+
+
+
+
+ {isSubmitting ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/secret/DeleteSecretDialog.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/secret/DeleteSecretDialog.tsx
new file mode 100644
index 0000000000..b021ca445b
--- /dev/null
+++ b/portals/ai-workspace/src/pages/appShell/appShellPages/secret/DeleteSecretDialog.tsx
@@ -0,0 +1,144 @@
+/*
+ * 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.
+ */
+
+import React, { useEffect, useState } from 'react';
+import {
+ Box,
+ Button,
+ Chip,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogContentText,
+ DialogTitle,
+ Stack,
+ Typography,
+} from '@wso2/oxygen-ui';
+import { deleteSecret, SecretConflictError, type SecretMetadata, type SecretReference } from '../../../../apis/secretApis';
+import useAIWorkspaceSnackbar from '../../../../hooks/aiWorkspaceSnackbar';
+import useIsMounted from '../../../../hooks/useIsMounted';
+import { getErrorMessage } from '../../../../utils/apiError';
+
+interface DeleteSecretDialogProps {
+ secret: SecretMetadata | null;
+ onClose: () => void;
+ /** Called after the secret has actually been permanently deleted on the server. */
+ onDeleted: (handle: string) => void;
+}
+
+/**
+ * Deleting a secret permanently removes it on the backend and is blocked with a
+ * 409 if the secret is still referenced by another resource. This dialog handles
+ * both outcomes: a plain confirmation, or — once blocked — the list of
+ * referencing resources returned on the conflict.
+ */
+export default function DeleteSecretDialog({ secret, onClose, onDeleted }: DeleteSecretDialogProps): React.JSX.Element {
+ const showSnackbar = useAIWorkspaceSnackbar();
+ const isMounted = useIsMounted();
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [conflictRefs, setConflictRefs] = useState(null);
+
+ useEffect(() => {
+ setConflictRefs(null);
+ }, [secret?.id]);
+
+ const handleConfirm = async () => {
+ if (!secret) return;
+ setIsDeleting(true);
+ try {
+ await deleteSecret(secret.id);
+ if (!isMounted()) return;
+ showSnackbar(`"${secret.displayName}" was deleted.`, 'success');
+ onDeleted(secret.id);
+ onClose();
+ } catch (error) {
+ if (!isMounted()) return;
+ if (error instanceof SecretConflictError) {
+ setConflictRefs(error.conflict.references);
+ } else {
+ showSnackbar(getErrorMessage(error, 'Failed to delete secret.'), 'error');
+ onClose();
+ }
+ } finally {
+ if (isMounted()) setIsDeleting(false);
+ }
+ };
+
+ return (
+
+ {conflictRefs ? (
+ <>
+ Can't delete this secret
+
+
+ {secret?.displayName} is still referenced by {conflictRefs.length}{' '}
+ {conflictRefs.length === 1 ? 'resource' : 'resources'}. Remove the reference from each one below before
+ deleting it again.
+
+
+ {conflictRefs.map((ref) => (
+
+
+
+
+ {ref.name}
+
+
+
+ ))}
+
+
+
+
+ Got it
+
+
+ >
+ ) : (
+ <>
+ Delete secret
+
+
+ Delete {secret?.displayName} ? This permanently deletes the secret and cannot be
+ undone. Deletion is blocked while any resource still references it.
+
+
+
+
+ Cancel
+
+ void handleConfirm()} disabled={isDeleting}>
+ {isDeleting ? 'Deleting…' : 'Delete'}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/secret/RotateSecret.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/secret/RotateSecret.tsx
new file mode 100644
index 0000000000..7acfc0780d
--- /dev/null
+++ b/portals/ai-workspace/src/pages/appShell/appShellPages/secret/RotateSecret.tsx
@@ -0,0 +1,236 @@
+/*
+ * 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.
+ */
+
+import React, { useEffect, useRef, useState } from 'react';
+import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom';
+import {
+ Alert,
+ Box,
+ Button,
+ FormControl,
+ FormLabel,
+ Grid,
+ IconButton,
+ InputAdornment,
+ PageContent,
+ PageTitle,
+ Skeleton,
+ Stack,
+ TextField,
+} from '@wso2/oxygen-ui';
+import { ChevronLeft, Eye, EyeOff } from '@wso2/oxygen-ui-icons-react';
+import { FormattedMessage } from 'react-intl';
+import { getSecret, updateSecret, type SecretMetadata } from '../../../../apis/secretApis';
+import { useAppShell } from '../../../../contexts/AppShellContext';
+import { buildOrgPath } from '../../../../utils/projectRouting';
+import useAIWorkspaceSnackbar from '../../../../hooks/aiWorkspaceSnackbar';
+import useIsMounted from '../../../../hooks/useIsMounted';
+import { getErrorMessage } from '../../../../utils/apiError';
+import ErrorAlert from '../../../../Components/common/ErrorAlert';
+
+export default function RotateSecret(): React.JSX.Element {
+ const { handle } = useParams<{ handle: string }>();
+ const navigate = useNavigate();
+ const { currentOrganization } = useAppShell();
+ const showSnackbar = useAIWorkspaceSnackbar();
+ const isMounted = useIsMounted();
+
+ const [secret, setSecret] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [loadError, setLoadError] = useState(null);
+
+ const [value, setValue] = useState('');
+ const [valueVisible, setValueVisible] = useState(false);
+ const [displayName, setDisplayName] = useState('');
+ const [description, setDescription] = useState('');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const listPath = buildOrgPath(currentOrganization, '/settings/secrets');
+ const overviewPath = handle ? `${listPath}/${handle}` : listPath;
+
+ // Guards against out-of-order responses: if the handle changes while a fetch for
+ // the previous handle is still in flight, that response must not overwrite the
+ // form fields now shown for the newly-active handle — otherwise a submit could
+ // send the previous secret's display name/description to the current handle.
+ const requestIdRef = useRef(0);
+
+ const fetchSecret = async () => {
+ if (!handle) return;
+ const requestId = ++requestIdRef.current;
+ try {
+ setIsLoading(true);
+ setLoadError(null);
+ const response = await getSecret(handle);
+ if (!isMounted() || requestIdRef.current !== requestId) return; // superseded or unmounted
+ setSecret(response);
+ setDisplayName(response.displayName);
+ setDescription(response.description ?? '');
+ } catch (err) {
+ if (!isMounted() || requestIdRef.current !== requestId) return;
+ setLoadError(err instanceof Error ? err : new Error('Failed to load secret.'));
+ } finally {
+ if (isMounted() && requestIdRef.current === requestId) setIsLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ void fetchSecret();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [handle]);
+
+ const handleSubmit = async (event?: React.FormEvent) => {
+ if (event) event.preventDefault();
+ if (!handle || isSubmitting) return;
+
+ setIsSubmitting(true);
+ try {
+ await updateSecret(handle, {
+ value: value.trim(),
+ name: displayName.trim() || undefined,
+ description: description.trim() || undefined,
+ });
+ if (!isMounted()) return;
+ showSnackbar('Secret updated successfully.', 'success');
+ navigate(overviewPath);
+ } catch (err) {
+ if (!isMounted()) return;
+ showSnackbar(getErrorMessage(err, 'Failed to update secret.'), 'error');
+ } finally {
+ if (isMounted()) setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+ }>
+ Back to secret
+
+
+ {isLoading ? (
+
+
+
+
+ ) : loadError || !secret ? (
+
+
+
+ ) : (
+ <>
+
+
+
+
+
+
+
+
+
+
+ This will get automatically synced to the gateway.
+
+
+
+
+
+
+ Display Name
+ setDisplayName(e.target.value)} />
+
+
+
+
+
+ Handle
+
+
+
+
+
+
+ Description
+ setDescription(e.target.value)}
+ placeholder="Reason for rotation, expiry info…"
+ />
+
+
+
+
+
+ Value
+ setValue(e.target.value)}
+ placeholder="Paste the new credential"
+ autoComplete="new-password"
+ helperText="Leave blank to keep the current value. Providing a new value reactivates a deprecated secret."
+ slotProps={{
+ input: {
+ endAdornment: (
+
+ setValueVisible((v) => !v)}
+ aria-label={valueVisible ? 'Hide value' : 'Show value'}
+ >
+ {valueVisible ? : }
+
+
+ ),
+ },
+ }}
+ data-cyid="rotate-secret-value-input"
+ />
+
+
+
+
+
+
+ Cancel
+
+
+ {isSubmitting ? 'Updating…' : 'Update secret'}
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/secret/SecretOverview.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/secret/SecretOverview.tsx
new file mode 100644
index 0000000000..8bf15bfdf2
--- /dev/null
+++ b/portals/ai-workspace/src/pages/appShell/appShellPages/secret/SecretOverview.tsx
@@ -0,0 +1,366 @@
+/*
+ * 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.
+ */
+
+import React, { useEffect, useRef, useState } from 'react';
+import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom';
+import { Avatar, Box, Button, Card, Chip, Divider, IconButton, PageContent, Skeleton, Stack, Typography } from '@wso2/oxygen-ui';
+import { ChevronLeft, Clock, Copy, KeyRound, RotateCw, Trash2 } from '@wso2/oxygen-ui-icons-react';
+import { getSecret, getSecretUsages, type SecretMetadata, type SecretReference } from '../../../../apis/secretApis';
+import { useAppShell } from '../../../../contexts/AppShellContext';
+import { buildOrgPath } from '../../../../utils/projectRouting';
+import useAIWorkspaceSnackbar from '../../../../hooks/aiWorkspaceSnackbar';
+import ErrorAlert from '../../../../Components/common/ErrorAlert';
+import DeleteSecretDialog from './DeleteSecretDialog';
+import NoData from '../../../../assets/images/NoData.svg';
+
+// Maps the backend's artifact-type identifiers to display labels for the
+// Usages section below.
+const REFERENCE_TYPE_LABELS: Record = {
+ RestApi: 'REST API',
+ LlmProvider: 'LLM Provider',
+ LlmProxy: 'LLM Proxy',
+ Mcp: 'MCP Proxy',
+};
+
+function formatReferenceType(type: string): string {
+ return REFERENCE_TYPE_LABELS[type] ?? type;
+}
+
+// Matches the relative-time convention used elsewhere in the app (e.g.
+// contexts/llmProvider/LLMProviderContext.tsx's formatRelativeTime).
+function formatRelativeTime(value?: string): string {
+ if (!value) return 'Unknown';
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return 'Unknown';
+
+ const diffSeconds = Math.abs(Date.now() - date.getTime()) / 1000;
+ if (diffSeconds < 45) return 'Just now';
+ if (diffSeconds < 90) return '1 minute ago';
+
+ const diffMinutes = diffSeconds / 60;
+ if (diffMinutes < 45) return `${Math.round(diffMinutes)} minutes ago`;
+ if (diffMinutes < 90) return '1 hour ago';
+
+ const diffHours = diffMinutes / 60;
+ if (diffHours < 22) return `${Math.round(diffHours)} hours ago`;
+ if (diffHours < 36) return '1 day ago';
+
+ const diffDays = diffHours / 24;
+ if (diffDays < 26) return `${Math.round(diffDays)} days ago`;
+ if (diffDays < 45) return '1 month ago';
+
+ const diffMonths = diffDays / 30;
+ if (diffMonths < 11) return `${Math.round(diffMonths)} months ago`;
+ const diffYears = diffDays / 365;
+ return `${Math.round(diffYears)} year${Math.round(diffYears) === 1 ? '' : 's'} ago`;
+}
+
+function KeyValueRow({ label, children }: { label: string; children: React.ReactNode }): React.JSX.Element {
+ return (
+
+
+ {label}
+
+ {children}
+
+ );
+}
+
+export default function SecretOverview(): React.JSX.Element {
+ const { handle } = useParams<{ handle: string }>();
+ const navigate = useNavigate();
+ const { currentOrganization } = useAppShell();
+ const showSnackbar = useAIWorkspaceSnackbar();
+
+ const [secret, setSecret] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+
+ const [usages, setUsages] = useState([]);
+ const [usagesLoading, setUsagesLoading] = useState(true);
+ const [usagesError, setUsagesError] = useState(null);
+
+ const listPath = buildOrgPath(currentOrganization, '/settings/secrets');
+
+ const handleCopyHandle = async (value: string) => {
+ await navigator.clipboard.writeText(value);
+ showSnackbar('Handle copied to clipboard.', 'success');
+ };
+
+ // Guards against out-of-order responses: if the handle changes while a fetch for
+ // the previous handle is still in flight, that response must not overwrite the
+ // metadata now on screen for the newly-active handle.
+ const requestIdRef = useRef(0);
+
+ const fetchSecret = async () => {
+ if (!handle) return;
+ const requestId = ++requestIdRef.current;
+ try {
+ setIsLoading(true);
+ setError(null);
+ const response = await getSecret(handle);
+ if (requestIdRef.current !== requestId) return; // superseded by a newer request
+ setSecret(response);
+ } catch (err) {
+ if (requestIdRef.current !== requestId) return;
+ setError(err instanceof Error ? err : new Error('Failed to load secret.'));
+ } finally {
+ if (requestIdRef.current === requestId) setIsLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ void fetchSecret();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [handle]);
+
+ // Separate request-id guard from fetchSecret's: the two fetches are independent
+ // and must not clobber each other's staleness tracking.
+ const usagesRequestIdRef = useRef(0);
+
+ const fetchUsages = async () => {
+ if (!handle) return;
+ const requestId = ++usagesRequestIdRef.current;
+ try {
+ setUsagesLoading(true);
+ setUsagesError(null);
+ const response = await getSecretUsages(handle);
+ if (usagesRequestIdRef.current !== requestId) return;
+ setUsages(response);
+ } catch (err) {
+ if (usagesRequestIdRef.current !== requestId) return;
+ setUsagesError(err instanceof Error ? err : new Error('Failed to load usages.'));
+ } finally {
+ if (usagesRequestIdRef.current === requestId) setUsagesLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ void fetchUsages();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [handle]);
+
+ return (
+
+ }>
+ Back to Secrets
+
+
+ {isLoading ? (
+
+
+
+
+
+ ) : error || !secret ? (
+
+
+
+ ) : (
+
+ {/* Header card */}
+
+
+
+
+
+
+
+
+
+ {secret.displayName}
+
+
+
+ {secret.description && (
+
+ {secret.description}
+
+ )}
+
+
+ Last updated :
+
+
+
+ {formatRelativeTime(secret.updatedAt)}
+
+
+ {secret.createdBy && (
+
+ Created by: {secret.createdBy}
+
+ )}
+
+
+
+
+ }
+ onClick={() => navigate(`${listPath}/${secret.id}/rotate`)}
+ data-cyid="rotate-secret-button"
+ fullWidth
+ >
+ Update secret
+
+
+ setDeleteTarget(secret)}
+ aria-label={`Delete ${secret.displayName}`}
+ data-cyid="delete-secret-button"
+ >
+
+
+
+
+
+
+
+
+
+
+
+ {secret.id}
+ void handleCopyHandle(secret.id)}
+ aria-label="Copy handle"
+ sx={{ flexShrink: 0 }}
+ >
+
+
+
+
+
+
+ ••••••••••••
+
+ write-once — never returned after creation
+
+
+
+
+
+
+ {/* Usages */}
+
+
+
+ Usages
+
+ {usagesLoading ? (
+
+
+
+
+ ) : usagesError ? (
+
+ ) : usages.length === 0 ? (
+
+
+
+ No resources currently reference this secret.
+
+
+ ) : (
+
+ {usages.map((ref) => (
+
+
+ {ref.name || ref.handle}
+
+
+
+ ))}
+
+ )}
+
+
+
+ )}
+
+ setDeleteTarget(null)}
+ onDeleted={() => navigate(listPath)}
+ />
+
+ );
+}
diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/secret/SecretsList.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/secret/SecretsList.tsx
new file mode 100644
index 0000000000..bb6e7bd9a3
--- /dev/null
+++ b/portals/ai-workspace/src/pages/appShell/appShellPages/secret/SecretsList.tsx
@@ -0,0 +1,332 @@
+/*
+ * 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.
+ */
+
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { Link as RouterLink, useNavigate } from 'react-router-dom';
+import {
+ Box,
+ Button,
+ Card,
+ Chip,
+ IconButton,
+ InputAdornment,
+ PageContent,
+ PageTitle,
+ Skeleton,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TablePagination,
+ TableRow,
+ TextField,
+ Typography,
+} from '@wso2/oxygen-ui';
+import { KeyRound, Plus, Search, Trash2 } from '@wso2/oxygen-ui-icons-react';
+import { FormattedMessage } from 'react-intl';
+import { listSecrets, type SecretMetadata } from '../../../../apis/secretApis';
+import { useAppShell } from '../../../../contexts/AppShellContext';
+import { buildOrgPath } from '../../../../utils/projectRouting';
+import { getErrorMessage } from '../../../../utils/apiError';
+import ErrorAlert from '../../../../Components/common/ErrorAlert';
+import DeleteSecretDialog from './DeleteSecretDialog';
+
+const ROWS_PER_PAGE_OPTIONS = [10, 25, 50];
+
+function formatDate(value?: string): string {
+ if (!value) return '—';
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return '—';
+ return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
+}
+
+export default function SecretsList(): React.JSX.Element {
+ const navigate = useNavigate();
+ const { currentOrganization } = useAppShell();
+
+ const [secrets, setSecrets] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [page, setPage] = useState(0);
+ const [rowsPerPage, setRowsPerPage] = useState(ROWS_PER_PAGE_OPTIONS[0]);
+
+ const organizationId = currentOrganization?.uuid ?? '';
+ const basePath = buildOrgPath(currentOrganization, '/settings/secrets');
+
+ // Guards against out-of-order responses (e.g. two concurrent fetches from a
+ // double-click on Retry, or — defensively — an organization change): a response
+ // for a superseded request must not replace the list a more recent request
+ // already populated. Each mount gets its own counter, since OrgShell remounts
+ // this whole page (via a key on the organization id) on every organization switch.
+ const requestIdRef = useRef(0);
+
+ const fetchSecrets = async () => {
+ const requestId = ++requestIdRef.current;
+ try {
+ setIsLoading(true);
+ setError(null);
+ const response = await listSecrets({ limit: 100 });
+ if (requestIdRef.current !== requestId) return; // superseded by a newer request
+ setSecrets(response.list ?? []);
+ } catch (err) {
+ if (requestIdRef.current !== requestId) return;
+ setError(err instanceof Error ? err : new Error(getErrorMessage(err, 'Failed to load secrets.')));
+ } finally {
+ if (requestIdRef.current === requestId) setIsLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (!organizationId) return;
+ void fetchSecrets();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [organizationId]);
+
+ const filteredSecrets = useMemo(() => {
+ const query = searchQuery.trim().toLowerCase();
+ if (!query) return secrets;
+ return secrets.filter((secret) =>
+ [secret.displayName, secret.id, secret.description].filter(Boolean).join(' ').toLowerCase().includes(query)
+ );
+ }, [searchQuery, secrets]);
+
+ useEffect(() => {
+ setPage(0);
+ }, [rowsPerPage, searchQuery]);
+
+ // Clamps to the last valid page when the list shrinks out from under the current
+ // page (e.g. deleting the only secret on the last page), instead of rendering blank.
+ const pageCount = Math.max(1, Math.ceil(filteredSecrets.length / rowsPerPage));
+ const safePage = Math.min(page, pageCount - 1);
+
+ const pageSecrets = useMemo(
+ () => filteredSecrets.slice(safePage * rowsPerPage, safePage * rowsPerPage + rowsPerPage),
+ [filteredSecrets, safePage, rowsPerPage]
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {secrets.length > 0 && (
+ }
+ data-cyid="create-secret-button"
+ >
+
+
+ )}
+
+
+ {error && !isLoading && (
+
+
+
+ )}
+
+ {isLoading ? (
+
+
+
+
+
+ Name
+ Handle
+ Type
+ Last updated
+ Actions
+
+
+
+ {[...Array(3)].map((_, index) => (
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+ ) : !error && secrets.length === 0 ? (
+
+
+
+
+
+
+
+
+
+ }
+ data-cyid="create-secret-button"
+ >
+
+
+
+
+ ) : !error ? (
+ <>
+
+ setSearchQuery(event.target.value)}
+ slotProps={{
+ input: {
+ startAdornment: (
+
+
+
+ ),
+ },
+ }}
+ />
+
+
+
+
+
+
+ Name
+ Handle
+ Type
+ Last updated
+ Actions
+
+
+
+ {filteredSecrets.length === 0 ? (
+
+
+
+ No secrets match your search.
+
+
+
+ ) : (
+ pageSecrets.map((secret) => (
+ navigate(`${basePath}/${secret.id}`)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ navigate(`${basePath}/${secret.id}`);
+ }
+ }}
+ aria-label={`View ${secret.displayName}`}
+ sx={{ cursor: 'pointer' }}
+ data-cyid={`secret-row-${secret.id}`}
+ >
+ {secret.displayName}
+
+
+ {secret.id}
+
+
+
+
+
+ {formatDate(secret.updatedAt)}
+
+ {
+ event.stopPropagation();
+ setDeleteTarget(secret);
+ }}
+ aria-label={`Delete ${secret.displayName}`}
+ data-cyid={`delete-secret-${secret.id}`}
+ >
+
+
+
+
+ ))
+ )}
+
+
+
+ {filteredSecrets.length > 0 && (
+ setPage(nextPage)}
+ rowsPerPage={rowsPerPage}
+ onRowsPerPageChange={(event) => setRowsPerPage(parseInt(event.target.value, 10))}
+ rowsPerPageOptions={ROWS_PER_PAGE_OPTIONS}
+ />
+ )}
+
+ >
+ ) : null}
+
+ setDeleteTarget(null)}
+ onDeleted={(handle) => setSecrets((prev) => prev.filter((s) => s.id !== handle))}
+ />
+
+ );
+}
diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/ProviderTemplateFormFields.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/ProviderTemplateFormFields.tsx
index cfa7737d32..1af762d408 100644
--- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/ProviderTemplateFormFields.tsx
+++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/ProviderTemplateFormFields.tsx
@@ -26,8 +26,6 @@ import {
FormHelperText,
FormLabel,
Grid,
- IconButton,
- InputAdornment,
MenuItem,
Select,
Stack,
@@ -36,16 +34,14 @@ import {
Box,
CircularProgress,
} from '@wso2/oxygen-ui';
-import { Eye, EyeOff } from '@wso2/oxygen-ui-icons-react';
import type { ProviderTemplate } from '../../../../../utils/types';
import type { FormState } from './serviceProviderTypes';
import { FormattedMessage } from 'react-intl';
+import SecretValueField from '../../../../../Components/common/SecretValueField';
type ProviderTemplateFormFieldsProps = {
formState: FormState;
setFormState: React.Dispatch>;
- showCredential: boolean;
- setShowCredential: React.Dispatch>;
template?: ProviderTemplate | null;
isLoading: boolean;
error: Error | null;
@@ -71,8 +67,6 @@ const buildAutoContext = (name: string): string => {
export default function ProviderTemplateFormFields({
formState,
setFormState,
- showCredential,
- setShowCredential,
template,
isLoading,
error,
@@ -349,44 +343,16 @@ export default function ProviderTemplateFormFields({
defaultMessage={'API Key'}
/>
-
+ onChange={(nextValue) =>
setFormState((prev) => ({
...prev,
- upstreamAuthValue: e.target.value,
+ upstreamAuthValue: nextValue,
}))
}
- type={showCredential ? 'text' : 'password'}
data-cyid="provider-api-key-input"
- slotProps={{
- input: {
- endAdornment: (
-
- setShowCredential((prev) => !prev)}
- aria-label={
- showCredential ? 'Hide credentials' : 'Show credentials'
- }
- >
- {showCredential ? (
-
- ) : (
-
- )}
-
-
- ),
- },
- }}
placeholder="Enter your API key or token (optional)"
- // helperText={
- // template?.metadata?.auth?.valuePrefix
- // ? `Will be prefixed with: ${template.metadata.auth.valuePrefix}`
- // : 'Your authentication credential for the upstream provider'
- // }
/>
diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderConnectionTab.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderConnectionTab.tsx
index 3267bfe930..c0db22a5f1 100644
--- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderConnectionTab.tsx
+++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderConnectionTab.tsx
@@ -21,14 +21,11 @@ import {
FormControl,
FormHelperText,
FormLabel,
- IconButton,
- InputAdornment,
MenuItem,
Select,
Stack,
TextField,
} from '@wso2/oxygen-ui';
-import { Eye, EyeOff } from '@wso2/oxygen-ui-icons-react';
import { useLLMProvider } from '../../../../contexts/llmProvider';
import { useAppShell } from '../../../../contexts/AppShellContext';
import { PLATFORM_API_BASE_URL } from '../../../../paths';
@@ -37,6 +34,7 @@ import * as providerTemplateApis from '../../../../apis/providerTemplateApis';
import type { ProviderTemplate } from '../../../../utils/types';
import { logger } from '../../../../utils/logger';
import { isValidHttpUrl } from '../../../../utils/providerTemplateFields';
+import SecretValueField from '../../../../Components/common/SecretValueField';
const MASKED_CREDENTIAL_VALUE = '******';
@@ -51,7 +49,6 @@ export default function ServiceProviderConnectionTab() {
const [credentialValue, setCredentialValue] = useState('');
const [isCredentialMasked, setIsCredentialMasked] = useState(false);
const [hasCredentialChanged, setHasCredentialChanged] = useState(false);
- const [showCredential, setShowCredential] = useState(false);
const [providerTemplate, setProviderTemplate] =
useState(null);
const showSnackbar = useAIWorkspaceSnackbar();
@@ -382,9 +379,8 @@ export default function ServiceProviderConnectionTab() {
Credentials
- {
@@ -394,8 +390,7 @@ export default function ServiceProviderConnectionTab() {
setHasCredentialChanged(false);
}
}}
- onChange={(e) => {
- const nextValue = e.target.value;
+ onChange={(nextValue) => {
setCredentialValue(nextValue);
setHasCredentialChanged(true);
if (isDraftMode && !isCredentialMasked) {
@@ -411,30 +406,6 @@ export default function ServiceProviderConnectionTab() {
void handleUpdateCredential();
}
}}
- slotProps={{
- input: {
- endAdornment: (
-
- setShowCredential((prev) => !prev)}
- aria-label={
- showCredential
- ? 'Hide credentials'
- : 'Show credentials'
- }
- >
- {showCredential ? (
-
- ) : (
-
- )}
-
-
- ),
- },
- }}
/>
>
diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderNew.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderNew.tsx
index 754348780f..0d8f1d5eda 100644
--- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderNew.tsx
+++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderNew.tsx
@@ -77,8 +77,6 @@ const FIELD_NAME_MAP: Partial> = {
type TemplateBasedFormFieldsContainerProps = {
formState: FormState;
setFormState: React.Dispatch>;
- showCredential: boolean;
- setShowCredential: React.Dispatch>;
setOpenapiSpec: React.Dispatch>;
fieldErrors: Partial>;
};
@@ -86,8 +84,6 @@ type TemplateBasedFormFieldsContainerProps = {
function TemplateBasedFormFieldsContainer({
formState,
setFormState,
- showCredential,
- setShowCredential,
setOpenapiSpec,
fieldErrors,
}: TemplateBasedFormFieldsContainerProps) {
@@ -143,8 +139,6 @@ function TemplateBasedFormFieldsContainer({
>>({});
- const [showCredential, setShowCredential] = useState(false);
const [guardrails, setGuardrails] = useState([]);
const [guardrailDrawerOpen, setGuardrailDrawerOpen] = useState(false);
const [selectedGuardrail, setSelectedGuardrail] = useState(
@@ -581,8 +574,6 @@ export default function ServiceProviderNew() {
diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/settings/Main.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/settings/Main.tsx
index ac9ab80ce4..5a676a10c3 100644
--- a/portals/ai-workspace/src/pages/appShell/appShellPages/settings/Main.tsx
+++ b/portals/ai-workspace/src/pages/appShell/appShellPages/settings/Main.tsx
@@ -35,7 +35,7 @@ import {
PageTitle,
Stack,
} from '@wso2/oxygen-ui';
-import { LayoutTemplate, ShieldCheck } from '@wso2/oxygen-ui-icons-react';
+import { KeyRound, LayoutTemplate, ShieldCheck } from '@wso2/oxygen-ui-icons-react';
import { FormattedMessage } from 'react-intl';
import { useAppShell } from '../../../../contexts/AppShellContext';
import { useAppAuth } from '../../../../contexts/AppAuthContext';
@@ -68,18 +68,40 @@ const NAV_ITEMS: NavItem[] = [
path: '/settings/custom-policies',
scope: SCOPES.GATEWAY_CUSTOM_POLICY_READ,
},
+ {
+ key: 'secrets',
+ label: 'Secrets',
+ icon: ,
+ path: '/settings/secrets',
+ scope: SCOPES.SECRET_READ,
+ },
];
export default function Settings() {
const navigate = useNavigate();
const location = useLocation();
const { currentOrganization } = useAppShell();
- const { hasPermission } = useAppAuth();
+ const { hasPermission, isLoading: isAuthLoading } = useAppAuth();
+
+ // A hard navigation/refresh straight to a nested settings URL mounts this
+ // component before the BFF session fetch resolves. ProtectedRoute already
+ // blocks rendering on isLoading for the top-level authenticated/not branch,
+ // but scope data can still be mid-flight on the very first render here — do
+ // not let a not-yet-loaded hasPermission()===false for everything read as
+ // "no access" and fire the one-shot redirects below.
+ if (isAuthLoading) {
+ return null;
+ }
const visibleNavItems = NAV_ITEMS.filter((item) => hasPermission(item.scope));
- const selectedKey = visibleNavItems.find((item) =>
- location.pathname.includes(item.path)
- )?.key;
+
+ // Match against the full NAV_ITEMS list, not just visibleNavItems: a caller who
+ // navigates directly to a settings URL they lack the scope for must be denied
+ // access below, not merely have the sidebar fall back to highlighting a
+ // different item while the actual (unauthorized) page still renders via
+ // .
+ const matchedItem = NAV_ITEMS.find((item) => location.pathname.includes(item.path));
+ const selectedKey = matchedItem?.key ?? visibleNavItems[0]?.key;
// Settings requires at least one visible section; send others to org home.
if (visibleNavItems.length === 0) {
@@ -91,6 +113,18 @@ export default function Settings() {
);
}
+ // The active route matched a known settings section the caller lacks the scope
+ // for (e.g. direct navigation to /settings/secrets without SECRET_READ) —
+ // redirect to a section they can actually access instead of rendering it.
+ if (matchedItem && !hasPermission(matchedItem.scope)) {
+ return (
+
+ );
+ }
+
return (
{/* Persistent left sub-nav */}
@@ -138,11 +172,25 @@ export default function Settings() {
);
}
+// resolveSettingsFallbackPath picks the settings-relative destination a caller with
+// `hasPermission` should land on: the first section they hold the scope for, or the
+// org home page if they hold none of them. Shared by SettingsIndexRedirect and any
+// route guard (see App.tsx's RequireScope) that needs to bounce an unauthorized
+// caller somewhere useful instead of a hardcoded path.
+export function resolveSettingsFallbackPath(hasPermission: (scope: string) => boolean): string {
+ const firstVisibleItem = NAV_ITEMS.find((item) => hasPermission(item.scope));
+ return firstVisibleItem ? firstVisibleItem.path : '/home';
+}
+
export function SettingsIndexRedirect() {
const { currentOrganization } = useAppShell();
- const { hasPermission } = useAppAuth();
- const firstVisibleItem = NAV_ITEMS.find((item) => hasPermission(item.scope));
- const target = firstVisibleItem ? firstVisibleItem.path : '/home';
+ const { hasPermission, isLoading: isAuthLoading } = useAppAuth();
+
+ // Same one-shot-redirect-before-scopes-load hazard as Settings()/RequireScope
+ // above — wait for the session fetch to settle before picking a destination.
+ if (isAuthLoading) {
+ return null;
+ }
- return ;
+ return ;
}
diff --git a/tests/integration-e2e/README.md b/tests/integration-e2e/README.md
index d0456ea0b1..27c52f66de 100644
--- a/tests/integration-e2e/README.md
+++ b/tests/integration-e2e/README.md
@@ -92,9 +92,10 @@ Or via make (from `platform-api/`): `make e2e`, `make e2e-all-dbs`.
each run, so a rotating secret works either way.
- `E2E_TAGS=@smoke` runs a tag subset (other tags: `@secured`, `@multigateway`,
`@devportal`, `@lifecycle` for the credential-lifecycle scenario — run it alone
- with `E2E_TAGS="@devportal && @lifecycle"` —, and the on-demand secret fetch
+ with `E2E_TAGS="@devportal && @lifecycle"` —, the on-demand secret fetch
scenarios `@llm_provider`, `@llm_proxy`, `@mcp_proxy`, `@rest_api_secret` and
- `@policy_secret`). The `@multigateway` and `@devportal` scenarios run only on the
+ `@policy_secret`, and `@secret_lifecycle` for the secret rotation/deletion push-event
+ scenarios). The `@multigateway` and `@devportal` scenarios run only on the
postgres stack (the only one wired with a second gateway and the developer
portal) and are otherwise skipped automatically.
- `PA_HOST_PORT` / `GW_HTTP_PORT` / `GW2_HTTP_PORT` / `AP_HOST_PORT` override the
@@ -259,6 +260,27 @@ resolution keeps the artifact from ever rendering, so it never shows up).
against the single matching registered version in `gateway/build-manifest.yaml`
and rejects a full semver string like `"v1.1.0"`.
+### Secret rotation/deletion push-event scenarios
+
+The scenarios above cover secret resolution *at deploy time*. `secret_lifecycle.feature`
+(`@secret_lifecycle`) covers the opposite direction: a secret that a gateway is
+**already** resolving gets rotated or deleted while that gateway stays connected, with
+no controller restart and no new deployment. platform-api pushes a
+`secret.updated`/`secret.deleted` WebSocket event to every gateway in the org
+(`SecretService.broadcastSecretEvent`); the controller's `handleSecretUpdatedEvent` /
+`handleSecretDeletedEvent` (`pkg/controlplane/client.go`) react immediately instead of
+waiting for the next reconnect's incremental sync. Both scenarios build on the same
+secret-backed REST API fixture as `rest_api_secret.feature` (shared Given steps), then
+poll the gateway-controller's own `GET /api/management/v1/secrets/:handle` — which
+returns the gateway's locally decrypted value, not platform-api's — to observe the
+effect directly:
+
+12. **Rotation** — `PUT /secrets/:handle` with a new value. Asserts the gateway's local
+ copy reaches the rotated value.
+13. **Deletion** — `PUT /rest-apis/:id` swaps the upstream auth to a brand-new secret,
+ leaving the original referenced by nothing; platform-api's `cleanupRotatedSecret`
+ deprecates it automatically. Asserts the gateway's local copy is evicted (404).
+
## Status — passing on all three databases
The full live-traffic scenario passes on **SQLite, PostgreSQL and SQL Server**
diff --git a/tests/integration-e2e/features/secret_lifecycle.feature b/tests/integration-e2e/features/secret_lifecycle.feature
new file mode 100644
index 0000000000..bc6da23875
--- /dev/null
+++ b/tests/integration-e2e/features/secret_lifecycle.feature
@@ -0,0 +1,26 @@
+@secret_lifecycle
+Feature: Live secret rotation and deletion push events to a connected gateway
+ As an API platform operator
+ I want a connected gateway to pick up a secret rotation or deletion immediately
+ So that credential changes take effect without waiting for the gateway's next
+ reconnect-triggered poll, and without restarting the gateway-controller.
+
+ Background:
+ Given the platform-api control plane and gateway data plane are running
+ And I am authenticated to platform-api
+
+ Scenario: Rotating a secret pushes the new value to an already-connected gateway
+ Given a secret containing a REST API upstream credential
+ And a REST API whose upstream auth references the secret
+ And I deploy the secret-backed REST API to the gateway
+ And the gateway has the secret-backed REST API configured
+ When I rotate the secret to a new value
+ Then the gateway's local copy of the secret has the rotated value
+
+ Scenario: Deleting a secret evicts it from an already-connected gateway
+ Given a secret containing a REST API upstream credential
+ And a REST API whose upstream auth references the secret
+ And I deploy the secret-backed REST API to the gateway
+ And the gateway has the secret-backed REST API configured
+ When I update the REST API to reference a different secret instead
+ Then the gateway evicts the original secret from its local store
diff --git a/tests/integration-e2e/secret_helpers_test.go b/tests/integration-e2e/secret_helpers_test.go
index 7bb8feebb8..3d12431b99 100644
--- a/tests/integration-e2e/secret_helpers_test.go
+++ b/tests/integration-e2e/secret_helpers_test.go
@@ -24,16 +24,43 @@ package e2e
// secret-backed artifact, then polls the gateway-controller's management API
// until the artifact appears — confirming the controller resolved the
// {{ secret "..." }} reference at deploy time.
+//
+// rotateSecret / waitGatewaySecretValue / waitGatewaySecretGone below back a
+// different scenario family — secret_lifecycle.feature — which exercises the
+// live secret.updated/secret.deleted WebSocket push path (handleSecretUpdatedEvent /
+// handleSecretDeletedEvent) against an already-connected gateway, rather than the
+// on-demand fetch that happens at artifact-deploy time.
import (
"bytes"
+ "encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
+ "os"
+ "strconv"
"time"
)
+// defaultMaxSecretHelperRespBytes bounds how much of a platform-api response body
+// these secret helpers will buffer into memory — a safety ceiling against a
+// misbehaving server returning an unbounded stream, not an expected size.
+// Override via E2E_MAX_RESP_BYTES for scenarios that legitimately need more.
+const defaultMaxSecretHelperRespBytes = 10 << 20 // 10 MiB
+
+// maxSecretHelperRespBytes returns the configured response-body size ceiling
+// (E2E_MAX_RESP_BYTES), falling back to defaultMaxSecretHelperRespBytes when unset
+// or invalid.
+func maxSecretHelperRespBytes() int64 {
+ if v := os.Getenv("E2E_MAX_RESP_BYTES"); v != "" {
+ if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {
+ return n
+ }
+ }
+ return defaultMaxSecretHelperRespBytes
+}
+
// createSecret creates a GENERIC secret in platform-api via multipart/form-data
// and returns its handle.
func createSecret(displayName, value string) (string, error) {
@@ -65,7 +92,7 @@ func createSecret(displayName, value string) (string, error) {
return "", err
}
defer resp.Body.Close()
- body, _ := io.ReadAll(resp.Body)
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, maxSecretHelperRespBytes()))
if resp.StatusCode >= 300 {
return "", fmt.Errorf("create secret failed (%d): %s", resp.StatusCode, body)
}
@@ -127,3 +154,137 @@ func waitGatewayResource(resourcePath string, timeout time.Duration) error {
return fmt.Errorf("gateway did not configure resource %q within timeout: last status %d",
resourcePath, lastStatus)
}
+
+// rotateSecret rotates an existing secret's value via PUT /secrets/:handle
+// (multipart/form-data) — the same call the AI Workspace UI's "Rotate secret"
+// action makes. platform-api broadcasts a secret.updated event to every gateway
+// in the org as part of this call.
+func rotateSecret(handle, newValue string) error {
+ buf := &bytes.Buffer{}
+ mw := multipart.NewWriter(buf)
+ if err := mw.WriteField("value", newValue); err != nil {
+ return err
+ }
+ mw.Close()
+
+ req, err := http.NewRequest(http.MethodPut, platformAPI+platformAPIBase+"/secrets/"+handle, buf)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", mw.FormDataContentType())
+ req.Header.Set("Authorization", "Bearer "+suite.token)
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, maxSecretHelperRespBytes()))
+ if resp.StatusCode >= 300 {
+ return fmt.Errorf("rotate secret failed (%d): %s", resp.StatusCode, body)
+ }
+ return nil
+}
+
+// deleteSecret permanently deletes a secret via DELETE /secrets/:handle — the
+// same call the AI Workspace UI's "Delete secret" action makes. platform-api broadcasts
+// a secret.deleted event to every gateway in the org once the delete actually
+// succeeds (it 409s instead if the handle is still referenced by any artifact, current
+// config or deployed snapshot, on any gateway).
+func deleteSecret(handle string) error {
+ req, err := http.NewRequest(http.MethodDelete, platformAPI+platformAPIBase+"/secrets/"+handle, nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+suite.token)
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, maxSecretHelperRespBytes()))
+ if resp.StatusCode >= 300 {
+ return fmt.Errorf("delete secret failed (%d): %s", resp.StatusCode, body)
+ }
+ return nil
+}
+
+// gatewaySecretValue extracts spec.value from a gateway-controller GetSecret
+// response body (GET /api/management/v1/secrets/:handle — see
+// buildSecretResourceResponse in the gateway-controller source).
+func gatewaySecretValue(body []byte) string {
+ var r struct {
+ Spec struct {
+ Value string `json:"value"`
+ } `json:"spec"`
+ }
+ if json.Unmarshal(body, &r) != nil {
+ return ""
+ }
+ return r.Spec.Value
+}
+
+// waitGatewaySecretValue polls the gateway-controller's own secret store
+// (GET /api/management/v1/secrets/:handle, basic auth admin:admin) until its
+// decrypted value equals expectedValue or timeout expires. Confirms a
+// secret.updated push event caused an immediate re-fetch — not merely that the
+// gateway will eventually catch up on its next reconnect-triggered poll.
+func waitGatewaySecretValue(handle, expectedValue string, timeout time.Duration) error {
+ url := gwMgmtAPI + "/api/management/v1/secrets/" + handle
+ deadline := time.Now().Add(timeout)
+ var lastValue string
+ for time.Now().Before(deadline) {
+ req, err := http.NewRequest(http.MethodGet, url, nil)
+ if err != nil {
+ return err
+ }
+ req.SetBasicAuth("admin", "admin")
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ time.Sleep(2 * time.Second)
+ continue
+ }
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if resp.StatusCode == http.StatusOK {
+ lastValue = gatewaySecretValue(body)
+ if lastValue == expectedValue {
+ return nil
+ }
+ }
+ time.Sleep(2 * time.Second)
+ }
+ return fmt.Errorf("gateway secret %q did not reach the rotated value within timeout (last seen: %q)",
+ handle, lastValue)
+}
+
+// waitGatewaySecretGone polls the gateway-controller's own secret store until
+// GET /api/management/v1/secrets/:handle returns 404 or timeout expires.
+// Confirms a secret.deleted push event caused the gateway to evict its local
+// copy of a secret that is no longer referenced by any artifact.
+func waitGatewaySecretGone(handle string, timeout time.Duration) error {
+ url := gwMgmtAPI + "/api/management/v1/secrets/" + handle
+ deadline := time.Now().Add(timeout)
+ var lastStatus int
+ for time.Now().Before(deadline) {
+ req, err := http.NewRequest(http.MethodGet, url, nil)
+ if err != nil {
+ return err
+ }
+ req.SetBasicAuth("admin", "admin")
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ time.Sleep(2 * time.Second)
+ continue
+ }
+ io.Copy(io.Discard, resp.Body) //nolint:errcheck
+ resp.Body.Close()
+ lastStatus = resp.StatusCode
+ if lastStatus == http.StatusNotFound {
+ return nil
+ }
+ time.Sleep(2 * time.Second)
+ }
+ return fmt.Errorf("gateway did not evict secret %q within timeout: last status %d", handle, lastStatus)
+}
diff --git a/tests/integration-e2e/secret_lifecycle_steps_test.go b/tests/integration-e2e/secret_lifecycle_steps_test.go
new file mode 100644
index 0000000000..a13383c3bb
--- /dev/null
+++ b/tests/integration-e2e/secret_lifecycle_steps_test.go
@@ -0,0 +1,130 @@
+/*
+ * 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 e2e
+
+// Steps for secret_lifecycle.feature — exercises the LIVE push-event path for an
+// already-referenced, already-connected secret, as opposed to rest_api_secret.feature
+// (and siblings) which exercise the on-demand fetch that happens at artifact-deploy
+// time. The Background/Given steps here are shared with rest_api_secret.feature
+// (aSecretForRestAPI, aRestAPIReferencingSecret, deploySecretBackedRestAPI,
+// gatewayHasSecretBackedRestAPIConfigured in rest_api_secret_steps_test.go) — this
+// feature picks up from an already-deployed, already-resolved secret-backed REST API
+// and then rotates or replaces the secret:
+//
+// 1. Rotation: PUT /secrets/:handle with a new value (rotateSecret in
+// secret_helpers_test.go). platform-api broadcasts secret.updated to every
+// gateway in the org; the already-connected controller's handleSecretUpdatedEvent
+// re-fetches the plaintext and upserts it, without a restart. The assertion polls
+// the gateway-controller's own GET /api/management/v1/secrets/:handle until its
+// decrypted value matches the rotated one.
+// 2. Deletion: PUT /rest-apis/:id to swap the upstream auth to a brand-new secret,
+// then redeploy (without this, the gateway's already-deployed snapshot still
+// references the original handle — artifact_secret_refs keeps a gateway-scoped
+// row for it independently of the artifact's current config — so the explicit
+// DELETE below would 409 as still-referenced). Once the redeploy clears that
+// gateway-scoped row, the original secret is referenced by nothing and
+// DELETE /secrets/:handle succeeds, broadcasting secret.deleted; the
+// controller's handleSecretDeletedEvent evicts its local copy. The assertion
+// polls the same management endpoint until it 404s.
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+)
+
+// iRotateTheSecretToANewValue rotates w.restAPISecretHandle to a fresh value,
+// exercising the live secret.updated push-event path.
+func (w *world) iRotateTheSecretToANewValue() error {
+ if w.restAPISecretHandle == "" {
+ return fmt.Errorf("no secret handle — run the REST API secret background steps first")
+ }
+ w.restAPISecretRotatedValue = "e2e-test-restapi-rotated-" + randHex()
+ return rotateSecret(w.restAPISecretHandle, w.restAPISecretRotatedValue)
+}
+
+// theGatewaysLocalSecretHasTheRotatedValue polls the gateway-controller's own
+// secret store until it reflects the rotated value, confirming secret.updated
+// triggered an immediate re-fetch rather than waiting for the next reconnect.
+func (w *world) theGatewaysLocalSecretHasTheRotatedValue() error {
+ if w.restAPISecretRotatedValue == "" {
+ return fmt.Errorf("no rotated value recorded — run 'I rotate the secret to a new value' first")
+ }
+ return waitGatewaySecretValue(w.restAPISecretHandle, w.restAPISecretRotatedValue, pollTimeout)
+}
+
+// iUpdateTheRestAPIToReferenceADifferentSecret creates a second secret, updates the
+// REST API's upstream auth to reference it instead of the original one, redeploys so
+// the gateway's deployed snapshot stops referencing the original handle too, then
+// explicitly deletes the now-fully-unreferenced original secret.
+func (w *world) iUpdateTheRestAPIToReferenceADifferentSecret() error {
+ if w.restAPISecretApiID == "" {
+ return fmt.Errorf("no REST API — run the REST API secret background steps first")
+ }
+
+ replacementHandle, err := createSecret("E2E REST API Replacement Credential", "e2e-test-restapi-value-"+randHex())
+ if err != nil {
+ return err
+ }
+
+ // Reconstruct the exact displayName aRestAPIReferencingSecret used, so this
+ // full-replace PUT doesn't incidentally change anything but the auth value.
+ suffix := strings.TrimPrefix(w.restAPISecretContext, "/e2e-secret-")
+ displayName := "e2e-secret-api-" + suffix
+
+ st, body, err := apiCall(http.MethodPut, "/rest-apis/"+w.restAPISecretApiID, suite.token, map[string]any{
+ "displayName": displayName,
+ "context": w.restAPISecretContext,
+ "version": "v1",
+ "projectId": suite.projectID,
+ "upstream": map[string]any{
+ "main": map[string]any{
+ "url": "http://sample-backend:9080",
+ "auth": map[string]any{
+ "type": "api-key",
+ "header": "Authorization",
+ "value": `{{ secret "` + replacementHandle + `" }}`,
+ },
+ },
+ },
+ })
+ if err != nil {
+ return err
+ }
+ if st >= 300 {
+ return fmt.Errorf("update REST API to swap secret failed (%d): %s", st, body)
+ }
+
+ // Redeploy the updated config so the gateway's deployed snapshot (and therefore
+ // its gateway-scoped artifact_secret_refs row) picks up the replacement handle,
+ // freeing the original one from every reference — current config and deployed
+ // snapshot alike — before we try to delete it.
+ if _, err := deployRestAPIWithoutRestart(w.restAPISecretApiID, suite.gw1ID); err != nil {
+ return fmt.Errorf("redeploy after secret swap failed: %w", err)
+ }
+
+ return deleteSecret(w.restAPISecretHandle)
+}
+
+// theGatewayEvictsTheOriginalSecretFromItsLocalStore polls the gateway-controller's
+// secret store until the original (now-unreferenced, permanently deleted) secret is
+// gone, confirming the secret.deleted push event triggered eviction.
+func (w *world) theGatewayEvictsTheOriginalSecretFromItsLocalStore() error {
+ return waitGatewaySecretGone(w.restAPISecretHandle, pollTimeout)
+}
diff --git a/tests/integration-e2e/steps_test.go b/tests/integration-e2e/steps_test.go
index d1cf096e96..e5e89d5fdf 100644
--- a/tests/integration-e2e/steps_test.go
+++ b/tests/integration-e2e/steps_test.go
@@ -77,6 +77,11 @@ type world struct {
policySecretApiID string // id of the created REST API
policySecretContext string // e.g. /e2e-policy-ab12cd34
policySecretDepID string // deploymentId returned when the API is deployed
+
+ // Secret rotation/deletion push-event scenario state (see
+ // secret_lifecycle_steps_test.go). Reuses the restAPISecret* fields above,
+ // populated by the shared rest_api_secret.feature background steps.
+ restAPISecretRotatedValue string // the new plaintext value after rotation
}
// initializeScenario is invoked by godog for each scenario; it binds a fresh
@@ -138,6 +143,13 @@ func initializeScenario(sc *godog.ScenarioContext) {
sc.Step(`^a REST API with a set-headers policy referencing the secret$`, w.aRestAPIWithPolicyReferencingSecret)
sc.Step(`^I deploy the policy-secret REST API to the gateway$`, w.deployPolicySecretRestAPI)
sc.Step(`^the gateway has the policy-secret REST API configured$`, w.gatewayHasPolicySecretRestAPIConfigured)
+
+ // Secret rotation/deletion push-event steps (secret_lifecycle.feature). The
+ // Given steps are shared with rest_api_secret.feature (registered above).
+ sc.Step(`^I rotate the secret to a new value$`, w.iRotateTheSecretToANewValue)
+ sc.Step(`^the gateway's local copy of the secret has the rotated value$`, w.theGatewaysLocalSecretHasTheRotatedValue)
+ sc.Step(`^I update the REST API to reference a different secret instead$`, w.iUpdateTheRestAPIToReferenceADifferentSecret)
+ sc.Step(`^the gateway evicts the original secret from its local store$`, w.theGatewayEvictsTheOriginalSecretFromItsLocalStore)
}
// --- Background steps ------------------------------------------------------