Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: offline mode key rotation #408

Merged
merged 27 commits into from
Jun 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a036eae
refactor: add rotator component
cwaldren-ld Jun 12, 2024
6c29d17
tests
cwaldren-ld Jun 12, 2024
373c539
in progress
cwaldren-ld Jun 13, 2024
ee67b77
more refactoring
cwaldren-ld Jun 14, 2024
7f96b6b
plumbing key rotator
cwaldren-ld Jun 18, 2024
3669a07
it builds
cwaldren-ld Jun 18, 2024
4b7b2ba
updating tests
cwaldren-ld Jun 19, 2024
260640b
fix bug with immediate revocation
cwaldren-ld Jun 19, 2024
9fc465c
refactor the rotator to be more testable without any time dependency
cwaldren-ld Jun 20, 2024
74858da
update tests and add new configuration item
cwaldren-ld Jun 21, 2024
4c8d4aa
update docs
cwaldren-ld Jun 21, 2024
1e49590
lints
cwaldren-ld Jun 21, 2024
6191e58
remove old tests
cwaldren-ld Jun 21, 2024
84427b0
goimports
cwaldren-ld Jun 21, 2024
3666691
more tests
cwaldren-ld Jun 24, 2024
b300967
fix some nits
cwaldren-ld Jun 24, 2024
047bc27
fix envrep conversion
cwaldren-ld Jun 24, 2024
bf29380
comments & rename rotator.Query to rotator.StepTime
cwaldren-ld Jun 25, 2024
1b41db3
feat: offline mode key rotation
cwaldren-ld Jun 24, 2024
0242c46
plumb a new 'offline' bool throughout environment config
cwaldren-ld Jun 25, 2024
79d18fa
more comments
cwaldren-ld Jun 25, 2024
3498aff
Merge branch 'v8' into sc-246233/offline-mode-key-rotation
cwaldren-ld Jun 25, 2024
41d58b9
refactor the new offline bool out of EnvParams
cwaldren-ld Jun 25, 2024
2851a8f
refactoring
cwaldren-ld Jun 25, 2024
247f9dd
more offline mode integration tests
cwaldren-ld Jun 25, 2024
46c64a5
refactor tests into individual tests
cwaldren-ld Jun 25, 2024
6255451
reduce flakiness
cwaldren-ld Jun 25, 2024
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
1 change: 1 addition & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ type EnvConfig struct {
TTL ct.OptDuration `conf:"LD_TTL_"`
ProjKey string `conf:"LD_PROJ_KEY_"`
FilterKey FilterKey // injected based on [filters] section
Offline bool // set to true if this environment was created in offline mode
}

