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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 138 additions & 2 deletions gateway/gateway-controller/pkg/controlplane/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Comment thread
npamudika marked this conversation as resolved.
}

// 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()
Expand Down
40 changes: 40 additions & 0 deletions gateway/gateway-controller/pkg/controlplane/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Comment on lines +424 to +432

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline platform-api/internal/model/secret.go --items all
rg -n -C 5 'Revision|isStaleSecretEvent|secretRevisionCache|SecretDeletedEventPayload' \
  platform-api/internal/model/secret.go \
  platform-api/internal/service/secret_service.go \
  gateway/gateway-controller/pkg/controlplane/client.go \
  gateway/gateway-controller/pkg/controlplane/events.go

Repository: wso2/api-platform

Length of output: 19590


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '180,285p' platform-api/internal/service/secret_service.go
sed -n '384,100p' platform-api/internal/store/postgres/secret_repository.go
sed -n '70,220p' platform-api/internal/store/postgres/secret_repository.go

printf '\n--- create/update/delete helpers references ---\n'
rg -n -C 8 'CreatedAt|UpdatedAt|UnixNano|Find[ ]+\\(|Get[ ]+\\(|Create\\(|Update\\(' platform-api/internal/store platform-api/internal/service | sed -n '1,260p'

Repository: wso2/api-platform

Length of output: 3706


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- candidate secret repositories ---'
git ls-files | rg 'secret.*repository|secret.*store|postgres.*secret|sql/.*secret|*.sql' | rg -i 'secret' | sed -n '1,120p'

printf '%s\n' '--- Update/GetByHandle/Create definitions'
rg -n -C 8 'func .*GetByHandle|func .*Create|func .*Update|type Secret\s+\{|CreatedAt\s+|UpdatedAt\s+' platform-api/internal platform-api/api | sed -n '1,360p'

printf '%s\n' '--- Create flow around SecretCreate'
rg -n -C 12 'func \(s \*SecretService\) Create|NewSecret|Created_at|CreatedAt|UpdatedAt' platform-api/internal/service/secret_service.go platform-api/internal/model platform-api/api | sed -n '1,280p'

Repository: wso2/api-platform

Length of output: 411


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- candidate secret repository files ---'
git ls-files | rg -i 'secret.*(repository|store|reposit)|.*postgres|.*sql' | rg -i 'secret' | sed -n '1,160p'

printf '%s\n' '--- Create/GetByHandle/Update definitions ---'
rg -n -C 12 'func \(s \*SecretService\) Create|func .*GetByHandle|func .*Update|func .*FindRefsAndDelete|NewSecret|CreatedAt\s+|UpdatedAt\s+' platform-api/internal/service/secret_service.go platform-api/internal/model platform-api/internal/store platform-api/internal/dto | sed -n '1,380p'

printf '%s\n' '--- exact Update implementation ---'
sed -n '190,270p' platform-api/internal/service/secret_service.go

Repository: wso2/api-platform

Length of output: 36046


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '88,185p' platform-api/internal/service/secret_service.go
rg -n -C 12 'type Secret struct|CreatedAt|UpdatedAt|db:"created_at"|db:"updated_at"|Update\(|FindRefsAndDelete|GetByHandle' platform-api/internal platform-api/api --glob '*.go' --glob '*.sql' | sed -n '1,320p'

Repository: wso2/api-platform

Length of output: 31888


🌐 Web query:

GORM callback create UpdatedAt Before Create Now() documentation

💡 Result:

