Skip to content

Commit

Permalink
feat: offline mode key rotation
Browse files Browse the repository at this point in the history
  • Loading branch information
cwaldren-ld committed Jun 24, 2024
1 parent 047bc27 commit 513445d
Show file tree
Hide file tree
Showing 4 changed files with 154 additions and 4 deletions.
18 changes: 17 additions & 1 deletion relay/filedata_actions.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package relay

import (
"github.com/launchdarkly/ld-relay/v8/internal/relayenv"
"time"

"github.com/launchdarkly/ld-relay/v8/internal/sdkauth"
Expand Down Expand Up @@ -49,11 +50,15 @@ func (a *relayFileDataActions) AddEnvironment(ae filedata.ArchiveEnvironment) {
return config
}
envConfig := envfactory.NewEnvConfigFactoryForOfflineMode(a.r.config.OfflineMode).MakeEnvironmentConfig(ae.Params)
_, _, err := a.r.addEnvironment(ae.Params.Identifiers, envConfig, transformConfig)
env, _, err := a.r.addEnvironment(ae.Params.Identifiers, envConfig, transformConfig)
if err != nil {
a.r.loggers.Errorf(logMsgAutoConfEnvInitError, ae.Params.Identifiers.GetDisplayName(), err)
return
}
if ae.Params.ExpiringSDKKey.Defined() {
update := relayenv.NewCredentialUpdate(ae.Params.SDKKey)
env.UpdateCredential(update.WithGracePeriod(ae.Params.ExpiringSDKKey.Key, ae.Params.ExpiringSDKKey.Expiration))
}
select {
case updates := <-updatesCh:
if a.envUpdates == nil {
Expand Down Expand Up @@ -83,6 +88,17 @@ func (a *relayFileDataActions) UpdateEnvironment(ae filedata.ArchiveEnvironment)
env.SetTTL(ae.Params.TTL)
env.SetSecureMode(ae.Params.SecureMode)

if ae.Params.MobileKey.Defined() {
env.UpdateCredential(relayenv.NewCredentialUpdate(ae.Params.MobileKey))
}
if ae.Params.SDKKey.Defined() {
update := relayenv.NewCredentialUpdate(ae.Params.SDKKey)
if ae.Params.ExpiringSDKKey.Defined() {
update = update.WithGracePeriod(ae.Params.ExpiringSDKKey.Key, ae.Params.ExpiringSDKKey.Expiration)
}
env.UpdateCredential(update)
}

// SDKData will be non-nil only if the flag/segment data for the environment has actually changed.
if ae.SDKData != nil {
updates.Init(ae.SDKData)
Expand Down
101 changes: 100 additions & 1 deletion relay/filedata_actions_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package relay

import (
"fmt"
"github.com/launchdarkly/ld-relay/v8/internal/credential"
"net/http"
"net/http/httptest"
"sort"
Expand Down Expand Up @@ -90,7 +92,11 @@ func offlineModeTest(
}

func (p offlineModeTestParams) awaitClient() testclient.CapturedLDClient {
return helpers.RequireValue(p.t, p.clientsCreatedCh, time.Second, "timed out waiting for client creation")
return p.awaitClientFor(time.Second)
}

func (p offlineModeTestParams) awaitClientFor(duration time.Duration) testclient.CapturedLDClient {
return helpers.RequireValue(p.t, p.clientsCreatedCh, duration, "timed out waiting for client creation")
}

func (p offlineModeTestParams) shouldNotCreateClient(timeout time.Duration) {
Expand Down Expand Up @@ -205,3 +211,96 @@ func TestOfflineModeEventsAreAcceptedAndDiscardedIfSendEventsIsTrue(t *testing.T
})
})
}

func TestOfflineModeDeprecatedSDKKeyIsRespectedIfExpiryInFuture(t *testing.T) {
// The goal here is to validate that if we load an offline mode archive containing a deprecated key,
// it will be added as a credential (even though it was never previously seen as a primary key.) This situation
// would happen when Relay is starting up at time T if the key was deprecated at a time before T.

offlineModeTest(t, config.Config{}, func(p offlineModeTestParams) {

envData := RotateSDKKeyWithGracePeriod("primary-key", "deprecated-key", time.Now().Add(1*time.Hour))

p.updateHandler.AddEnvironment(envData)

client1 := p.awaitClient()
assert.Equal(t, envData.Params.SDKKey, client1.Key)

env := p.awaitEnvironment(testFileDataEnv1.Params.EnvID)

assert.ElementsMatch(t, []credential.SDKCredential{envData.Params.SDKKey, envData.Params.EnvID}, env.GetCredentials())
assert.ElementsMatch(t, []credential.SDKCredential{envData.Params.ExpiringSDKKey.Key}, env.GetDeprecatedCredentials())
})
}

func TestOfflineModePrimarySDKKeyIsDeprecated(t *testing.T) {
offlineModeTest(t, config.Config{}, func(p offlineModeTestParams) {
update1 := RotateSDKKey("key1")

p.updateHandler.AddEnvironment(update1)

expectedClient1 := p.awaitClient()
assert.Equal(t, update1.Params.SDKKey, expectedClient1.Key)

env := p.awaitEnvironment(update1.Params.EnvID)

assert.ElementsMatch(t, []credential.SDKCredential{update1.Params.SDKKey, update1.Params.EnvID}, env.GetCredentials())
assert.Empty(t, env.GetDeprecatedCredentials())

update2 := RotateSDKKeyWithGracePeriod("key2", "key1", time.Now().Add(1*time.Hour))
p.updateHandler.UpdateEnvironment(update2)

assert.ElementsMatch(t, []credential.SDKCredential{update2.Params.SDKKey, update1.Params.EnvID}, env.GetCredentials())
assert.ElementsMatch(t, []credential.SDKCredential{update2.Params.ExpiringSDKKey.Key}, env.GetDeprecatedCredentials())

update3 := RotateSDKKey("key3")
p.updateHandler.UpdateEnvironment(update3)

assert.ElementsMatch(t, []credential.SDKCredential{update3.Params.SDKKey, update1.Params.EnvID}, env.GetCredentials())

// Note: key2 isn't in the deprecated list, because update3 was an immediate rotation (with no grace period for the
// previous key.) At the same time, key1 is still deprecated until the hour is up.
assert.ElementsMatch(t, []credential.SDKCredential{update2.Params.ExpiringSDKKey.Key}, env.GetDeprecatedCredentials())
})
}

func TestOfflineModeSDKKeyCanExpire(t *testing.T) {
// This test aims to deprecate an SDK key, sleep until the expiry, and then verify that the
// key is no longer accepted.
//
// This test is extremely timing dependent, because we're unable to easily inject a mocked time
// into the lower level components under test.

// Instead, we configure the credential cleanup interval to be as short as possible (100ms)
// and then sleep at least that amount of time after specifying a key expiry. The intention is to
// simulate a real scenario, but fast enough for a test.

const minimumCleanupInterval = 100 * time.Millisecond

cfg := config.Config{}
cfg.Main.ExpiredCredentialCleanupInterval = configtypes.NewOptDuration(minimumCleanupInterval)

offlineModeTest(t, cfg, func(p offlineModeTestParams) {

for i := 0; i < 3; i++ {
primary := config.SDKKey(fmt.Sprintf("key%v", i+1))
expiring := config.SDKKey(fmt.Sprintf("key%v", i))

// It's important that the expiry be in the future (so that the key isn't ignored by the key rotator
// component), but it should also be in the near future so the test doesn't need to sleep long.
keyExpiry := time.Now().Add(10 * time.Millisecond)
update1 := RotateSDKKeyWithGracePeriod(primary, expiring, keyExpiry)
p.updateHandler.AddEnvironment(update1)

// Waiting for the environment can take up to 1 second, but it could be much faster. In any case
// we'll still need to sleep at least the cleanup interval to ensure the key is expired.
env := p.awaitEnvironmentFor(update1.Params.EnvID, time.Second)
assert.ElementsMatch(t, []credential.SDKCredential{update1.Params.SDKKey, update1.Params.EnvID}, env.GetCredentials())
assert.ElementsMatch(t, []credential.SDKCredential{update1.Params.ExpiringSDKKey.Key}, env.GetDeprecatedCredentials())

time.Sleep(minimumCleanupInterval)
assert.ElementsMatch(t, []credential.SDKCredential{update1.Params.SDKKey, update1.Params.EnvID}, env.GetCredentials())
assert.Empty(t, env.GetDeprecatedCredentials())
}
})
}
31 changes: 31 additions & 0 deletions relay/filedata_testdata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/launchdarkly/ld-relay/v8/internal/filedata"
"github.com/launchdarkly/ld-relay/v8/internal/relayenv"
"github.com/launchdarkly/ld-relay/v8/internal/sharedtest"
"time"

"github.com/launchdarkly/go-server-sdk-evaluation/v3/ldbuilders"
"github.com/launchdarkly/go-server-sdk/v7/subsystems/ldstoreimpl"
Expand Down Expand Up @@ -58,3 +59,33 @@ var testFileDataEnv2 = filedata.ArchiveEnvironment{
},
},
}

