Skip to content

Commit f063227

Browse files
committed
feat(credential): expose the full accepted key set via AcceptedKeys
Add AcceptedKey (type, value, optional identifier, optional expiry) and KeyType, plus Rotator.AcceptedKeys() to snapshot every accepted SDK and mobile key — anchor and primary mobile key included — and an EnvContext GetAcceptedKeys() accessor that delegates to it. The status endpoint will partition this set by type to build the sdkKeys[] / mobileKeys[] arrays.
1 parent d3d3a0e commit f063227

4 files changed

Lines changed: 149 additions & 4 deletions

File tree

internal/credential/rotator.go

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,32 @@ type acceptedKeyInfo struct {
1414
key *string // wire "key" identifier — non-secret human-readable name; nil when absent
1515
}
1616

17+
// KeyType distinguishes a server-side SDK key from a mobile key in an AcceptedKey. Client-side IDs
18+
// are not part of the accepted-set model and so have no KeyType.
19+
type KeyType int
20+
21+
const (
22+
// KeyTypeServer is a server-side SDK key.
23+
KeyTypeServer KeyType = iota
24+
// KeyTypeMobile is a mobile key.
25+
KeyTypeMobile
26+
)
27+
28+
// AcceptedKey is metadata for one accepted credential, returned by AcceptedKeys. The status endpoint
29+
// uses it to populate the sdkKeys[] / mobileKeys[] response arrays.
30+
type AcceptedKey struct {
31+
// Type is the kind of key — server-side SDK or mobile.
32+
Type KeyType
33+
// Value is the credential secret (what the wire calls "value"). Plain, not obscured; the status
34+
// handler obscures it before serialising.
35+
Value string
36+
// Key is the non-secret human-readable identifier (the wire "key" field). Nil when the source
37+
// carried no identifier — manual configuration, or an old-format payload predating concurrent keys.
38+
Key *string
39+
// Expiry is the key's expiry time. Nil means the key is permanent.
40+
Expiry *time.Time
41+
}
42+
1743
type Rotator struct {
1844
loggers ldlog.Loggers
1945

@@ -120,12 +146,10 @@ func (r *Rotator) allCredentials() []SDKCredential {
120146

121147
// DeprecatedCredentials returns the SDK keys being phased out — every accepted SDK key, other than the
122148
// anchor, that carries a future expiry. (Per-key expiry is stored as data on the accepted entry; the
123-
// cleanup ticker drops the key once it elapses.) EnvContext.GetDeprecatedCredentials delegates here to
124-
// populate the status endpoint's expiringSdkKey field.
149+
// cleanup ticker drops the key once it elapses.)
125150
//
126151
// Mobile keys are deliberately not returned even though they expire the same way SDK keys do — carried
127-
// as per-key expiry and dropped by the same cleanup ticker. They are omitted only because the status
128-
// endpoint has no expiringMobileKey field to populate, not because mobile-key expiry is unimplemented.
152+
// as per-key expiry and dropped by the same cleanup ticker.
129153
func (r *Rotator) DeprecatedCredentials() []SDKCredential {
130154
r.mu.RLock()
131155
defer r.mu.RUnlock()
@@ -314,3 +338,25 @@ func (r *Rotator) reconcileEnvironmentID(set AcceptedSet) {
314338
r.primaryEnvironmentID = set.envID
315339
r.additions = append(r.additions, set.envID)
316340
}
341+
342+
// acceptedKeysOf snapshots one accepted-key map (SDK or mobile) into a slice of AcceptedKey tagged
343+
// with kind. Order is unspecified — the status endpoint does not depend on it. The caller must hold
344+
// at least the read lock.
345+
func acceptedKeysOf[K ~string](accepted map[K]*acceptedKeyInfo, kind KeyType) []AcceptedKey {
346+
out := make([]AcceptedKey, 0, len(accepted))
347+
for key, info := range accepted {
348+
out = append(out, AcceptedKey{Type: kind, Value: string(key), Key: info.key, Expiry: info.expiry})
349+
}
350+
return out
351+
}
352+
353+
// AcceptedKeys returns every accepted credential — all server-side SDK keys and all mobile keys,
354+
// including the anchor and the primary mobile key. The status endpoint partitions the result by Type
355+
// to populate the full sdkKeys[] / mobileKeys[] arrays. Order is unspecified.
356+
func (r *Rotator) AcceptedKeys() []AcceptedKey {
357+
r.mu.RLock()
358+
defer r.mu.RUnlock()
359+
360+
keys := acceptedKeysOf(r.acceptedSDKKeys, KeyTypeServer)
361+
return append(keys, acceptedKeysOf(r.acceptedMobileKeys, KeyTypeMobile)...)
362+
}

internal/credential/rotator_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,3 +266,93 @@ func TestReconcileDeExpiryRestoresKey(t *testing.T) {
266266
assert.Empty(t, expirations)
267267
assert.Contains(t, r.AllCredentials(), SDKCredential(key))
268268
}
269+
270+
// findAcceptedKey returns the entry with the given value, or nil. Used because AcceptedKeys order is
271+
// unspecified.
272+
func findAcceptedKey(entries []AcceptedKey, value string) *AcceptedKey {
273+
for i := range entries {
274+
if entries[i].Value == value {
275+
return &entries[i]
276+
}
277+
}
278+
return nil
279+
}
280+
281+
// TestAcceptedKeys verifies that AcceptedKeys returns the full accepted set — every server and mobile
282+
// key, including the anchor and primary mobile key — with type, identifier, and expiry populated.
283+
func TestAcceptedKeys(t *testing.T) {
284+
t.Run("single anchor plus primary mobile", func(t *testing.T) {
285+
r := newTestRotator()
286+
r.Reconcile(mustBuild(t, NewAcceptedSetBuilder().
287+
WithAnchor(SDKKeyParams{Value: "sdk-anchor", Key: util.PtrOrNil("default")}).
288+
WithPrimaryMobileKey(MobileKeyParams{Value: "mob-primary", Key: util.PtrOrNil("mob-1")})), time.Unix(0, 0))
289+
290+
entries := r.AcceptedKeys()
291+
require.Len(t, entries, 2)
292+
293+
anchor := findAcceptedKey(entries, "sdk-anchor")
294+
require.NotNil(t, anchor)
295+
assert.Equal(t, KeyTypeServer, anchor.Type)
296+
require.NotNil(t, anchor.Key)
297+
assert.Equal(t, "default", *anchor.Key)
298+
assert.Nil(t, anchor.Expiry)
299+
300+
mob := findAcceptedKey(entries, "mob-primary")
301+
require.NotNil(t, mob)
302+
assert.Equal(t, KeyTypeMobile, mob.Type)
303+
require.NotNil(t, mob.Key)
304+
assert.Equal(t, "mob-1", *mob.Key)
305+
})
306+
307+
t.Run("multiple keys include the anchor; expiry populated", func(t *testing.T) {
308+
r := newTestRotator()
309+
expiry := time.Date(2099, 6, 1, 0, 0, 0, 0, time.UTC)
310+
r.Reconcile(mustBuild(t, NewAcceptedSetBuilder().
311+
WithAnchor(SDKKeyParams{Value: "sdk-anchor", Key: util.PtrOrNil("default")}).
312+
WithSDKKey(SDKKeyParams{Value: "sdk-b", Key: util.PtrOrNil("b-service")}).
313+
WithSDKKey(SDKKeyParams{Value: "sdk-old", Key: util.PtrOrNil("old-key"), Expiry: util.PtrOrNil(expiry)}).
314+
WithPrimaryMobileKey(MobileKeyParams{Value: "mob-primary"})), time.Unix(0, 0))
315+
316+
entries := r.AcceptedKeys()
317+
require.Len(t, entries, 4) // anchor + sdk-b + sdk-old + mob-primary
318+
assert.NotNil(t, findAcceptedKey(entries, "sdk-anchor"), "anchor must be present in the full set")
319+
320+
old := findAcceptedKey(entries, "sdk-old")
321+
require.NotNil(t, old)
322+
require.NotNil(t, old.Expiry)
323+
assert.Equal(t, expiry, *old.Expiry)
324+
325+
// A key with no identifier (the primary mobile here) carries a nil Key.
326+
mob := findAcceptedKey(entries, "mob-primary")
327+
require.NotNil(t, mob)
328+
assert.Nil(t, mob.Key)
329+
})
330+
}
331+
332+
// TestReconcileClearsStaleKeyIdentifier verifies that when a later reconcile carries no identifier for
333+
// a key that previously had one (e.g. an old-format payload after a new-format one), the rotator
334+
// clears the stale identifier rather than retaining it — so /status never shows an identifier the
335+
// current credential set no longer carries.
336+
func TestReconcileClearsStaleKeyIdentifier(t *testing.T) {
337+
r := newTestRotator()
338+
now := time.Unix(0, 0)
339+
340+
// First reconcile: sdk-b carries the identifier "b-service".
341+
r.Reconcile(mustBuild(t, NewAcceptedSetBuilder().
342+
WithAnchor(SDKKeyParams{Value: "sdk-anchor"}).
343+
WithSDKKey(SDKKeyParams{Value: "sdk-b", Key: util.PtrOrNil("b-service")})), now)
344+
345+
b := findAcceptedKey(r.AcceptedKeys(), "sdk-b")
346+
require.NotNil(t, b)
347+
require.NotNil(t, b.Key)
348+
assert.Equal(t, "b-service", *b.Key)
349+
350+
// Second reconcile: same credential value, but no identifier this time.
351+
r.Reconcile(mustBuild(t, NewAcceptedSetBuilder().
352+
WithAnchor(SDKKeyParams{Value: "sdk-anchor"}).
353+
WithSDKKey(SDKKeyParams{Value: "sdk-b"})), now)
354+
355+
b = findAcceptedKey(r.AcceptedKeys(), "sdk-b")
356+
require.NotNil(t, b)
357+
assert.Nil(t, b.Key, "identifier must be cleared when the new payload carries none")
358+
}

internal/relayenv/env_context.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ type EnvContext interface {
5050
// several accepted mobile keys (primary + expiring) in nondeterministic order.
5151
GetMobileKey() config.MobileKey
5252

53+
// GetAcceptedKeys returns metadata for every accepted credential — all server-side SDK keys and
54+
// all mobile keys, including the anchor and the primary mobile key. The status endpoint partitions
55+
// the result by type to populate the full sdkKeys[] / mobileKeys[] arrays.
56+
GetAcceptedKeys() []credential.AcceptedKey
57+
5358
// ReconcileCredentials atomically reconciles the environment's accepted credentials to match
5459
// newSet. The set names its own anchor (the SDK key that owns the upstream connection) and
5560
// primary mobile key. The method owns the order of operations internally (add → re-anchor →

internal/relayenv/env_context_impl.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,10 @@ func (c *envContextImpl) GetDeprecatedCredentials() []credential.SDKCredential {
640640
return c.keyRotator.DeprecatedCredentials()
641641
}
642642

643+
func (c *envContextImpl) GetAcceptedKeys() []credential.AcceptedKey {
644+
return c.keyRotator.AcceptedKeys()
645+
}
646+
643647
func (c *envContextImpl) GetClient() sdks.LDClientContext {
644648
c.mu.RLock()
645649
defer c.mu.RUnlock()

0 commit comments

Comments
 (0)