In GORM, you do not need to manually create a callback to set CreatedAt or UpdatedAt to the current time, as this is handled automatically by GORM’s built-in functionality [1]. Automatic Time Tracking GORM tracks creation and update times by convention if your model includes fields named CreatedAt and UpdatedAt [1]. GORM will automatically populate these fields with the current time during create and update operations [1]. You can control this behavior using GORM tags in your model struct [1]: - autoCreateTime: Automatically sets the field to the current time upon creation [1]. - autoUpdateTime: Automatically sets the field to the current time upon creation and update [1]. For example: type User struct { CreatedAt time.Time UpdatedAt time.Time // Automatically set/updated by GORM } Custom Callback Behavior If you need custom logic (such as using a specific time source or formatting), you can implement GORM hooks like BeforeCreate [2][3]. Hooks are methods defined on your model that GORM calls during the operation lifecycle [2]. func (u *User) BeforeCreate(tx *gorm.DB) (err error) { // Custom logic before creation u.UpdatedAt = time.Now return } If you must manipulate callbacks directly (e.g., to replace the default timestamping behavior), you can register custom callbacks or remove existing ones via the GORM callback API [4]. However, this is generally unnecessary for standard timestamp management [1]. GORM's built-in callbacks are executed in a specific order, which you can inspect or modify if required [4]. Top results: [2][1][4]

Citations:


Make recreated secret handles use strictly newer revisions.

SecretDeletedEventPayload depends on recreated handles being newer than deleted predecessors, but secret creation does not set UpdatedAt in SecretService.Create; a later reuse can receive an older/generated timestamp. Set an explicit UTC update timestamp for the recycled handle, and keep isStaleSecretEvent using strict less-than so equal revisions remain idempotent while older deletions are ignored.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gateway/gateway-controller/pkg/controlplane/events.go` around lines 424 -
432, Update SecretService.Create to assign an explicit current UTC UpdatedAt
timestamp when creating a recycled secret handle, ensuring its revision is
strictly newer than the deleted predecessor. Preserve isStaleSecretEvent’s
strict less-than comparison so equal revisions remain idempotent and older
deletion events are ignored.


// 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"`
Expand Down
56 changes: 56 additions & 0 deletions gateway/gateway-controller/pkg/controlplane/sync_secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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),
)
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Comment on lines +202 to +211

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect GenerateDeterministicUUIDv7 and its existing call sites.
set -euo pipefail

ast-grep run --pattern 'func GenerateDeterministicUUIDv7($$$) { $$$ }' --lang go gateway/gateway-controller/pkg/utils
rg -nP --type=go -C 3 '\bGenerateDeterministicUUIDv7\s*\('

Repository: wso2/api-platform

Length of output: 155


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate files =="
git ls-files | rg 'sync_secrets\.go|utils\.go|uuid|correlation|GenerateDeterministicUUIDv7' || true

echo "== find function by text =="
rg -n --type=go -C 4 'GenerateDeterministicUUIDv7|DeterministicUUIDv7|deterministic.*UUID|UUIDv7|UUID.*v7' . || true

echo "== target file excerpt if present =="
if [ -f gateway/gateway-controller/pkg/controlplane/sync_secrets.go ]; then
  nl -ba gateway/gateway-controller/pkg/controlplane/sync_secrets.go | sed -n '170,225p'
fi

Repository: wso2/api-platform

Length of output: 31695


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== gateway utils GenerateDeterministicUUIDv7 =="
sed -n '80,115p' gateway/gateway-controller/pkg/utils/commonutils.go

echo "== platform-api GenerateDeterministicUUIDv7 =="
sed -n '526,550p' platform-api/internal/utils/common.go

echo "== relevant deterministic UUID test cases =="
sed -n '137,174p' gateway/gateway-controller/pkg/utils/api_deployment_test.go
sed -n '230,262p' gateway/gateway-controller/pkg/controlplane/sync_test.go

echo "== retry/poll context for secret eviction (if present) =="
rg -n --type=go -C 4 'stale|secretSyncer\.Delete|GenerateDeterministicUUIDv7\(handle' gateway/gateway-controller/pkg/controlplane/sync_secrets.go gateway/gateway-controller/pkg/controlplane || true

Repository: wso2/api-platform

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== sync polling/eviction call context =="
rg -n --type=go -C 4 'evictSecretsNotIn|Start.*poll|poll|Ticker|Ticker\(|time\.Ticker|ticker\.' gateway/gateway-controller/pkg/controlplane || true

echo "== deterministic UUID millisecond edge behavior probe =="
python3 - <<'PY'
import hashlib, struct, datetime
def uuid7(handle, t):
    ms = int(t.timestamp()*1000)
    b = bytearray(16)
    b[0:2] = struct.pack(">H", (ms >> 32) & 0xFFFF)
    b[2:6] = struct.pack(">I", ms & 0xFFFFFFFF)
    b[6:] = hashlib.sha256(handle.encode()).digest()[:10]
    b[6] |= 0x70
    b[8] |= 0x80
    return "{:08x}-{:04x}-{:04x}-{:02}{:02}-{:02}{:02}{:02}{:02}{:02}{:02}{:02}{:02}".format(
        struct.unpack(">I", bytes(b[:4]))[0],
        struct.unpack(">H", bytes(b[4:6]))[0],
        b[6] << 8 | b[7],
        b[8], b[9],
        struct.unpack(">H", bytes(b[10:12]))[0],
        struct.unpack(">H", bytes(b[12:14]))[0],
        struct.unpack(">H", bytes(b[14:16]))[0],
    )