type FiltersConfig struct {
Expand Down
16 changes: 8 additions & 8 deletions integrationtests/autoconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func testPolicyUpdate(t *testing.T, manager *integrationTestManager) {
manager.awaitRelayStatus(t, func(status api.StatusRep) bool {
if len(status.Environments) == 1 {
if envStatus, ok := status.Environments[string(remainingEnv.id)]; ok {
verifyEnvProperties(t, testData.project, remainingEnv, envStatus, true)
verifyEnvProperties(t, testData.project, remainingEnv, envStatus, &envPropertyExpectations{nameAndKey: true})
return true
}
}
Expand All @@ -96,7 +96,7 @@ func testAddEnvironment(t *testing.T, manager *integrationTestManager) {
manager.awaitRelayStatus(t, func(status api.StatusRep) bool {
if len(status.Environments) == len(testData.environments)+1 {
if envStatus, ok := status.Environments[string(newEnv.id)]; ok {
verifyEnvProperties(t, testData.project, newEnv, envStatus, true)
verifyEnvProperties(t, testData.project, newEnv, envStatus, &envPropertyExpectations{nameAndKey: true})
return true
}
}
Expand Down Expand Up @@ -136,7 +136,7 @@ func testUpdatedSDKKeyWithoutExpiry(t *testing.T, manager *integrationTestManage

manager.awaitRelayStatus(t, func(status api.StatusRep) bool {
if envStatus, ok := status.Environments[string(envToUpdate.id)]; ok {
verifyEnvProperties(t, testData.project, updatedEnv, envStatus, true)
verifyEnvProperties(t, testData.project, updatedEnv, envStatus, &envPropertyExpectations{nameAndKey: true})
return last5(envStatus.SDKKey) == last5(string(newKey)) && envStatus.ExpiringSDKKey == ""
}
return false
Expand Down Expand Up @@ -166,7 +166,7 @@ func testUpdatedSDKKeyWithExpiry(t *testing.T, manager *integrationTestManager)
return false
}
if envStatus, ok := status.Environments[string(envToUpdate.id)]; ok {
verifyEnvProperties(t, testData.project, updatedEnv, envStatus, true)
verifyEnvProperties(t, testData.project, updatedEnv, envStatus, &envPropertyExpectations{nameAndKey: true})
return last5(envStatus.SDKKey) == last5(string(newKey)) &&
last5(envStatus.ExpiringSDKKey) == last5(string(oldKey))
}
Expand Down Expand Up @@ -204,13 +204,13 @@ func testUpdatedSDKKeyWithExpiryBeforeStartingRelay(t *testing.T, manager *integ
})
defer manager.stopRelay(t)

manager.awaitEnvironments(t, projAndEnvs, false, func(proj projectInfo, env environmentInfo) string {
manager.awaitEnvironments(t, projAndEnvs, nil, func(proj projectInfo, env environmentInfo) string {
return string(env.id)
})

manager.awaitRelayStatus(t, func(status api.StatusRep) bool {
if envStatus, ok := status.Environments[string(envToUpdate.id)]; ok {
verifyEnvProperties(t, testData.project, updatedEnv, envStatus, true)
verifyEnvProperties(t, testData.project, updatedEnv, envStatus, &envPropertyExpectations{nameAndKey: true})
return last5(envStatus.SDKKey) == last5(string(newKey)) &&
last5(envStatus.ExpiringSDKKey) == last5(string(oldKey))
}
Expand Down Expand Up @@ -238,7 +238,7 @@ func testUpdatedMobileKey(t *testing.T, manager *integrationTestManager) {

manager.awaitRelayStatus(t, func(status api.StatusRep) bool {
if envStatus, ok := status.Environments[string(envToUpdate.id)]; ok {
verifyEnvProperties(t, testData.project, updatedEnv, envStatus, true)
verifyEnvProperties(t, testData.project, updatedEnv, envStatus, &envPropertyExpectations{nameAndKey: true})
return last5(envStatus.MobileKey) == last5(string(newKey))
}
return false
Expand Down Expand Up @@ -286,7 +286,7 @@ func withRelayAndTestData(t *testing.T, manager *integrationTestManager, action

func awaitInitialState(t *testing.T, manager *integrationTestManager, testData autoConfigTestData) {
projsAndEnvs := projsAndEnvs{testData.project: testData.environments}
manager.awaitEnvironments(t, projsAndEnvs, true, func(proj projectInfo, env environmentInfo) string {
manager.awaitEnvironments(t, projsAndEnvs, &envPropertyExpectations{nameAndKey: true}, func(proj projectInfo, env environmentInfo) string {
return string(env.id)
})
}
215 changes: 211 additions & 4 deletions integrationtests/offline_mode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ package integrationtests
import (
"fmt"
"io"
"maps"
"net/http"
"os"
"path/filepath"
"testing"
"time"

"github.com/launchdarkly/ld-relay/v8/config"

Expand All @@ -25,8 +27,35 @@ type offlineModeTestData struct {
autoConfigID autoConfigID
}

// Used to configure the environment/project setup for an offline mode test.
type apiParams struct {
// How many projects to create.
numProjects int
// Within each project, how many environments to create. Note: this must be >= 2 due to the way test flag
// variations are setup.
numEnvironments int
}

func testOfflineMode(t *testing.T, manager *integrationTestManager) {
withOfflineModeTestData(t, manager, func(testData offlineModeTestData) {
t.Run("expected environments and flag values", func(t *testing.T) {
testExpectedEnvironmentsAndFlagValues(t, manager)
})
t.Run("sdk key is rotated with deprecation after relay has started", func(t *testing.T) {
testSDKKeyRotatedAfterRelayStarted(t, manager)
})
t.Run("sdk key is rotated with deprecation before relay has started", func(t *testing.T) {
testSDKKeyRotatedBeforeRelayStarted(t, manager)
})
t.Run("sdk key is rotated and then expires", func(t *testing.T) {
testSDKKeyExpires(t, manager)
})
t.Run("sdk key is rotated multiple times without deprecation after relay started", func(t *testing.T) {
testKeyIsRotatedWithoutGracePeriod(t, manager)
})
}

func testExpectedEnvironmentsAndFlagValues(t *testing.T, manager *integrationTestManager) {
withOfflineModeTestData(t, manager, apiParams{numEnvironments: 2, numProjects: 2}, func(testData offlineModeTestData) {
helpers.WithTempDir(func(dirPath string) {
fileName := "archive.tar.gz"
filePath := filepath.Join(manager.relaySharedDir, fileName)
Expand All @@ -40,16 +69,194 @@ func testOfflineMode(t *testing.T, manager *integrationTestManager) {
})
defer manager.stopRelay(t)

manager.awaitEnvironments(t, testData.projsAndEnvs, true, func(proj projectInfo, env environmentInfo) string {
manager.awaitEnvironments(t, testData.projsAndEnvs, &envPropertyExpectations{nameAndKey: true}, func(proj projectInfo, env environmentInfo) string {
return string(env.id)
})
manager.verifyFlagValues(t, testData.projsAndEnvs)
})
})
}

func testSDKKeyRotatedAfterRelayStarted(t *testing.T, manager *integrationTestManager) {
// If we download an archive with a primary SDK key, and then it is subsequently updated
// with a deprecated key, we become initialized with both keys present.
withOfflineModeTestData(t, manager, apiParams{numEnvironments: 2, numProjects: 1}, func(testData offlineModeTestData) {
helpers.WithTempDir(func(dirPath string) {
fileName := "archive.tar.gz"
filePath := filepath.Join(manager.relaySharedDir, fileName)

err := downloadRelayArchive(manager, testData.autoConfigKey, filePath)
manager.apiHelper.logResult("Download data archive from /relay/latest-all to "+filePath, err)
require.NoError(t, err)

// Ensure the key won't expire during this test.
const keyGracePeriod = 1 * time.Hour
// Relay will check for expired keys at this interval.
const cleanupInterval = 100 * time.Millisecond
// We'll sleep longer than the interval after rotating keys, to try and reduce test flakiness.
const cleanupIntervalBuffer = 1 * time.Second

manager.startRelay(t, map[string]string{
"FILE_DATA_SOURCE": filepath.Join(relayContainerSharedDir, fileName),
"EXPIRED_CREDENTIAL_CLEANUP_INTERVAL": cleanupInterval.String(),
})
defer manager.stopRelay(t)

manager.awaitEnvironments(t, testData.projsAndEnvs, &envPropertyExpectations{nameAndKey: true}, func(proj projectInfo, env environmentInfo) string {
return string(env.id)
})
manager.verifyFlagValues(t, testData.projsAndEnvs)

// The updated map will is modified to contain expiringSdkKey field (with the old SDK key) and
// the new key set to whatever the API call returned.
updated := manager.rotateSDKKeys(t, testData.projsAndEnvs, time.Now().Add(keyGracePeriod))

err = downloadRelayArchive(manager, testData.autoConfigKey, filePath)
manager.apiHelper.logResult("Download data archive from /relay/latest-all to "+filePath, err)
require.NoError(t, err)

time.Sleep(cleanupIntervalBuffer)

// We are now asserting that the environment credentials returned by the status endpoint contains not just
// the new SDK key, but the expiring one as well.
manager.awaitEnvironments(t, updated, &envPropertyExpectations{nameAndKey: true, sdkKeys: true}, func(proj projectInfo, env environmentInfo) string {
return string(env.id)
})
})
})
}

func testSDKKeyRotatedBeforeRelayStarted(t *testing.T, manager *integrationTestManager) {
// Upon startup if an archive contains a primary and deprecated key, we become initialized with both keys.
withOfflineModeTestData(t, manager, apiParams{numEnvironments: 2, numProjects: 1}, func(testData offlineModeTestData) {
helpers.WithTempDir(func(dirPath string) {
fileName := "archive.tar.gz"
filePath := filepath.Join(manager.relaySharedDir, fileName)

// Relay will check for expired keys at this interval.
const cleanupInterval = 100 * time.Millisecond
// We'll sleep longer than the interval after rotating keys, to try and reduce test flakiness.
const cleanupIntervalBuffer = 1 * time.Second

// Rotation happens before starting up the relay.
updated := manager.rotateSDKKeys(t, testData.projsAndEnvs, time.Now().Add(1*time.Hour))

err := downloadRelayArchive(manager, testData.autoConfigKey, filePath)
manager.apiHelper.logResult("Download data archive from /relay/latest-all to "+filePath, err)
require.NoError(t, err)

manager.startRelay(t, map[string]string{
"FILE_DATA_SOURCE": filepath.Join(relayContainerSharedDir, fileName),
"EXPIRED_CREDENTIAL_CLEANUP_INTERVAL": cleanupInterval.String(),
})
defer manager.stopRelay(t)

time.Sleep(cleanupIntervalBuffer)

manager.awaitEnvironments(t, updated, &envPropertyExpectations{nameAndKey: true, sdkKeys: true}, func(proj projectInfo, env environmentInfo) string {
return string(env.id)
})
manager.verifyFlagValues(t, testData.projsAndEnvs)
})
})
}

func testSDKKeyExpires(t *testing.T, manager *integrationTestManager) {
// If a key is deprecated and then expires, it should be removed from the environment credentials.
withOfflineModeTestData(t, manager, apiParams{numEnvironments: 2, numProjects: 1}, func(testData offlineModeTestData) {
helpers.WithTempDir(func(dirPath string) {
fileName := "archive.tar.gz"
filePath := filepath.Join(manager.relaySharedDir, fileName)

const keyGracePeriod = 5 * time.Second
// Relay will check for expired keys at this interval.
const cleanupInterval = 100 * time.Millisecond

// Rotation happens before starting up the relay.
updated := manager.rotateSDKKeys(t, testData.projsAndEnvs, time.Now().Add(keyGracePeriod))
then := time.Now()

err := downloadRelayArchive(manager, testData.autoConfigKey, filePath)
manager.apiHelper.logResult("Download data archive from /relay/latest-all to "+filePath, err)
require.NoError(t, err)

manager.startRelay(t, map[string]string{
"FILE_DATA_SOURCE": filepath.Join(relayContainerSharedDir, fileName),
"EXPIRED_CREDENTIAL_CLEANUP_INTERVAL": cleanupInterval.String(),
})
defer manager.stopRelay(t)

manager.awaitEnvironments(t, updated, &envPropertyExpectations{nameAndKey: true, sdkKeys: true}, func(proj projectInfo, env environmentInfo) string {
return string(env.id)
})

// This test is timing-dependant on Relay removing the expired keys before we check the /status endpoint.
// To keep the test fast, only sleep as long as necessary to ensure the keys have expired.
toSleep := keyGracePeriod - time.Since(then)
if toSleep > 0 {
time.Sleep(toSleep)
}
manager.awaitEnvironments(t, updated.withoutExpiringKeys(), &envPropertyExpectations{nameAndKey: true, sdkKeys: true}, func(proj projectInfo, env environmentInfo) string {
return string(env.id)
})
})
})
}

func testKeyIsRotatedWithoutGracePeriod(t *testing.T, manager *integrationTestManager) {
// If a key is rotated without a grace period, then the old one should be revoked immediately.
// If a key is deprecated and then expires, it should be removed from the environment credentials.
withOfflineModeTestData(t, manager, apiParams{numEnvironments: 2, numProjects: 1}, func(testData offlineModeTestData) {
helpers.WithTempDir(func(dirPath string) {
fileName := "archive.tar.gz"
filePath := filepath.Join(manager.relaySharedDir, fileName)

err := downloadRelayArchive(manager, testData.autoConfigKey, filePath)
manager.apiHelper.logResult("Download data archive from /relay/latest-all to "+filePath, err)
require.NoError(t, err)

// Relay will check for expired keys at this interval.
const cleanupInterval = 100 * time.Millisecond
// We'll sleep longer than the interval after rotating keys, to try and reduce test flakiness.
const cleanupIntervalBuffer = 1 * time.Second

fmt.Println(cleanupInterval)
manager.startRelay(t, map[string]string{
"FILE_DATA_SOURCE": filepath.Join(relayContainerSharedDir, fileName),
"EXPIRED_CREDENTIAL_CLEANUP_INTERVAL": cleanupInterval.String(),
})
defer manager.stopRelay(t)

manager.awaitEnvironments(t, testData.projsAndEnvs, &envPropertyExpectations{nameAndKey: true}, func(proj projectInfo, env environmentInfo) string {
return string(env.id)
})
manager.verifyFlagValues(t, testData.projsAndEnvs)

updated := maps.Clone(testData.projsAndEnvs)

// Check that the rotation logic holds for more than one rotation.
const numRotations = 3
for i := 0; i < numRotations; i++ {
// time.Time{} to signify that there's no deprecation period.
updated = manager.rotateSDKKeys(t, updated, time.Time{})

err = downloadRelayArchive(manager, testData.autoConfigKey, filePath)
manager.apiHelper.logResult("Download data archive from /relay/latest-all to "+filePath, err)
require.NoError(t, err)

time.Sleep(cleanupIntervalBuffer)

// We are now asserting that the SDK key was rotated (and that there's no expiringSDKKey).
manager.awaitEnvironments(t, updated, &envPropertyExpectations{nameAndKey: true, sdkKeys: true}, func(proj projectInfo, env environmentInfo) string {
return string(env.id)
})
}
})
})
}

func withOfflineModeTestData(t *testing.T, manager *integrationTestManager, fn func(offlineModeTestData)) {
projsAndEnvs, err := manager.apiHelper.createProjectsAndEnvironments(2, 2)
func withOfflineModeTestData(t *testing.T, manager *integrationTestManager, cfg apiParams, fn func(offlineModeTestData)) {
projsAndEnvs, err := manager.apiHelper.createProjectsAndEnvironments(cfg.numProjects, cfg.numEnvironments)
require.NoError(t, err)
defer manager.apiHelper.deleteProjects(projsAndEnvs)

Expand Down
24 changes: 17 additions & 7 deletions integrationtests/projects_and_environments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,30 @@ type projectInfo struct {
}

type environmentInfo struct {
id config.EnvironmentID
key string
name string
sdkKey config.SDKKey
mobileKey config.MobileKey
prefix string
projKey string
id config.EnvironmentID
key string
name string
sdkKey config.SDKKey
expiringSdkKey config.SDKKey
mobileKey config.MobileKey
prefix string
projKey string

// this is a synthetic field, set only when this environment is a filtered environment.
filterKey config.FilterKey
}

type projsAndEnvs map[projectInfo][]environmentInfo

func (pe projsAndEnvs) withoutExpiringKeys() projsAndEnvs {
for _, envs := range pe {
for i := range envs {
envs[i].expiringSdkKey = ""
}
}
return pe
}

func (pe projsAndEnvs) enumerateEnvs(fn func(projectInfo, environmentInfo)) {
for proj, envs := range pe {
for _, env := range envs {
Expand Down
4 changes: 2 additions & 2 deletions integrationtests/standard_mode_payload_filters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func testStandardModeWithDefaultFilters(t *testing.T, manager *integrationTestMa
manager.startRelay(t, envVars)
defer manager.stopRelay(t)

manager.awaitEnvironments(t, testData.projsAndEnvs, false, func(proj projectInfo, env environmentInfo) string {
manager.awaitEnvironments(t, testData.projsAndEnvs, nil, func(proj projectInfo, env environmentInfo) string {
if env.filterKey == "" {
return env.key
}
Expand Down Expand Up @@ -105,7 +105,7 @@ func testStandardModeWithSpecificFilters(t *testing.T, manager *integrationTestM
manager.startRelay(t, envVars)
defer manager.stopRelay(t)

manager.awaitEnvironments(t, testData.projsAndEnvs, false, func(proj projectInfo, env environmentInfo) string {
manager.awaitEnvironments(t, testData.projsAndEnvs, nil, func(proj projectInfo, env environmentInfo) string {
if env.filterKey == "" {
return env.key
}
Expand Down
2 changes: 1 addition & 1 deletion integrationtests/standard_mode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func testStandardMode(t *testing.T, manager *integrationTestManager) {
manager.startRelay(t, envVars)
defer manager.stopRelay(t)

manager.awaitEnvironments(t, testData.projsAndEnvs, false, func(proj projectInfo, env environmentInfo) string {
manager.awaitEnvironments(t, testData.projsAndEnvs, nil, func(proj projectInfo, env environmentInfo) string {
return string(env.name)
})
manager.verifyFlagValues(t, testData.projsAndEnvs)
Expand Down
Loading
Loading