diff --git a/internal/api/status_reps.go b/internal/api/status_reps.go index 7618d16e..6d72298f 100644 --- a/internal/api/status_reps.go +++ b/internal/api/status_reps.go @@ -15,17 +15,41 @@ type StatusRep struct { ClientVersion string `json:"clientVersion"` } +// KeyStatus is the JSON representation of one accepted SDK or mobile key in the status endpoint's +// sdkKeys[] / mobileKeys[] arrays. +// +// Key is the non-secret human-readable identifier from the wire format (the "key" field of a +// sdkKeys/mobileKeys entry); it is omitted when the source carried no identifier (manual config, or +// an old-format payload predating concurrent keys). Value is the obscured credential secret (via +// sdks.ObscureKey). Expiry carries the Unix-millisecond expiry timestamp when the key is being phased +// out; it is omitted for permanent keys. +type KeyStatus struct { + Key string `json:"key,omitempty"` + Value string `json:"value"` + Expiry *int64 `json:"expiry,omitempty"` +} + // EnvironmentStatusRep is the per-environment JSON representation returned by the status endpoint. // // This is exported for use in integration test code. type EnvironmentStatusRep struct { - SDKKey string `json:"sdkKey"` - EnvID string `json:"envId,omitempty"` - EnvKey string `json:"envKey,omitempty"` - EnvName string `json:"envName,omitempty"` - ProjKey string `json:"projKey,omitempty"` - ProjName string `json:"projName,omitempty"` - MobileKey string `json:"mobileKey,omitempty"` + // SDKKey is the obscured anchor SDK key — the key relay uses for its upstream connection. It + // designates which SDKKeys entry is the anchor. + SDKKey string `json:"sdkKey"` + // SDKKeys carries the full accepted set of server-side SDK keys — including the anchor — with their + // identifiers, obscured values, and optional expiry. Always present; always contains at least the + // anchor. + SDKKeys []KeyStatus `json:"sdkKeys"` + EnvID string `json:"envId,omitempty"` + EnvKey string `json:"envKey,omitempty"` + EnvName string `json:"envName,omitempty"` + ProjKey string `json:"projKey,omitempty"` + ProjName string `json:"projName,omitempty"` + // MobileKey is the obscured primary mobile key. It designates which MobileKeys entry is the primary. + MobileKey string `json:"mobileKey,omitempty"` + // MobileKeys carries the full accepted set of mobile keys — including the primary. Always present; + // empty for an environment with no mobile keys (e.g. server-side only). + MobileKeys []KeyStatus `json:"mobileKeys"` ExpiringSDKKey string `json:"expiringSdkKey,omitempty"` Status string `json:"status"` ConnectionStatus ConnectionStatusRep `json:"connectionStatus"` diff --git a/relay/endpoints_status.go b/relay/endpoints_status.go index cce75869..725cf26a 100644 --- a/relay/endpoints_status.go +++ b/relay/endpoints_status.go @@ -3,10 +3,13 @@ package relay import ( "encoding/json" "net/http" + "slices" + "strings" "time" "github.com/launchdarkly/ld-relay/v8/config" "github.com/launchdarkly/ld-relay/v8/internal/api" + "github.com/launchdarkly/ld-relay/v8/internal/credential" "github.com/launchdarkly/ld-relay/v8/internal/relayenv" "github.com/launchdarkly/ld-relay/v8/internal/sdks" @@ -46,25 +49,54 @@ func statusHandler(relay *Relay) http.Handler { ProjName: identifiers.ProjName, } - // Use the anchor SDK key and primary mobile key specifically — GetCredentials() may return - // multiple SDK and mobile keys (primary + expiring), so iterating it for these singular - // status fields would give a non-deterministic result. - if key := clientCtx.GetAnchorKey(); key.Defined() { - status.SDKKey = sdks.ObscureKey(string(key)) + // One consistent snapshot of the accepted credential set drives every credential field + // below — the scalar anchor/primary designations, the full sdkKeys[]/mobileKeys[] arrays, + // and expiringSdkKey — so they cannot drift relative to each other under a concurrent + // reconcile. + accepted := clientCtx.GetAcceptedKeys() + + // Scalar fields: the anchor SDK key and primary mobile key designate which array entry is + // the anchor / primary. + if accepted.Anchor.Defined() { + status.SDKKey = sdks.ObscureKey(string(accepted.Anchor)) } - if key := clientCtx.GetMobileKey(); key.Defined() { - status.MobileKey = sdks.ObscureKey(string(key)) + if accepted.PrimaryMobile.Defined() { + status.MobileKey = sdks.ObscureKey(string(accepted.PrimaryMobile)) } for _, c := range clientCtx.GetCredentials() { if envID, ok := c.(config.EnvironmentID); ok { status.EnvID = string(envID) } } - for _, c := range clientCtx.GetDeprecatedCredentials() { - if key, ok := c.(config.SDKKey); ok { - status.ExpiringSDKKey = sdks.ObscureKey(string(key)) + + // sdkKeys[] / mobileKeys[]: the full accepted set, grouped by kind. Always present (never + // null): a server-only env has an empty mobileKeys. Order is unspecified. + status.SDKKeys = make([]api.KeyStatus, 0, len(accepted.Server)) + var expiringCandidates []expiringSDKKey + for value, info := range accepted.Server { + status.SDKKeys = append(status.SDKKeys, keyStatus(string(value), info)) + // expiringSdkKey considers non-anchor server keys that carry an expiry. + if value != accepted.Anchor && info.Expiry != nil { + expiringCandidates = append(expiringCandidates, expiringSDKKey{value: string(value), expiry: *info.Expiry}) } } + status.MobileKeys = make([]api.KeyStatus, 0, len(accepted.Mobile)) + for value, info := range accepted.Mobile { + status.MobileKeys = append(status.MobileKeys, keyStatus(string(value), info)) + } + + // expiringSdkKey: the soonest-expiring non-anchor SDK key. Comparing by expiry then by value + // gives a total order, so the chosen key is deterministic even when several keys share the + // same expiry (map iteration order, and hence MinFunc's pick on a tie, is otherwise unstable). + if len(expiringCandidates) > 0 { + earliest := slices.MinFunc(expiringCandidates, func(a, b expiringSDKKey) int { + if c := a.expiry.Compare(b.expiry); c != 0 { + return c + } + return strings.Compare(a.value, b.value) + }) + status.ExpiringSDKKey = sdks.ObscureKey(earliest.value) + } client := clientCtx.GetClient() if client == nil { @@ -155,3 +187,24 @@ func statusHandler(relay *Relay) http.Handler { _, _ = w.Write(data) }) } + +// expiringSDKKey is a candidate for the status endpoint's expiringSdkKey field: a non-anchor SDK key +// that carries an expiry. value is the plain credential; expiry is its (non-nil) expiry. +type expiringSDKKey struct { + value string + expiry time.Time +} + +// keyStatus converts an accepted key — its credential value plus metadata — into its status-endpoint +// JSON representation, obscuring the secret value and surfacing the optional identifier and expiry. +func keyStatus(value string, k credential.AcceptedKey) api.KeyStatus { + ks := api.KeyStatus{Value: sdks.ObscureKey(value)} + if k.Key != nil { + ks.Key = *k.Key + } + if k.Expiry != nil { + ms := k.Expiry.UnixMilli() + ks.Expiry = &ms + } + return ks +} diff --git a/relay/endpoints_status_test.go b/relay/endpoints_status_test.go index ffbcdad7..9527e4cb 100644 --- a/relay/endpoints_status_test.go +++ b/relay/endpoints_status_test.go @@ -5,12 +5,14 @@ import ( "testing" "time" + "github.com/launchdarkly/ld-relay/v8/internal/credential" "github.com/launchdarkly/ld-relay/v8/internal/sdkauth" c "github.com/launchdarkly/ld-relay/v8/config" "github.com/launchdarkly/ld-relay/v8/internal/sdks" st "github.com/launchdarkly/ld-relay/v8/internal/sharedtest" "github.com/launchdarkly/ld-relay/v8/internal/sharedtest/testclient" + "github.com/launchdarkly/ld-relay/v8/internal/util" ct "github.com/launchdarkly/go-configtypes" "github.com/launchdarkly/go-sdk-common/v3/ldtime" @@ -56,6 +58,36 @@ func TestEndpointsStatus(t *testing.T) { st.AssertJSONPathMatch(t, p.relay.version, status, "version") st.AssertJSONPathMatch(t, ld.Version, status, "clientVersion") }) + + t.Run("sdkKeys/mobileKeys arrays carry the full accepted set including the anchor", func(t *testing.T) { + var config c.Config + config.Environment = st.MakeEnvConfigs(st.EnvMain, st.EnvMobile) + + withStartedRelay(t, config, func(p relayTestParams) { + r, _ := http.NewRequest("GET", "http://localhost/status", nil) + result, body := st.DoRequest(r, p.relay) + assert.Equal(t, http.StatusOK, result.StatusCode) + status := ldvalue.Parse(body) + + // EnvMain is a manually-configured single-key env: sdkKeys[] is present and contains + // exactly the anchor (full set includes it); manual config carries no key identifier. + sdkKeys := status.GetByKey("environments").GetByKey(st.EnvMain.Name).GetByKey("sdkKeys") + require.Equal(t, 1, sdkKeys.Count(), "sdkKeys must contain the anchor") + assert.Equal(t, sdks.ObscureKey(string(st.EnvMain.Config.SDKKey)), + sdkKeys.GetByIndex(0).GetByKey("value").StringValue()) + + // EnvMain has no mobile key — mobileKeys is present but empty. + mobileKeys := status.GetByKey("environments").GetByKey(st.EnvMain.Name).GetByKey("mobileKeys") + assert.Equal(t, 0, mobileKeys.Count()) + assert.Equal(t, ldvalue.ArrayType, mobileKeys.Type(), "mobileKeys present (not null) even when empty") + + // EnvMobile has both: its mobile key appears in mobileKeys[]. + mobMobileKeys := status.GetByKey("environments").GetByKey(st.EnvMobile.Name).GetByKey("mobileKeys") + require.Equal(t, 1, mobMobileKeys.Count()) + assert.Equal(t, sdks.ObscureKey(string(st.EnvMobile.Config.MobileKey)), + mobMobileKeys.GetByIndex(0).GetByKey("value").StringValue()) + }) + }) }) t.Run("connection interruption - less than DisconnectedStatusTime", func(t *testing.T) { @@ -131,3 +163,116 @@ func TestEndpointsStatus(t *testing.T) { }) }) } + +// findKeyStatusByValue returns the sdkKeys[]/mobileKeys[] entry whose obscured "value" matches, or a +// null value. Array entry order is unspecified, so callers look entries up by value. +func findKeyStatusByValue(arr ldvalue.Value, obscuredValue string) ldvalue.Value { + for i := 0; i < arr.Count(); i++ { + if arr.GetByIndex(i).GetByKey("value").StringValue() == obscuredValue { + return arr.GetByIndex(i) + } + } + return ldvalue.Null() +} + +// TestEndpointsStatusExpiringSDKKey drives a multi-key environment through the real /status handler: +// it reconciles an env to an anchor plus two non-anchor expiring SDK keys and asserts the +// expiringSdkKey selection, the per-key expiry/identifier serialization in sdkKeys[], and that the +// soonest-expiry pick is deterministic on an exact expiry tie. +func TestEndpointsStatusExpiringSDKKey(t *testing.T) { + getStatus := func(t *testing.T, p relayTestParams, set credential.AcceptedSet) ldvalue.Value { + env, err := p.relay.getEnvironment(sdkauth.New(st.EnvMain.Config.SDKKey)) + require.NoError(t, err) + require.NotNil(t, env) + env.ReconcileCredentials(set) + + r, _ := http.NewRequest("GET", "http://localhost/status", nil) + result, body := st.DoRequest(r, p.relay) + require.Equal(t, http.StatusOK, result.StatusCode) + return ldvalue.Parse(body).GetByKey("environments").GetByKey(st.EnvMain.Name) + } + + t.Run("soonest-expiring non-anchor key, with expiry and identifier surfaced", func(t *testing.T) { + var config c.Config + config.Environment = st.MakeEnvConfigs(st.EnvMain) + withStartedRelay(t, config, func(p relayTestParams) { + anchor := st.EnvMain.Config.SDKKey + soon := time.Now().Add(1 * time.Hour) + later := time.Now().Add(2 * time.Hour) + set, err := credential.NewAcceptedSetBuilder(). + WithAnchor(credential.SDKKeyParams{Value: anchor}). + WithSDKKey(credential.SDKKeyParams{Value: "sdk-soon", Key: util.PtrOrNil("soon-key"), Expiry: util.PtrOrNil(soon)}). + WithSDKKey(credential.SDKKeyParams{Value: "sdk-later", Expiry: util.PtrOrNil(later)}). + Build() + require.NoError(t, err) + + envStatus := getStatus(t, p, set) + + // expiringSdkKey is the obscured soonest-expiring non-anchor key. + st.AssertJSONPathMatch(t, sdks.ObscureKey("sdk-soon"), envStatus, "expiringSdkKey") + + // sdkKeys[] carries the full set (anchor + both non-anchor keys). + sdkKeys := envStatus.GetByKey("sdkKeys") + require.Equal(t, 3, sdkKeys.Count()) + + // The expiring key surfaces its identifier and Unix-millis expiry. + soonEntry := findKeyStatusByValue(sdkKeys, sdks.ObscureKey("sdk-soon")) + require.False(t, soonEntry.IsNull()) + assert.Equal(t, "soon-key", soonEntry.GetByKey("key").StringValue()) + assert.Equal(t, float64(soon.UnixMilli()), soonEntry.GetByKey("expiry").Float64Value()) + + // A key with no identifier omits "key" entirely (omitempty, nil pointer). + laterEntry := findKeyStatusByValue(sdkKeys, sdks.ObscureKey("sdk-later")) + require.False(t, laterEntry.IsNull()) + assert.True(t, laterEntry.GetByKey("key").IsNull(), `"key" must be omitted when the source carried no identifier`) + }) + }) + + t.Run("tie on expiry resolves deterministically to the smaller value", func(t *testing.T) { + var config c.Config + config.Environment = st.MakeEnvConfigs(st.EnvMain) + withStartedRelay(t, config, func(p relayTestParams) { + anchor := st.EnvMain.Config.SDKKey + sameExpiry := time.Now().Add(1 * time.Hour) + set, err := credential.NewAcceptedSetBuilder(). + WithAnchor(credential.SDKKeyParams{Value: anchor}). + WithSDKKey(credential.SDKKeyParams{Value: "sdk-bbb", Expiry: util.PtrOrNil(sameExpiry)}). + WithSDKKey(credential.SDKKeyParams{Value: "sdk-aaa", Expiry: util.PtrOrNil(sameExpiry)}). + Build() + require.NoError(t, err) + + envStatus := getStatus(t, p, set) + // With equal expiries, the smaller value (sdk-aaa) wins deterministically. + st.AssertJSONPathMatch(t, sdks.ObscureKey("sdk-aaa"), envStatus, "expiringSdkKey") + }) + }) +} + +// TestKeyStatus verifies the helper that converts an accepted key into its status-endpoint JSON form. +func TestKeyStatus(t *testing.T) { + strptr := func(s string) *string { return &s } + + t.Run("permanent key with identifier", func(t *testing.T) { + ks := keyStatus("sdk-abc123", credential.AcceptedKey{Key: strptr("default")}) + assert.Equal(t, "default", ks.Key) + assert.Equal(t, sdks.ObscureKey("sdk-abc123"), ks.Value) + assert.Nil(t, ks.Expiry) + }) + + t.Run("nil identifier yields empty Key (omitted in JSON)", func(t *testing.T) { + ks := keyStatus("sdk-legacy", credential.AcceptedKey{Key: nil}) + assert.Equal(t, "", ks.Key) + }) + + t.Run("expiring key has expiry in Unix milliseconds", func(t *testing.T) { + expiry := time.Date(2099, 6, 1, 12, 0, 0, 0, time.UTC) + ks := keyStatus("sdk-old", credential.AcceptedKey{Key: strptr("old-key"), Expiry: &expiry}) + require.NotNil(t, ks.Expiry) + assert.Equal(t, expiry.UnixMilli(), *ks.Expiry) + }) + + t.Run("mobile key value is obscured", func(t *testing.T) { + ks := keyStatus("mob-secret", credential.AcceptedKey{Key: strptr("mob-1")}) + assert.Equal(t, sdks.ObscureKey("mob-secret"), ks.Value) + }) +}