base = datetime.datetime(2026, 1, 2, 3, 4, 5, 123456, tzinfo=datetime.timezone.utc)
for ns in [0, 500_000_000, 999_000_000, 999_999_999]:
    t = base + datetime.timedelta(nanoseconds=ns)
    print(base.isoformat(), "+", ns, "ns ->", uuid7("handle", t))
print("equal within same millisecond:", uuid7("handle", base) == uuid7("handle", base + datetime.timedelta(milliseconds=999)))
print("different millisecond:", uuid7("handle", base) == uuid7("handle", base + datetime.timedelta(milliseconds=1)))
PY

Repository: wso2/api-platform

Length of output: 18094


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== deterministic UUID millisecond edge behavior probe =="
python3 - <<'PY'
import hashlib, struct, datetime
def uuid7(handle, t):
    ms = int(t.timestamp()*1000)
    b = bytearray(16)
    b[0:2] = struct.pack(">H", (ms >> 32) & 0xFFFF)
    b[2:6] = struct.pack(">I", ms & 0xFFFFFFFF)
    b[6:] = hashlib.sha256(handle.encode()).digest()[:10]
    b[6] |= 0x70
    b[8] |= 0x80
    return "{:08x}-{:04x}-{:04x}-{:02}{:02}-{:02}{:02}{:02}{:02}{:02}{:02}{:02}{:02}".format(
        struct.unpack(">I", bytes(b[:4]))[0],
        struct.unpack(">H", bytes(b[4:6]))[0],
        b[6] << 8 | b[7],
        b[8], b[9],
        struct.unpack(">H", bytes(b[10:12]))[0],
        struct.unpack(">H", bytes(b[12:14]))[0],
        struct.unpack(">H", bytes(b[14:16]))[0],
    )
base = datetime.datetime(2026, 1, 2, 3, 4, 5, 123456, tzinfo=datetime.timezone.utc)
for add_us in [0, 500000, 999000, 999999]:
    t = base + datetime.timedelta(microseconds=add_us)
    print(base.isoformat(), "+", add_us, "us ->", uuid7("handle", t))
print("equal within same millisecond:", uuid7("handle", base) == uuid7("handle", base + datetime.timedelta(milliseconds=999)))
print("different millisecond:", uuid7("handle", base) == uuid7("handle", base + datetime.timedelta(milliseconds=1)))
PY

echo "== retry/test around delete error keeps same correlation seed if same time still in same millisecond =="
sed -n '920,940p' gateway/gateway-controller/pkg/controlplane/sync_secrets_test.go

Repository: wso2/api-platform

Length of output: 393


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== deterministic UUID millisecond edge behavior probe =="
python3 - <<'PY'
import hashlib, struct, datetime

def uuid7(handle, t):
    ms = int(t.timestamp() * 1000)
    b = bytearray(16)
    b[0:2] = struct.pack(">H", (ms >> 32) & 0xFFFF)
    b[2:6] = struct.pack(">I", ms & 0xFFFFFFFF)
    b[6:] = hashlib.sha256(handle.encode()).digest()[:10]
    b[6] |= 0x70
    b[8] |= 0x80
    return "{:08x}-{:04x}-{:04x}-{:02}{:02}-{:02}{:02}{:02}{:02}{:02}{:02}{:02}{:02}".format(
        struct.unpack(">I", bytes(b[:4]))[0],
        struct.unpack(">H", bytes(b[4:6]))[0],
        b[6] << 8 | b[7],
        b[8], b[9],
        struct.unpack(">H", bytes(b[10:12]))[0],
        struct.unpack(">H", bytes(b[12:14]))[0],
        struct.unpack(">H", bytes(b[14:16]))[0],
    )

base = datetime.datetime(2026, 1, 2, 3, 4, 5, 123456, tzinfo=datetime.timezone.utc)
for add_us in [0, 500000, 999000, 999999]:
    t = base + datetime.timedelta(microseconds=add_us)
    print(base.isoformat(), "+", add_us, "us ->", uuid7("handle", t))

print("equal within same millisecond:", uuid7("handle", base) == uuid7("handle", base + datetime.timedelta(milliseconds=999)))
print("different millisecond:", uuid7("handle", base) == uuid7("handle", base + datetime.timedelta(milliseconds=1)))
PY

echo "== retry behavior in sync_secrets_test =="
sed -n '920,940p' gateway/gateway-controller/pkg/controlplane/sync_secrets_test.go

