Auth: Load ini/env vars settings in the fallback strategy (#78495)

* Return data in camelCase from the OAuth fb strategy

* changes

* wip

* Add defaults for oauth fb strategy

* revert other changes

* Add tests

* Add Defaults to cfg and use it in OAuthStrategy

* Return *OAuthInfo from OAuthStrategy

* lint

* Remove unnecessary Defaults

* Introduce const for fields, fix import order

* Align failing tests

* clean up

* Changes requested by @gamab

* Update pkg/services/ssosettings/strategies/oauth_strategy_test.go

Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>

* Load data on startup

* Rename + simplify

---------

Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>
This commit is contained in:
Misi
2023-12-01 15:35:44 +01:00
committed by GitHub
co-authored by Gabriel MABILLE
parent 46044efdf8
commit d3a269ab3b
18 changed files with 398 additions and 147 deletions
+8 -8
View File
@@ -37,13 +37,13 @@ type SSOSettings struct {
}
type SSOSettingsDTO struct {
ID string `xorm:"id pk" json:"id"`
Provider string `xorm:"provider" json:"provider"`
Settings map[string]interface{} `xorm:"settings" json:"settings"`
Created time.Time `xorm:"created" json:"-"`
Updated time.Time `xorm:"updated" json:"-"`
IsDeleted bool `xorm:"is_deleted" json:"-"`
Source SettingsSource `xorm:"-" json:"source"`
ID string `xorm:"id pk" json:"id"`
Provider string `xorm:"provider" json:"provider"`
Settings map[string]any `xorm:"settings" json:"settings"`
Created time.Time `xorm:"created" json:"-"`
Updated time.Time `xorm:"updated" json:"-"`
IsDeleted bool `xorm:"is_deleted" json:"-"`
Source SettingsSource `xorm:"-" json:"source"`
}
// TableName returns the table name (needed for Xorm)
@@ -79,7 +79,7 @@ func (s SSOSettings) ToSSOSettingsDTO() (*SSOSettingsDTO, error) {
return nil, err
}
var settings map[string]interface{}
var settings map[string]any
err = json.Unmarshal(settingsEncoded, &settings)
if err != nil {
return nil, err
+5 -4
View File
@@ -3,6 +3,7 @@ package ssosettings
import (
"context"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/ssosettings/models"
)
@@ -12,7 +13,7 @@ var (
// TODO: make it configurable
ConfigurableOAuthProviders = []string{"github", "gitlab", "google", "generic_oauth", "azuread", "okta"}
AllOAuthProviders = []string{"github", "gitlab", "google", "generic_oauth", "grafana_com", "azuread", "okta"}
AllOAuthProviders = []string{social.GitHubProviderName, social.GitlabProviderName, social.GoogleProviderName, social.GenericOAuthProviderName, social.GrafanaComProviderName, social.AzureADProviderName, social.OktaProviderName}
)
// Service is a SSO settings service
@@ -28,7 +29,7 @@ type Service interface {
// Delete deletes the SSO settings for a given provider (soft delete)
Delete(ctx context.Context, provider string) error
// Patch updates the specified SSO settings (key-value pairs) for a given provider
Patch(ctx context.Context, provider string, data map[string]interface{}) error
Patch(ctx context.Context, provider string, data map[string]any) error
// RegisterReloadable registers a reloadable provider
RegisterReloadable(ctx context.Context, provider string, reloadable Reloadable)
// Reload implements ssosettings.Reloadable interface
@@ -45,7 +46,7 @@ type Reloadable interface {
// using the config file and/or environment variables. Used mostly for backwards compatibility.
type FallbackStrategy interface {
IsMatch(provider string) bool
ParseConfigFromSystem(ctx context.Context) (map[string]interface{}, error)
GetProviderConfig(ctx context.Context, provider string) (any, error)
}
// Store is a SSO settings store
@@ -55,6 +56,6 @@ type Store interface {
Get(ctx context.Context, provider string) (*models.SSOSettings, error)
List(ctx context.Context) ([]*models.SSOSettings, error)
Upsert(ctx context.Context, settings models.SSOSettings) error
Patch(ctx context.Context, provider string, data map[string]interface{}) error
Patch(ctx context.Context, provider string, data map[string]any) error
Delete(ctx context.Context, provider string) error
}
@@ -121,7 +121,7 @@ func (s *SSOSettingsService) Upsert(ctx context.Context, settings models.SSOSett
return nil
}
func (s *SSOSettingsService) Patch(ctx context.Context, provider string, data map[string]interface{}) error {
func (s *SSOSettingsService) Patch(ctx context.Context, provider string, data map[string]any) error {
panic("not implemented") // TODO: Implement
}
@@ -147,21 +147,21 @@ func (s *SSOSettingsService) loadSettingsUsingFallbackStrategy(ctx context.Conte
return nil, errors.New("no fallback strategy found for provider: " + provider)
}
settingsFromSystem, err := loadStrategy.ParseConfigFromSystem(ctx)
settingsFromSystem, err := loadStrategy.GetProviderConfig(ctx, provider)
if err != nil {
return nil, err
}
oAuthInfo, err := social.CreateOAuthInfoFromKeyValues(settingsFromSystem)
if err != nil {
return nil, err
switch settingsFromSystem := settingsFromSystem.(type) {
case *social.OAuthInfo:
return &models.SSOSettings{
Provider: provider,
Source: models.System,
OAuthSettings: settingsFromSystem,
}, nil
default:
return nil, errors.New("could not parse settings from system")
}
return &models.SSOSettings{
Provider: provider,
Source: models.System,
OAuthSettings: oAuthInfo,
}, nil
}
func getSettingsByProvider(provider string, settings []*models.SSOSettings) []*models.SSOSettings {
@@ -52,9 +52,7 @@ func TestSSOSettingsService_GetForProvider(t *testing.T) {
setup: func(env testEnv) {
env.store.ExpectedError = ssosettings.ErrNotFound
env.fallbackStrategy.ExpectedIsMatch = true
env.fallbackStrategy.ExpectedConfig = map[string]interface{}{
"enabled": true,
}
env.fallbackStrategy.ExpectedConfig = &social.OAuthInfo{Enabled: true}
},
want: &models.SSOSettings{
Provider: "github",
@@ -150,9 +148,7 @@ func TestSSOSettingsService_List(t *testing.T) {
},
}
env.fallbackStrategy.ExpectedIsMatch = true
env.fallbackStrategy.ExpectedConfig = map[string]interface{}{
"enabled": false,
}
env.fallbackStrategy.ExpectedConfig = &social.OAuthInfo{Enabled: false}
},
identity: defaultIdentity,
want: []*models.SSOSettings{
@@ -210,9 +206,7 @@ func TestSSOSettingsService_List(t *testing.T) {
},
}
env.fallbackStrategy.ExpectedIsMatch = true
env.fallbackStrategy.ExpectedConfig = map[string]interface{}{
"enabled": false,
}
env.fallbackStrategy.ExpectedConfig = &social.OAuthInfo{Enabled: false}
},
identity: scopedIdentity,
want: []*models.SSOSettings{
@@ -241,9 +235,7 @@ func TestSSOSettingsService_List(t *testing.T) {
setup: func(env testEnv) {
env.store.ExpectedSSOSettings = []*models.SSOSettings{}
env.fallbackStrategy.ExpectedIsMatch = true
env.fallbackStrategy.ExpectedConfig = map[string]interface{}{
"enabled": false,
}
env.fallbackStrategy.ExpectedConfig = &social.OAuthInfo{Enabled: false}
},
identity: defaultIdentity,
want: []*models.SSOSettings{
@@ -4,7 +4,7 @@ import context "context"
type FakeFallbackStrategy struct {
ExpectedIsMatch bool
ExpectedConfig map[string]interface{}
ExpectedConfig any
ExpectedError error
}
@@ -17,6 +17,6 @@ func (f *FakeFallbackStrategy) IsMatch(provider string) bool {
return f.ExpectedIsMatch
}
func (f *FakeFallbackStrategy) ParseConfigFromSystem(ctx context.Context) (map[string]interface{}, error) {
func (f *FakeFallbackStrategy) GetProviderConfig(ctx context.Context, provider string) (any, error) {
return f.ExpectedConfig, f.ExpectedError
}
@@ -31,7 +31,7 @@ func (f *FakeStore) Upsert(ctx context.Context, settings models.SSOSettings) err
return f.ExpectedError
}
func (f *FakeStore) Patch(ctx context.Context, provider string, data map[string]interface{}) error {
func (f *FakeStore) Patch(ctx context.Context, provider string, data map[string]any) error {
return f.ExpectedError
}
@@ -2,73 +2,101 @@ package strategies
import (
"context"
"regexp"
"strings"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/services/ssosettings"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
type OAuthStrategy struct {
provider string
cfg *setting.Cfg
supportedProvidersRegex *regexp.Regexp
cfg *setting.Cfg
settingsByProvider map[string]*social.OAuthInfo
}
var extraKeysByProvider = map[string][]string{
social.AzureADProviderName: social.ExtraAzureADSettingKeys,
social.GenericOAuthProviderName: social.ExtraGenericOAuthSettingKeys,
social.GitHubProviderName: social.ExtraGithubSettingKeys,
social.GrafanaComProviderName: social.ExtraGrafanaComSettingKeys,
social.GrafanaNetProviderName: social.ExtraGrafanaComSettingKeys,
}
var _ ssosettings.FallbackStrategy = (*OAuthStrategy)(nil)
func NewOAuthStrategy(cfg *setting.Cfg) *OAuthStrategy {
compiledRegex := regexp.MustCompile(`^` + strings.Join(ssosettings.AllOAuthProviders, "|") + `$`)
return &OAuthStrategy{
cfg: cfg,
supportedProvidersRegex: compiledRegex,
oauthStrategy := &OAuthStrategy{
cfg: cfg,
settingsByProvider: make(map[string]*social.OAuthInfo),
}
oauthStrategy.loadAllSettings()
return oauthStrategy
}
func (s *OAuthStrategy) IsMatch(provider string) bool {
return s.supportedProvidersRegex.MatchString(provider)
_, ok := s.settingsByProvider[provider]
return ok
}
func (s *OAuthStrategy) ParseConfigFromSystem(_ context.Context) (map[string]any, error) {
section := s.cfg.SectionWithEnvOverrides("auth." + s.provider)
func (s *OAuthStrategy) GetProviderConfig(_ context.Context, provider string) (any, error) {
return s.settingsByProvider[provider], nil
}
// TODO: load the provider specific keys separately
result := map[string]any{
"client_id": section.Key("client_id").Value(),
"client_secret": section.Key("client_secret").Value(),
"scopes": section.Key("scopes").Value(),
"auth_url": section.Key("auth_url").Value(),
"token_url": section.Key("token_url").Value(),
"api_url": section.Key("api_url").Value(),
"teams_url": section.Key("teams_url").Value(),
"enabled": section.Key("enabled").MustBool(false),
"email_attribute_name": section.Key("email_attribute_name").Value(),
"email_attribute_path": section.Key("email_attribute_path").Value(),
"role_attribute_path": section.Key("role_attribute_path").Value(),
"role_attribute_strict": section.Key("role_attribute_strict").MustBool(false),
"groups_attribute_path": section.Key("groups_attribute_path").Value(),
"team_ids_attribute_path": section.Key("team_ids_attribute_path").Value(),
"allowed_domains": section.Key("allowed_domains").Value(),
"hosted_domain": section.Key("hosted_domain").Value(),
"allow_sign_up": section.Key("allow_sign_up").MustBool(true),
"name": section.Key("name").MustString("default name"), // TODO: change this default value
"icon": section.Key("icon").Value(),
// TODO: @mgyongyosi move skipOrgRoleSync here in a separate PR
// "skip_org_role_sync": section.Key("skip_org_role_sync").MustBool(false),
"tls_client_cert": section.Key("tls_client_cert").Value(),
"tls_client_key": section.Key("tls_client_key").Value(),
"tls_client_ca": section.Key("tls_client_ca").Value(),
"tls_skip_verify_insecure": section.Key("tls_skip_verify_insecure").MustBool(false),
"use_pkce": section.Key("use_pkce").MustBool(true),
"use_refresh_token": section.Key("use_refresh_token").MustBool(false),
"allow_assign_grafana_admin": section.Key("allow_assign_grafana_admin").MustBool(false),
"auto_login": section.Key("auto_login").MustBool(false),
"allowed_groups": section.Key("allowed_groups").Value(),
func (s *OAuthStrategy) loadAllSettings() {
allProviders := append(ssosettings.AllOAuthProviders, social.GrafanaNetProviderName)
for _, provider := range allProviders {
settings := s.loadSettingsForProvider(provider)
if provider == social.GrafanaNetProviderName {
provider = social.GrafanaComProviderName
}
s.settingsByProvider[provider] = settings
}
}
// when empty_scopes parameter exists and is true, overwrite scope with empty value
if section.Key("empty_scopes").MustBool(false) {
result["scopes"] = []string{}
func (s *OAuthStrategy) loadSettingsForProvider(provider string) *social.OAuthInfo {
section := s.cfg.SectionWithEnvOverrides("auth." + provider)
result := &social.OAuthInfo{
AllowAssignGrafanaAdmin: section.Key("allow_assign_grafana_admin").MustBool(false),
AllowSignup: section.Key("allow_sign_up").MustBool(false),
AllowedDomains: util.SplitString(section.Key("allowed_domains").Value()),
AllowedGroups: util.SplitString(section.Key("allowed_groups").Value()),
ApiUrl: section.Key("api_url").Value(),
AuthStyle: section.Key("auth_style").Value(),
AuthUrl: section.Key("auth_url").Value(),
AutoLogin: section.Key("auto_login").MustBool(false),
ClientId: section.Key("client_id").Value(),
ClientSecret: section.Key("client_secret").Value(),
EmailAttributeName: section.Key("email_attribute_name").Value(),
EmailAttributePath: section.Key("email_attribute_path").Value(),
EmptyScopes: section.Key("empty_scopes").MustBool(false),
Enabled: section.Key("enabled").MustBool(false),
GroupsAttributePath: section.Key("groups_attribute_path").Value(),
HostedDomain: section.Key("hosted_domain").Value(),
Icon: section.Key("icon").Value(),
Name: section.Key("name").Value(),
RoleAttributePath: section.Key("role_attribute_path").Value(),
RoleAttributeStrict: section.Key("role_attribute_strict").MustBool(false),
Scopes: util.SplitString(section.Key("scopes").Value()),
SignoutRedirectUrl: section.Key("signout_redirect_url").Value(),
SkipOrgRoleSync: section.Key("skip_org_role_sync").MustBool(false),
TeamIdsAttributePath: section.Key("team_ids_attribute_path").Value(),
TeamsUrl: section.Key("teams_url").Value(),
TlsClientCa: section.Key("tls_client_ca").Value(),
TlsClientCert: section.Key("tls_client_cert").Value(),
TlsClientKey: section.Key("tls_client_key").Value(),
TlsSkipVerify: section.Key("tls_skip_verify_insecure").MustBool(false),
TokenUrl: section.Key("token_url").Value(),
UsePKCE: section.Key("use_pkce").MustBool(false),
UseRefreshToken: section.Key("use_refresh_token").MustBool(false),
Extra: map[string]string{},
}
extraFields := extraKeysByProvider[provider]
for _, key := range extraFields {
result.Extra[key] = section.Key(key).Value()
}
return result, nil
return result
}
@@ -0,0 +1,196 @@
package strategies
import (
"context"
"testing"
"gopkg.in/ini.v1"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/require"
)
var (
iniContent = `
[auth.generic_oauth]
name = OAuth
icon = signin
enabled = true
allow_sign_up = false
auto_login = true
client_id = test_client_id
client_secret = test_client_secret
scopes = ["openid", "profile", "email"]
empty_scopes = false
email_attribute_name = email:primary
email_attribute_path = email
login_attribute_path = login
name_attribute_path = name
role_attribute_path = role
role_attribute_strict = true
groups_attribute_path = groups
id_token_attribute_name = id_token
team_ids_attribute_path = team_ids
auth_style = inheader
auth_url = test_auth_url
token_url = test_token_url
api_url = test_api_url
teams_url = test_teams_url
allowed_domains = domain1.com
allowed_groups =
team_ids = first, second
allowed_organizations = org1, org2
tls_skip_verify_insecure = true
tls_client_cert =
tls_client_key =
tls_client_ca =
use_pkce = false
allow_assign_grafana_admin = true
skip_org_role_sync = true
use_refresh_token = true
empty_scopes =
hosted_domain = test_hosted_domain
signout_redirect_url = test_signout_redirect_url
`
expectedOAuthInfo = &social.OAuthInfo{
Name: "OAuth",
Icon: "signin",
Enabled: true,
AllowSignup: false,
AutoLogin: true,
ClientId: "test_client_id",
ClientSecret: "test_client_secret",
Scopes: []string{"openid", "profile", "email"},
EmptyScopes: false,
EmailAttributeName: "email:primary",
EmailAttributePath: "email",
RoleAttributePath: "role",
RoleAttributeStrict: true,
GroupsAttributePath: "groups",
TeamIdsAttributePath: "team_ids",
AuthUrl: "test_auth_url",
TokenUrl: "test_token_url",
ApiUrl: "test_api_url",
TeamsUrl: "test_teams_url",
AllowedDomains: []string{"domain1.com"},
AllowedGroups: []string{},
TlsSkipVerify: true,
TlsClientCert: "",
TlsClientKey: "",
TlsClientCa: "",
UsePKCE: false,
AuthStyle: "inheader",
AllowAssignGrafanaAdmin: true,
UseRefreshToken: true,
HostedDomain: "test_hosted_domain",
SkipOrgRoleSync: true,
SignoutRedirectUrl: "test_signout_redirect_url",
Extra: map[string]string{
"allowed_organizations": "org1, org2",
"id_token_attribute_name": "id_token",
"login_attribute_path": "login",
"name_attribute_path": "name",
"team_ids": "first, second",
},
}
)
func TestGetProviderConfig_EnvVarsOnly(t *testing.T) {
setupEnvVars(t)
cfg := setting.NewCfg()
strategy := NewOAuthStrategy(cfg)
result, err := strategy.GetProviderConfig(context.Background(), "generic_oauth")
require.NoError(t, err)
oauthInfo, ok := result.(*social.OAuthInfo)
require.True(t, ok)
require.Equal(t, expectedOAuthInfo, oauthInfo)
}
func TestGetProviderConfig_IniFileOnly(t *testing.T) {
iniFile, err := ini.Load([]byte(iniContent))
require.NoError(t, err)
cfg := setting.NewCfg()
cfg.Raw = iniFile
strategy := NewOAuthStrategy(cfg)
result, err := strategy.GetProviderConfig(context.Background(), "generic_oauth")
require.NoError(t, err)
oauthInfo, ok := result.(*social.OAuthInfo)
require.True(t, ok)
require.Equal(t, expectedOAuthInfo, oauthInfo)
}
func TestGetProviderConfig_EnvVarsOverrideIniFileSettings(t *testing.T) {
t.Setenv("GF_AUTH_GENERIC_OAUTH_ENABLED", "false")
t.Setenv("GF_AUTH_GENERIC_OAUTH_SKIP_ORG_ROLE_SYNC", "false")
iniFile, err := ini.Load([]byte(iniContent))
require.NoError(t, err)
cfg := setting.NewCfg()
cfg.Raw = iniFile
strategy := NewOAuthStrategy(cfg)
result, err := strategy.GetProviderConfig(context.Background(), "generic_oauth")
require.NoError(t, err)
oauthInfo, ok := result.(*social.OAuthInfo)
require.True(t, ok)
expectedOAuthInfoWithOverrides := *expectedOAuthInfo
expectedOAuthInfoWithOverrides.Enabled = false
expectedOAuthInfoWithOverrides.SkipOrgRoleSync = false
require.Equal(t, expectedOAuthInfoWithOverrides, *oauthInfo)
}
func setupEnvVars(t *testing.T) {
t.Setenv("GF_AUTH_GENERIC_OAUTH_NAME", "OAuth")
t.Setenv("GF_AUTH_GENERIC_OAUTH_ICON", "signin")
t.Setenv("GF_AUTH_GENERIC_OAUTH_ENABLED", "true")
t.Setenv("GF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP", "false")
t.Setenv("GF_AUTH_GENERIC_OAUTH_AUTO_LOGIN", "true")
t.Setenv("GF_AUTH_GENERIC_OAUTH_CLIENT_ID", "test_client_id")
t.Setenv("GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET", "test_client_secret")
t.Setenv("GF_AUTH_GENERIC_OAUTH_SCOPES", `["openid", "profile", "email"]`)
t.Setenv("GF_AUTH_GENERIC_OAUTH_EMPTY_SCOPES", "")
t.Setenv("GF_AUTH_GENERIC_OAUTH_EMAIL_ATTRIBUTE_NAME", "email:primary")
t.Setenv("GF_AUTH_GENERIC_OAUTH_EMAIL_ATTRIBUTE_PATH", "email")
t.Setenv("GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH", "role")
t.Setenv("GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_STRICT", "true")
t.Setenv("GF_AUTH_GENERIC_OAUTH_GROUPS_ATTRIBUTE_PATH", "groups")
t.Setenv("GF_AUTH_GENERIC_OAUTH_TEAM_IDS_ATTRIBUTE_PATH", "team_ids")
t.Setenv("GF_AUTH_GENERIC_OAUTH_AUTH_URL", "test_auth_url")
t.Setenv("GF_AUTH_GENERIC_OAUTH_TOKEN_URL", "test_token_url")
t.Setenv("GF_AUTH_GENERIC_OAUTH_API_URL", "test_api_url")
t.Setenv("GF_AUTH_GENERIC_OAUTH_TEAMS_URL", "test_teams_url")
t.Setenv("GF_AUTH_GENERIC_OAUTH_ALLOWED_DOMAINS", "domain1.com")
t.Setenv("GF_AUTH_GENERIC_OAUTH_ALLOWED_GROUPS", "")
t.Setenv("GF_AUTH_GENERIC_OAUTH_TLS_SKIP_VERIFY_INSECURE", "true")
t.Setenv("GF_AUTH_GENERIC_OAUTH_TLS_CLIENT_CERT", "")
t.Setenv("GF_AUTH_GENERIC_OAUTH_TLS_CLIENT_KEY", "")
t.Setenv("GF_AUTH_GENERIC_OAUTH_TLS_CLIENT_CA", "")
t.Setenv("GF_AUTH_GENERIC_OAUTH_USE_PKCE", "false")
t.Setenv("GF_AUTH_GENERIC_OAUTH_AUTH_STYLE", "inheader")
t.Setenv("GF_AUTH_GENERIC_OAUTH_ALLOW_ASSIGN_GRAFANA_ADMIN", "true")
t.Setenv("GF_AUTH_GENERIC_OAUTH_SKIP_ORG_ROLE_SYNC", "true")
t.Setenv("GF_AUTH_GENERIC_OAUTH_USE_REFRESH_TOKEN", "true")
t.Setenv("GF_AUTH_GENERIC_OAUTH_HOSTED_DOMAIN", "test_hosted_domain")
t.Setenv("GF_AUTH_GENERIC_OAUTH_ALLOWED_ORGANIZATIONS", "org1, org2")
t.Setenv("GF_AUTH_GENERIC_OAUTH_ID_TOKEN_ATTRIBUTE_NAME", "id_token")
t.Setenv("GF_AUTH_GENERIC_OAUTH_LOGIN_ATTRIBUTE_PATH", "login")
t.Setenv("GF_AUTH_GENERIC_OAUTH_NAME_ATTRIBUTE_PATH", "name")
t.Setenv("GF_AUTH_GENERIC_OAUTH_TEAM_IDS", "first, second")
t.Setenv("GF_AUTH_GENERIC_OAUTH_SIGNOUT_REDIRECT_URL", "test_signout_redirect_url")
}