Skip to content

Commit 9b4302f

Browse files
committed
feat(status): surface full sdkKeys[]/mobileKeys[] arrays on /status
Add a KeyStatus representation and sdkKeys[]/mobileKeys[] arrays to EnvironmentStatusRep, populated from GetAcceptedKeys() partitioned by type. Each entry carries the obscured value, the optional wire identifier, and an optional Unix-millisecond expiry. The arrays are always present (never null); a server-only environment has an empty mobileKeys. expiringSdkKey is now computed from the accepted set as the soonest- expiring non-anchor SDK key, with a (expiry, value) tie-break so the pick is deterministic when several keys share an expiry.
1 parent f063227 commit 9b4302f

3 files changed

Lines changed: 239 additions & 13 deletions

File tree

internal/api/status_reps.go

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,41 @@ type StatusRep struct {
1515
ClientVersion string `json:"clientVersion"`
1616
}
1717

18+
// KeyStatus is the JSON representation of one accepted SDK or mobile key in the status endpoint's
19+
// sdkKeys[] / mobileKeys[] arrays.
20+
//
21+
// Key is the non-secret human-readable identifier from the wire format (the "key" field of a
22+
// sdkKeys/mobileKeys entry); it is omitted when the source carried no identifier (manual config, or
23+
// an old-format payload predating concurrent keys). Value is the obscured credential secret (via
24+
// sdks.ObscureKey). Expiry carries the Unix-millisecond expiry timestamp when the key is being phased
25+
// out; it is omitted for permanent keys.
26+
type KeyStatus struct {
27+
Key string `json:"key,omitempty"`
28+
Value string `json:"value"`
29+
Expiry *int64 `json:"expiry,omitempty"`
30+
}
31+
1832
// EnvironmentStatusRep is the per-environment JSON representation returned by the status endpoint.
1933
//
2034
// This is exported for use in integration test code.
2135
type EnvironmentStatusRep struct {
22-
SDKKey string `json:"sdkKey"`
23-
EnvID string `json:"envId,omitempty"`
24-
EnvKey string `json:"envKey,omitempty"`
25-
EnvName string `json:"envName,omitempty"`
26-
ProjKey string `json:"projKey,omitempty"`
27-
ProjName string `json:"projName,omitempty"`
28-
MobileKey string `json:"mobileKey,omitempty"`
36+
// SDKKey is the obscured anchor SDK key — the key relay uses for its upstream connection. It
37+
// designates which SDKKeys entry is the anchor.
38+
SDKKey string `json:"sdkKey"`
39+
// SDKKeys carries the full accepted set of server-side SDK keys — including the anchor — with their
40+
// identifiers, obscured values, and optional expiry. Always present; always contains at least the
41+
// anchor.
42+
SDKKeys []KeyStatus `json:"sdkKeys"`
43+
EnvID string `json:"envId,omitempty"`
44+
EnvKey string `json:"envKey,omitempty"`
45+
EnvName string `json:"envName,omitempty"`
46+
ProjKey string `json:"projKey,omitempty"`
47+
ProjName string `json:"projName,omitempty"`
48+
// MobileKey is the obscured primary mobile key. It designates which MobileKeys entry is the primary.
49+
MobileKey string `json:"mobileKey,omitempty"`
50+
// MobileKeys carries the full accepted set of mobile keys — including the primary. Always present;
51+
// empty for an environment with no mobile keys (e.g. server-side only).
52+
MobileKeys []KeyStatus `json:"mobileKeys"`
2953
ExpiringSDKKey string `json:"expiringSdkKey,omitempty"`
3054
Status string `json:"status"`
3155
ConnectionStatus ConnectionStatusRep `json:"connectionStatus"`

relay/endpoints_status.go

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ package relay
33
import (
44
"encoding/json"
55
"net/http"
6+
"slices"
7+
"strings"
68
"time"
79

810
"github.com/launchdarkly/ld-relay/v8/config"
911
"github.com/launchdarkly/ld-relay/v8/internal/api"
12+
"github.com/launchdarkly/ld-relay/v8/internal/credential"
1013
"github.com/launchdarkly/ld-relay/v8/internal/relayenv"
1114
"github.com/launchdarkly/ld-relay/v8/internal/sdks"
1215

@@ -46,9 +49,7 @@ func statusHandler(relay *Relay) http.Handler {
4649
ProjName: identifiers.ProjName,
4750
}
4851

49-
// Use the anchor SDK key and primary mobile key specifically — GetCredentials() may return
50-
// multiple SDK and mobile keys (primary + expiring), so iterating it for these singular
51-
// status fields would give a non-deterministic result.
52+
// Scalar fields: anchor SDK key and primary mobile key.
5253
if key := clientCtx.GetAnchorKey(); key.Defined() {
5354
status.SDKKey = sdks.ObscureKey(string(key))
5455
}
@@ -60,11 +61,45 @@ func statusHandler(relay *Relay) http.Handler {
6061
status.EnvID = string(envID)
6162
}
6263
}
63-
for _, c := range clientCtx.GetDeprecatedCredentials() {
64-
if key, ok := c.(config.SDKKey); ok {
65-
status.ExpiringSDKKey = sdks.ObscureKey(string(key))
64+
65+
// sdkKeys[] / mobileKeys[]: the full accepted set — including the anchor and primary mobile
66+
// key — partitioned by type. The scalar sdkKey/mobileKey above designate which entry is the
67+
// anchor/primary. Order is unspecified.
68+
anchor := string(clientCtx.GetAnchorKey())
69+
var expiringCandidates []credential.AcceptedKey
70+
for _, k := range clientCtx.GetAcceptedKeys() {
71+
ks := keyStatus(k)
72+
switch k.Type {
73+
case credential.KeyTypeServer:
74+
status.SDKKeys = append(status.SDKKeys, ks)
75+
// expiringSdkKey considers non-anchor server keys that carry an expiry.
76+
if k.Value != anchor && k.Expiry != nil {
77+
expiringCandidates = append(expiringCandidates, k)
78+
}
79+
case credential.KeyTypeMobile:
80+
status.MobileKeys = append(status.MobileKeys, ks)
6681
}
6782
}
83+
// Arrays are always present (never null): a server-only env has an empty mobileKeys.
84+
if status.SDKKeys == nil {
85+
status.SDKKeys = []api.KeyStatus{}
86+
}
87+
if status.MobileKeys == nil {
88+
status.MobileKeys = []api.KeyStatus{}
89+
}
90+
91+
// expiringSdkKey: the soonest-expiring non-anchor SDK key. Comparing by expiry then by value
92+
// gives a total order, so the chosen key is deterministic even when several keys share the
93+
// same expiry (map iteration order, and hence MinFunc's pick on a tie, is otherwise unstable).
94+
if len(expiringCandidates) > 0 {
95+
earliest := slices.MinFunc(expiringCandidates, func(a, b credential.AcceptedKey) int {
96+
if c := a.Expiry.Compare(*b.Expiry); c != 0 {
97+
return c
98+
}
99+
return strings.Compare(a.Value, b.Value)
100+
})
101+
status.ExpiringSDKKey = sdks.ObscureKey(earliest.Value)
102+
}
68103

69104
client := clientCtx.GetClient()
70105
if client == nil {
@@ -155,3 +190,17 @@ func statusHandler(relay *Relay) http.Handler {
155190
_, _ = w.Write(data)
156191
})
157192
}
193+
194+
// keyStatus converts an accepted key into its status-endpoint JSON representation, obscuring the
195+
// secret value and surfacing the optional identifier and expiry.
196+
func keyStatus(k credential.AcceptedKey) api.KeyStatus {
197+
ks := api.KeyStatus{Value: sdks.ObscureKey(k.Value)}
198+
if k.Key != nil {
199+
ks.Key = *k.Key
200+
}
201+
if k.Expiry != nil {
202+
ms := k.Expiry.UnixMilli()
203+
ks.Expiry = &ms
204+
}
205+
return ks
206+
}

relay/endpoints_status_test.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import (
55
"testing"
66
"time"
77

8+
"github.com/launchdarkly/ld-relay/v8/internal/credential"
89
"github.com/launchdarkly/ld-relay/v8/internal/sdkauth"
910

1011
c "github.com/launchdarkly/ld-relay/v8/config"
1112
"github.com/launchdarkly/ld-relay/v8/internal/sdks"
1213
st "github.com/launchdarkly/ld-relay/v8/internal/sharedtest"
1314
"github.com/launchdarkly/ld-relay/v8/internal/sharedtest/testclient"
15+
"github.com/launchdarkly/ld-relay/v8/internal/util"
1416

1517
ct "github.com/launchdarkly/go-configtypes"
1618
"github.com/launchdarkly/go-sdk-common/v3/ldtime"
@@ -56,6 +58,36 @@ func TestEndpointsStatus(t *testing.T) {
5658
st.AssertJSONPathMatch(t, p.relay.version, status, "version")
5759
st.AssertJSONPathMatch(t, ld.Version, status, "clientVersion")
5860
})
61+
62+
t.Run("sdkKeys/mobileKeys arrays carry the full accepted set including the anchor", func(t *testing.T) {
63+
var config c.Config
64+
config.Environment = st.MakeEnvConfigs(st.EnvMain, st.EnvMobile)
65+
66+
withStartedRelay(t, config, func(p relayTestParams) {
67+
r, _ := http.NewRequest("GET", "http://localhost/status", nil)
68+
result, body := st.DoRequest(r, p.relay)
69+
assert.Equal(t, http.StatusOK, result.StatusCode)
70+
status := ldvalue.Parse(body)
71+
72+
// EnvMain is a manually-configured single-key env: sdkKeys[] is present and contains
73+
// exactly the anchor (full set includes it); manual config carries no key identifier.
74+
sdkKeys := status.GetByKey("environments").GetByKey(st.EnvMain.Name).GetByKey("sdkKeys")
75+
require.Equal(t, 1, sdkKeys.Count(), "sdkKeys must contain the anchor")
76+
assert.Equal(t, sdks.ObscureKey(string(st.EnvMain.Config.SDKKey)),
77+
sdkKeys.GetByIndex(0).GetByKey("value").StringValue())
78+
79+
// EnvMain has no mobile key — mobileKeys is present but empty.
80+
mobileKeys := status.GetByKey("environments").GetByKey(st.EnvMain.Name).GetByKey("mobileKeys")
81+
assert.Equal(t, 0, mobileKeys.Count())
82+
assert.Equal(t, ldvalue.ArrayType, mobileKeys.Type(), "mobileKeys present (not null) even when empty")
83+
84+
// EnvMobile has both: its mobile key appears in mobileKeys[].
85+
mobMobileKeys := status.GetByKey("environments").GetByKey(st.EnvMobile.Name).GetByKey("mobileKeys")
86+
require.Equal(t, 1, mobMobileKeys.Count())
87+
assert.Equal(t, sdks.ObscureKey(string(st.EnvMobile.Config.MobileKey)),
88+
mobMobileKeys.GetByIndex(0).GetByKey("value").StringValue())
89+
})
90+
})
5991
})
6092