Repository: wso2/api-platform

Length of output: 393


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import hashlib, struct, datetime

def uuid7(handle, t):
    ms = int(t.timestamp() * 1000)
    b = bytearray(16)
    b[0:2] = struct.pack(">H", (ms >> 32) & 0xFFFF)
    b[2:6] = struct.pack(">I", ms & 0xFFFFFFFF)
    b[6:] = hashlib.sha256(handle.encode()).digest()[:10]
    b[6] |= 0x70
    b[8] |= 0x80
    return ("{:08x}-{:04x}-{:04x}-{:02}{:02}-{:02}{:02}{:02}{:02}{:02}{:02}{:02}{:02}").format(
        struct.unpack(">I", bytes(b[:4]))[0],
        struct.unpack(">H", bytes(b[4:6]))[0],
        b[6] << 8 | b[7],
        b[8], b[9],
        struct.unpack(">H", bytes(b[10:12]))[0],
        struct.unpack(">H", bytes(b[12:14]))[0],
        struct.unpack(">H", bytes(b[14:16]))[0],
    )

base = datetime.datetime(2026, 1, 2, 3, 4, 5, 123456, tzinfo=datetime.timezone.utc)
for add_us in [0, 500000, 999000, 999999]:
    t = base + datetime.timedelta(microseconds=add_us)
    print(base.isoformat(), "+", add_us, "us ->", uuid7("handle", t))

print("equal within same millisecond:", uuid7("	handle", base) == uuid7("handle", base + datetime.timedelta(milliseconds=999)))
print("different millisecond:", uuid7("handle", base) == uuid7("handle", base + datetime.timedelta(milliseconds=1)))
PY

Repository: wso2/api-platform

Length of output: 336


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import hashlib, struct, datetime

def uuid7(handle, t):
    ms = int(t.timestamp() * 1000)
    b = bytearray(16)
    b[0:2] = struct.pack(">H", (ms >> 32) & 0xFFFF)
    b[2:6] = struct.pack(">I", ms & 0xFFFFFFFF)
    b[6:] = hashlib.sha256(handle.encode()).digest()[:10]
    b[6] |= 0x70
    b[8] |= 0x80
    parts = (
        struct.unpack(">I", bytes(b[:4]))[0],
        struct.unpack(">H", bytes(b[4:6]))[0],
        b[6] << 8 | b[7],
        b[8], b[9],
        struct.unpack(">H", bytes(b[10:12]))[0],
        struct.unpack(">H", bytes(b[12:14]))[0],
        struct.unpack(">H", bytes(b[14:16]))[0],
    )
    return "{:08x}-{:04x}-{:04x}-{:02}{:02}-{:02}{:02}{:02}{:02}{:02}{:02}{:02}{:02}".format(*parts)

base = datetime.datetime(2026, 1, 2, 3, 4, 5, 123456, tzinfo=datetime.timezone.utc)
for add_us in [0, 500000, 999000, 999999]:
    t = base + datetime.timedelta(microseconds=add_us)
    print(base.isoformat(), "+", add_us, "us ->", uuid7("handle", t))

print("equal within same millisecond:", uuid7("handle", base) == uuid7("handle", base + datetime.timedelta(milliseconds=999)))
print("different millisecond:", uuid7("handle", base) == uuid7("handle", base + datetime.timedelta(milliseconds=1)))
PY

echo "== retry behavior in sync_secrets_test =="
sed -n '920,940p' gateway/gateway-controller/pkg/controlplane/sync_secrets_test.go

Repository: wso2/api-platform

Length of output: 336


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import hashlib, struct, datetime

def uuid7(handle, t):
    ms = int(t.timestamp() * 1000)
    b = bytearray(16)
    b[0:2] = struct.pack(">H", (ms >> 32) & 0xFFFF)
    b[2:6] = struct.pack(">I", ms & 0xFFFFFFFF)
    b[6:] = hashlib.sha256(handle.encode()).digest()[:10]
    b[6] |= 0x70
    b[8] |= 0x80
    return uuid7_bytes(bytes(b))

def uuid7_bytes(b):
    parts = ["{:08x}".format(struct.unpack(">I", b[:4])[0]),
             "{:04x}".format(struct.unpack(">H", b[4:6])[0]),
             "{:04x}".format(b[6] << 8 | b[7]),
             "{:02x}{:02x}".format(b[8], b[9]),
             "{:02x}{:02x}".format(struct.unpack(">H", b[10:12])[0]),
             "{:02x}{:02x}".format(struct.unpack(">H", b[12:14])[0]),
             "{:02x}{:02x}".format(struct.unpack(">H", b[14:16])[0])]
    return "-".join(parts)

