AuthN: Support reloading SSO config after the sso settings have changed (#80734)

* Add AuthNSvc reload handling

* Working, need to add test

* Remove commented out code

* Add Reload implementation to connectors

* Align and add tests, refactor

* Add more tests, linting

* Add extra checks + tests to oauth client

* Clean up based on reviews

* Move config instantiation into newSocialBase

* Use specific error
This commit is contained in:
Misi
2024-01-22 14:54:48 +01:00
committed by GitHub
parent 1f4a520b9d
commit 20bb0a3ab1
31 changed files with 889 additions and 217 deletions
@@ -147,7 +147,7 @@ func (s *SSOSettingsService) ListWithRedactedSecrets(ctx context.Context) ([]*mo
return storeSettings, nil
}
func (s *SSOSettingsService) Upsert(ctx context.Context, settings models.SSOSettings) error {
func (s *SSOSettingsService) Upsert(ctx context.Context, settings *models.SSOSettings) error {
if !isProviderConfigurable(settings.Provider) {
return ssosettings.ErrInvalidProvider.Errorf("provider %s is not configurable", settings.Provider)
}
@@ -157,7 +157,7 @@ func (s *SSOSettingsService) Upsert(ctx context.Context, settings models.SSOSett
return ssosettings.ErrInvalidProvider.Errorf("provider %s not found in reloadables", settings.Provider)
}
err := social.Validate(ctx, settings)
err := social.Validate(ctx, *settings)
if err != nil {
return err
}
@@ -167,6 +167,8 @@ func (s *SSOSettingsService) Upsert(ctx context.Context, settings models.SSOSett
return err
}
secrets := collectSecrets(settings, storedSettings)
settings.Settings, err = s.encryptSecrets(ctx, settings.Settings, storedSettings.Settings)
if err != nil {
return err
@@ -178,7 +180,8 @@ func (s *SSOSettingsService) Upsert(ctx context.Context, settings models.SSOSett
}
go func() {
err = social.Reload(context.Background(), settings)
settings.Settings = overrideMaps(storedSettings.Settings, settings.Settings, secrets)
err = social.Reload(context.Background(), *settings)
if err != nil {
s.logger.Error("failed to reload the provider", "provider", settings.Provider, "error", err)
}
@@ -249,7 +252,7 @@ func (s *SSOSettingsService) getFallbackStrategyFor(provider string) (ssosetting
func (s *SSOSettingsService) encryptSecrets(ctx context.Context, settings, storedSettings map[string]any) (map[string]any, error) {
result := make(map[string]any)
for k, v := range settings {
if isSecret(k) {
if isSecret(k) && v != "" {
strValue, ok := v.(string)
if !ok {
return result, fmt.Errorf("failed to encrypt %s setting because it is not a string: %v", k, v)
@@ -315,20 +318,24 @@ func (s *SSOSettingsService) doReload(ctx context.Context) {
// mergeSSOSettings merges the settings from the database with the system settings
// Required because it is possible that the user has configured some of the settings (current Advanced OAuth settings)
// and the rest of the settings are loaded from the system settings
// and the rest of the settings have to be loaded from the system settings
func (s *SSOSettingsService) mergeSSOSettings(dbSettings, systemSettings *models.SSOSettings) *models.SSOSettings {
if dbSettings == nil {
s.logger.Debug("No SSO Settings found in the database, using system settings")
return systemSettings
}
s.logger.Debug("Merging SSO Settings", "dbSettings", dbSettings.Settings, "systemSettings", systemSettings.Settings)
s.logger.Debug("Merging SSO Settings", "dbSettings", removeSecrets(dbSettings.Settings), "systemSettings", removeSecrets(systemSettings.Settings))
finalSettings := mergeSettings(dbSettings.Settings, systemSettings.Settings)
result := &models.SSOSettings{
Provider: dbSettings.Provider,
Source: dbSettings.Source,
Settings: mergeSettings(dbSettings.Settings, systemSettings.Settings),
Created: dbSettings.Created,
Updated: dbSettings.Updated,
}
dbSettings.Settings = finalSettings
return dbSettings
return result
}
func (s *SSOSettingsService) decryptSecrets(ctx context.Context, settings map[string]any) (map[string]any, error) {
@@ -358,6 +365,22 @@ func (s *SSOSettingsService) decryptSecrets(ctx context.Context, settings map[st
return settings, nil
}
// removeSecrets removes all the secrets from the map and replaces them with a redacted password
// and returns a new map
func removeSecrets(settings map[string]any) map[string]any {
result := make(map[string]any)
for k, v := range settings {
if isSecret(k) {
result[k] = setting.RedactedPassword
continue
}
result[k] = v
}
return result
}
// mergeSettings merges two maps in a way that the values from the first map are preserved
// and the values from the second map are added only if they don't exist in the first map
func mergeSettings(storedSettings, systemSettings map[string]any) map[string]any {
settings := make(map[string]any)
@@ -374,6 +397,32 @@ func mergeSettings(storedSettings, systemSettings map[string]any) map[string]any
return settings
}
// collectSecrets collects all the secrets from the request and the currently stored settings
// and returns a new map
func collectSecrets(settings *models.SSOSettings, storedSettings *models.SSOSettings) map[string]any {
secrets := map[string]any{}
for k, v := range settings.Settings {
if isSecret(k) {
if isNewSecretValue(v.(string)) {
secrets[k] = v.(string) // use the new value
continue
}
secrets[k] = storedSettings.Settings[k] // keep the currently stored value
}
}
return secrets
}
func overrideMaps(maps ...map[string]any) map[string]any {
result := make(map[string]any)
for _, m := range maps {
for k, v := range m {
result[k] = v
}
}
return result
}
func isSecret(fieldName string) bool {
secretFieldPatterns := []string{"secret"}
@@ -5,7 +5,10 @@ import (
"encoding/base64"
"errors"
"fmt"
"maps"
"sync"
"testing"
"time"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
@@ -772,16 +775,50 @@ func TestSSOSettingsService_Upsert(t *testing.T) {
},
IsDeleted: false,
}
var wg sync.WaitGroup
wg.Add(1)
reloadable := ssosettingstests.NewMockReloadable(t)
reloadable.On("Validate", mock.Anything, settings).Return(nil)
reloadable.On("Reload", mock.Anything, mock.Anything).Return(nil).Maybe()
reloadable.On("Reload", mock.Anything, mock.MatchedBy(func(settings models.SSOSettings) bool {
wg.Done()
return settings.Provider == provider &&
settings.ID == "someid" &&
maps.Equal(settings.Settings, map[string]any{
"client_id": "client-id",
"client_secret": "client-secret",
"enabled": true,
})
})).Return(nil).Once()
env.reloadables[provider] = reloadable
env.secrets.On("Encrypt", mock.Anything, []byte(settings.Settings["client_secret"].(string)), mock.Anything).Return([]byte("encrypted-client-secret"), nil).Once()
env.secrets.On("Decrypt", mock.Anything, []byte("encrypted-current-client-secret"), mock.Anything).Return([]byte("current-client-secret"), nil).Once()
err := env.service.Upsert(context.Background(), settings)
env.store.UpsertFn = func(ctx context.Context, settings *models.SSOSettings) error {
currentTime := time.Now()
settings.ID = "someid"
settings.Created = currentTime
settings.Updated = currentTime
env.store.ActualSSOSettings = *settings
return nil
}
env.store.GetFn = func(ctx context.Context, provider string) (*models.SSOSettings, error) {
return &models.SSOSettings{
ID: "someid",
Provider: provider,
Settings: map[string]any{
"client_secret": base64.RawStdEncoding.EncodeToString([]byte("encrypted-current-client-secret")),
},
}, nil
}
err := env.service.Upsert(context.Background(), &settings)
require.NoError(t, err)
// Wait for the goroutine first to assert the Reload call
wg.Wait()
settings.Settings["client_secret"] = base64.RawStdEncoding.EncodeToString([]byte("encrypted-client-secret"))
require.EqualValues(t, settings, env.store.ActualSSOSettings)
})
@@ -790,7 +827,7 @@ func TestSSOSettingsService_Upsert(t *testing.T) {
env := setupTestEnv(t)
provider := social.GrafanaComProviderName
settings := models.SSOSettings{
settings := &models.SSOSettings{
Provider: provider,
Settings: map[string]any{
"client_id": "client-id",
@@ -811,7 +848,7 @@ func TestSSOSettingsService_Upsert(t *testing.T) {
env := setupTestEnv(t)
provider := social.AzureADProviderName
settings := models.SSOSettings{
settings := &models.SSOSettings{
Provider: provider,
Settings: map[string]any{
"client_id": "client-id",
@@ -847,14 +884,14 @@ func TestSSOSettingsService_Upsert(t *testing.T) {
reloadable.On("Validate", mock.Anything, settings).Return(errors.New("validation failed"))
env.reloadables[provider] = reloadable
err := env.service.Upsert(context.Background(), settings)
err := env.service.Upsert(context.Background(), &settings)
require.Error(t, err)
})
t.Run("returns error if a fallback strategy is not available for the provider", func(t *testing.T) {
env := setupTestEnv(t)
settings := models.SSOSettings{
settings := &models.SSOSettings{
Provider: social.AzureADProviderName,
Settings: map[string]any{
"client_id": "client-id",
@@ -889,7 +926,7 @@ func TestSSOSettingsService_Upsert(t *testing.T) {
env.reloadables[provider] = reloadable
env.secrets.On("Encrypt", mock.Anything, []byte(settings.Settings["client_secret"].(string)), mock.Anything).Return(nil, errors.New("encryption failed")).Once()
err := env.service.Upsert(context.Background(), settings)
err := env.service.Upsert(context.Background(), &settings)
require.Error(t, err)
})
@@ -921,7 +958,7 @@ func TestSSOSettingsService_Upsert(t *testing.T) {
env.secrets.On("Decrypt", mock.Anything, []byte("current-client-secret"), mock.Anything).Return([]byte("encrypted-client-secret"), nil).Once()
env.secrets.On("Encrypt", mock.Anything, []byte("encrypted-client-secret"), mock.Anything).Return([]byte("current-client-secret"), nil).Once()
err := env.service.Upsert(context.Background(), settings)
err := env.service.Upsert(context.Background(), &settings)
require.NoError(t, err)
settings.Settings["client_secret"] = base64.RawStdEncoding.EncodeToString([]byte("current-client-secret"))
@@ -950,11 +987,11 @@ func TestSSOSettingsService_Upsert(t *testing.T) {
return &models.SSOSettings{}, nil
}
env.store.UpsertFn = func(ctx context.Context, settings models.SSOSettings) error {
env.store.UpsertFn = func(ctx context.Context, settings *models.SSOSettings) error {
return errors.New("failed to upsert settings")
}
err := env.service.Upsert(context.Background(), settings)
err := env.service.Upsert(context.Background(), &settings)
require.Error(t, err)
})
@@ -978,7 +1015,7 @@ func TestSSOSettingsService_Upsert(t *testing.T) {
env.reloadables[provider] = reloadable
env.secrets.On("Encrypt", mock.Anything, []byte(settings.Settings["client_secret"].(string)), mock.Anything).Return([]byte("encrypted-client-secret"), nil).Once()
err := env.service.Upsert(context.Background(), settings)
err := env.service.Upsert(context.Background(), &settings)
require.NoError(t, err)
settings.Settings["client_secret"] = base64.RawStdEncoding.EncodeToString([]byte("encrypted-client-secret"))
@@ -1098,6 +1135,22 @@ func TestSSOSettingsService_decryptSecrets(t *testing.T) {
"other_secret": "decrypted-other-secret",
},
},
{
name: "should not decrypt when a secret is empty",
setup: func(env testEnv) {
env.secrets.On("Decrypt", mock.Anything, []byte("other_secret"), mock.Anything).Return([]byte("decrypted-other-secret"), nil).Once()
},
settings: map[string]any{
"enabled": true,
"client_secret": "",
"other_secret": base64.RawStdEncoding.EncodeToString([]byte("other_secret")),
},
want: map[string]any{
"enabled": true,
"client_secret": "",
"other_secret": "decrypted-other-secret",
},
},
{
name: "should return an error if data is not a string",
settings: map[string]any{