func RotateSDKKey(primary config.SDKKey) filedata.ArchiveEnvironment {
return RotateSDKKeyWithGracePeriod(primary, "", time.Time{})
}

func RotateSDKKeyWithGracePeriod(primary config.SDKKey, expiring config.SDKKey, expiry time.Time) filedata.ArchiveEnvironment {
return filedata.ArchiveEnvironment{
Params: envfactory.EnvironmentParams{
EnvID: "env1",
SDKKey: primary,
ExpiringSDKKey: envfactory.ExpiringSDKKey{
Key: expiring,
Expiration: expiry,
},
Identifiers: relayenv.EnvIdentifiers{
ProjName: "Project",
ProjKey: "project",
EnvName: "Env1",
EnvKey: "env1",
},
},
SDKData: []ldstoretypes.Collection{
{
Kind: ldstoreimpl.Features(),
Items: []ldstoretypes.KeyedItemDescriptor{
{Key: testFileDataFlag1.Key, Item: sharedtest.FlagDesc(testFileDataFlag1)},
},
},
}}
}
8 changes: 6 additions & 2 deletions relay/testutils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,21 @@ type relayTestHelper struct {
relay *Relay
}

func (h relayTestHelper) awaitEnvironment(envID c.EnvironmentID) relayenv.EnvContext {
func (h relayTestHelper) awaitEnvironmentFor(envID c.EnvironmentID, duration time.Duration) relayenv.EnvContext {
h.t.Helper()
var e relayenv.EnvContext
var err error
require.Eventually(h.t, func() bool {
e, err = h.relay.getEnvironment(sdkauth.New(envID))
return err == nil
}, time.Second, time.Millisecond*5)
}, duration, time.Millisecond*5)
return e
}

func (h relayTestHelper) awaitEnvironment(envID c.EnvironmentID) relayenv.EnvContext {
return h.awaitEnvironmentFor(envID, time.Second)
}

func (h relayTestHelper) shouldNotHaveEnvironment(envID c.EnvironmentID, timeout time.Duration) {
h.t.Helper()
require.Eventually(h.t, func() bool {
Expand Down

0 comments on commit 513445d

Please sign in to comment.