base = datetime.datetime(2026, 1, 2, 3, 4, 5, 123456, tzinfo=datetime.timezone.utc)
for add_us in [0, 500000, 999000, 999999]:
    t = base + datetime.timedelta(microseconds=add_us)
    print(base.isoformat(), "+", add_us, "us ->", uuid7("handle", t))

print("equal within same millisecond:", uuid7("handle", base) == uuid7("handle", base + datetime.timedelta(milliseconds=999)))
print("different millisecond:", uuid7("handle", base) == uuid7("handle", base + datetime.timedelta(milliseconds=1)))
PY

echo "== retry behavior in sync_secrets_test =="
sed -n '920,940p' gateway/gateway-controller/pkg/controlplane/sync_secrets_test.go

Repository: wso2/api-platform

Length of output: 378


Use per-attempt entropy for stale-secret eviction correlation IDs.

GenerateDeterministicUUIDv7 truncates the UUIDv7 timestamp to milliseconds and derives the rest of the UUID from the handle hash. A retry within the same millisecond for the same secret handle can reuse the same correlation_id, so separate eviction attempts can appear merged in logs. Use a non-deterministic or per-attempt value for this retry path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gateway/gateway-controller/pkg/controlplane/sync_secrets.go` around lines 202
- 211, Update the stale-secret eviction loop around Delete to generate a
non-deterministic, per-attempt correlation ID instead of calling
GenerateDeterministicUUIDv7 with handle and time.Now(). Keep the existing
correlation_id logging and Delete behavior unchanged.

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)
}
Comment on lines +189 to +220

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Evict only handles that were cached before the poll started.

evictSecretsNotIn compares live secretHashCache state against activeHandles, which was derived from a response fetched earlier. secretHashCache is a shared sync.Map. syncSecretRefsFromYAML writes to it from artifact-deployment goroutines, and handleSecretUpdatedEvent writes to it from the WebSocket read loop. A handle added between the FetchPlatformSecrets call and this eviction is absent from activeHandles, so the poll deletes a secret that was just fetched for a live artifact. Resolution for that artifact then fails until the next poll restores it.

Snapshot the cache keys before the fetch and restrict eviction to that snapshot.

🛠️ Proposed fix
-	metas, err := c.apiUtilsService.FetchPlatformSecrets(nil, false)
+	preFetchHandles := c.cachedSecretHandles()
+	metas, err := c.apiUtilsService.FetchPlatformSecrets(nil, false)
 	if err != nil {
 		c.logger.Error("Failed to fetch platform secrets metadata", slog.Any("error", err))
 		return
 	}
-	evicted := c.evictSecretsNotIn(activeHandles)
+	evicted := c.evictSecretsNotIn(activeHandles, preFetchHandles)
-func (c *Client) evictSecretsNotIn(activeHandles map[string]struct{}) int {
+// cachedSecretHandles snapshots the handles currently in secretHashCache.
+func (c *Client) cachedSecretHandles() map[string]struct{} {
+	snapshot := make(map[string]struct{})
+	c.secretHashCache.Range(func(key, _ any) bool {
+		if handle, ok := key.(string); ok {
+			snapshot[handle] = struct{}{}
+		}
+		return true
+	})
+	return snapshot
+}
+
+// candidates limits eviction to handles cached before the poll response was
+// fetched, so a handle added concurrently is never mistaken for a stale one.
+func (c *Client) evictSecretsNotIn(activeHandles, candidates map[string]struct{}) int {
 	var stale []string
 	c.secretHashCache.Range(func(key, _ any) bool {
 		handle, ok := key.(string)
 		if !ok {
 			return true
 		}
+		if _, eligible := candidates[handle]; !eligible {
+			return true
+		}
 		if _, ok := activeHandles[handle]; !ok {
 			stale = append(stale, handle)
 		}
 		return true
 	})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gateway/gateway-controller/pkg/controlplane/sync_secrets.go` around lines 189
- 220, Snapshot the keys in secretHashCache before FetchPlatformSecrets begins,
then pass that snapshot into evictSecretsNotIn and only consider handles present
in it for eviction. Update the sync flow and evictSecretsNotIn signature
accordingly, preserving the existing deletion and logging behavior while
preventing handles added during the fetch from being removed.


// 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
Expand Down
Loading
Loading