6193
t.Run("connection interruption - less than DisconnectedStatusTime", func(t *testing.T) {
@@ -131,3 +163,124 @@ func TestEndpointsStatus(t *testing.T) {
131163
})
132164
})
133165
}
166+
167+
// findKeyStatusByValue returns the sdkKeys[]/mobileKeys[] entry whose obscured "value" matches, or a
168+
// null value. Array entry order is unspecified, so callers look entries up by value.
169+
func findKeyStatusByValue(arr ldvalue.Value, obscuredValue string) ldvalue.Value {
170+
for i := 0; i < arr.Count(); i++ {
171+
if arr.GetByIndex(i).GetByKey("value").StringValue() == obscuredValue {
172+
return arr.GetByIndex(i)
173+
}
174+
}
175+
return ldvalue.Null()
176+
}
177+
178+
// TestEndpointsStatusExpiringSDKKey drives a multi-key environment through the real /status handler:
179+
// it reconciles an env to an anchor plus two non-anchor expiring SDK keys and asserts the
180+
// expiringSdkKey selection, the per-key expiry/identifier serialization in sdkKeys[], and that the
181+
// soonest-expiry pick is deterministic on an exact expiry tie.
182+
func TestEndpointsStatusExpiringSDKKey(t *testing.T) {
183+
getStatus := func(t *testing.T, p relayTestParams, set credential.AcceptedSet) ldvalue.Value {
184+
env, err := p.relay.getEnvironment(sdkauth.New(st.EnvMain.Config.SDKKey))
185+
require.NoError(t, err)
186+
require.NotNil(t, env)
187+
env.ReconcileCredentials(set)
188+
189+
r, _ := http.NewRequest("GET", "http://localhost/status", nil)
190+
result, body := st.DoRequest(r, p.relay)
191+
require.Equal(t, http.StatusOK, result.StatusCode)
192+
return ldvalue.Parse(body).GetByKey("environments").GetByKey(st.EnvMain.Name)
193+
}
194+
195+
t.Run("soonest-expiring non-anchor key, with expiry and identifier surfaced", func(t *testing.T) {
196+
var config c.Config
197+
config.Environment = st.MakeEnvConfigs(st.EnvMain)
198+
withStartedRelay(t, config, func(p relayTestParams) {
199+
anchor := st.EnvMain.Config.SDKKey
200+
soon := time.Now().Add(1 * time.Hour)
201+
later := time.Now().Add(2 * time.Hour)
202+
set, err := credential.NewAcceptedSetBuilder().
203+
WithAnchor(credential.SDKKeyParams{Value: anchor}).
204+
WithSDKKey(credential.SDKKeyParams{Value: "sdk-soon", Key: util.PtrOrNil("soon-key"), Expiry: util.PtrOrNil(soon)}).
205+
WithSDKKey(credential.SDKKeyParams{Value: "sdk-later", Expiry: util.PtrOrNil(later)}).
206+
Build()
207+
require.NoError(t, err)
208+
209+
envStatus := getStatus(t, p, set)
210+
211+
// expiringSdkKey is the obscured soonest-expiring non-anchor key.
212+
st.AssertJSONPathMatch(t, sdks.ObscureKey("sdk-soon"), envStatus, "expiringSdkKey")
213+
214+
// sdkKeys[] carries the full set (anchor + both non-anchor keys).
215+
sdkKeys := envStatus.GetByKey("sdkKeys")
216+
require.Equal(t, 3, sdkKeys.Count())
217+
218+
// The expiring key surfaces its identifier and Unix-millis expiry.
219+
soonEntry := findKeyStatusByValue(sdkKeys, sdks.ObscureKey("sdk-soon"))
220+
require.False(t, soonEntry.IsNull())
221+
assert.Equal(t, "soon-key", soonEntry.GetByKey("key").StringValue())
222+
assert.Equal(t, float64(soon.UnixMilli()), soonEntry.GetByKey("expiry").Float64Value())
223+
224+
// A key with no identifier omits "key" entirely (omitempty, nil pointer).
225+
laterEntry := findKeyStatusByValue(sdkKeys, sdks.ObscureKey("sdk-later"))
226+
require.False(t, laterEntry.IsNull())
227+
assert.True(t, laterEntry.GetByKey("key").IsNull(), `"key" must be omitted when the source carried no identifier`)
228+
})
229+
})
230+
231+
t.Run("tie on expiry resolves deterministically to the smaller value", func(t *testing.T) {
232+
var config c.Config
233+
config.Environment = st.MakeEnvConfigs(st.EnvMain)
234+
withStartedRelay(t, config, func(p relayTestParams) {
235+
anchor := st.EnvMain.Config.SDKKey
236+
sameExpiry := time.Now().Add(1 * time.Hour)
237+
set, err := credential.NewAcceptedSetBuilder().
238+
WithAnchor(credential.SDKKeyParams{Value: anchor}).
239+
WithSDKKey(credential.SDKKeyParams{Value: "sdk-bbb", Expiry: util.PtrOrNil(sameExpiry)}).
240+
WithSDKKey(credential.SDKKeyParams{Value: "sdk-aaa", Expiry: util.PtrOrNil(sameExpiry)}).
241+
Build()
242+
require.NoError(t, err)
243+
244+
envStatus := getStatus(t, p, set)
245+
// With equal expiries, the smaller value (sdk-aaa) wins deterministically.
246+
st.AssertJSONPathMatch(t, sdks.ObscureKey("sdk-aaa"), envStatus, "expiringSdkKey")
247+
})
248+
})
249+
}
250+
251+
// TestKeyStatus verifies the helper that converts an accepted key into its status-endpoint JSON form.
252+
func TestKeyStatus(t *testing.T) {
253+
strptr := func(s string) *string { return &s }
254+
255+
t.Run("permanent key with identifier", func(t *testing.T) {
256+
ks := keyStatus(credential.AcceptedKey{
257+
Type: credential.KeyTypeServer, Value: "sdk-abc123", Key: strptr("default"),
258+
})
259+
assert.Equal(t, "default", ks.Key)
260+
assert.Equal(t, sdks.ObscureKey("sdk-abc123"), ks.Value)
261+
assert.Nil(t, ks.Expiry)
262+
})
263+
264+
t.Run("nil identifier yields empty Key (omitted in JSON)", func(t *testing.T) {
265+
ks := keyStatus(credential.AcceptedKey{
266+
Type: credential.KeyTypeServer, Value: "sdk-legacy", Key: nil,
267+
})
268+
assert.Equal(t, "", ks.Key)
269+
})
270+
271+
t.Run("expiring key has expiry in Unix milliseconds", func(t *testing.T) {
272+
expiry := time.Date(2099, 6, 1, 12, 0, 0, 0, time.UTC)
273+
ks := keyStatus(credential.AcceptedKey{
274+
Type: credential.KeyTypeServer, Value: "sdk-old", Key: strptr("old-key"), Expiry: &expiry,
275+
})
276+
require.NotNil(t, ks.Expiry)
277+
assert.Equal(t, expiry.UnixMilli(), *ks.Expiry)
278+
})
279+
280+
t.Run("mobile key value is obscured", func(t *testing.T) {
281+
ks := keyStatus(credential.AcceptedKey{
282+
Type: credential.KeyTypeMobile, Value: "mob-secret", Key: strptr("mob-1"),
283+
})
284+
assert.Equal(t, sdks.ObscureKey("mob-secret"), ks.Value)
285+
})
286+
}

0 commit comments

Comments
 (0)