diff --git a/pkg/login/social/azuread_oauth.go b/pkg/login/social/azuread_oauth.go index 7545e10a733..a1288926115 100644 --- a/pkg/login/social/azuread_oauth.go +++ b/pkg/login/social/azuread_oauth.go @@ -22,11 +22,15 @@ import ( "github.com/grafana/grafana/pkg/util" ) -var ( - errAzureADMissingGroups = &Error{"either the user does not have any group membership or the groups claim is missing from the token."} +const ( + AzureADProviderName = "azuread" + forceUseGraphAPIKey = "force_use_graph_api" // #nosec G101 not a hardcoded credential ) -const azureADProviderName = "azuread" +var ( + ExtraAzureADSettingKeys = []string{forceUseGraphAPIKey, allowedOrganizationsKey} + errAzureADMissingGroups = &Error{"either the user does not have any group membership or the groups claim is missing from the token."} +) var _ SocialConnector = (*SocialAzureAD)(nil) @@ -74,12 +78,12 @@ func NewAzureADProvider(settings map[string]any, cfg *setting.Cfg, features *fea return nil, err } - config := createOAuthConfig(info, cfg, azureADProviderName) + config := createOAuthConfig(info, cfg, AzureADProviderName) provider := &SocialAzureAD{ - SocialBase: newSocialBase(azureADProviderName, config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), + SocialBase: newSocialBase(AzureADProviderName, config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), cache: cache, - allowedOrganizations: util.SplitString(info.Extra["allowed_organizations"]), - forceUseGraphAPI: mustBool(info.Extra["force_use_graph_api"], false), + allowedOrganizations: util.SplitString(info.Extra[allowedOrganizationsKey]), + forceUseGraphAPI: MustBool(info.Extra[forceUseGraphAPIKey], false), skipOrgRoleSync: cfg.AzureADSkipOrgRoleSync, // FIXME: Move skipOrgRoleSync to OAuthInfo // skipOrgRoleSync: info.SkipOrgRoleSync diff --git a/pkg/login/social/common.go b/pkg/login/social/common.go index ce7d3b6cd45..3d40ee19ee9 100644 --- a/pkg/login/social/common.go +++ b/pkg/login/social/common.go @@ -19,6 +19,13 @@ import ( "gopkg.in/ini.v1" ) +const ( + // consider moving this to OAuthInfo + teamIdsKey = "team_ids" + // consider moving this to OAuthInfo + allowedOrganizationsKey = "allowed_organizations" +) + var ( errMissingGroupMembership = &Error{"user not a member of one of the required groups"} ) @@ -166,7 +173,7 @@ func createOAuthConfig(info *OAuthInfo, cfg *setting.Cfg, defaultName string) *o return &config } -func mustBool(value any, defaultValue bool) bool { +func MustBool(value any, defaultValue bool) bool { if value == nil { return defaultValue } diff --git a/pkg/login/social/commont_test.go b/pkg/login/social/commont_test.go index 39f8ebb8dd7..c3728fd872e 100644 --- a/pkg/login/social/commont_test.go +++ b/pkg/login/social/commont_test.go @@ -83,6 +83,7 @@ signout_redirect_url = https://oauth.com/signout?post_logout_redirect_uri=https: AuthStyle: "", AllowAssignGrafanaAdmin: true, UseRefreshToken: true, + SkipOrgRoleSync: true, HostedDomain: "test_hosted_domain", SignoutRedirectUrl: "https://oauth.com/signout?post_logout_redirect_uri=https://grafana.com", Extra: map[string]string{ @@ -90,7 +91,6 @@ signout_redirect_url = https://oauth.com/signout?post_logout_redirect_uri=https: "id_token_attribute_name": "id_token", "login_attribute_path": "login", "name_attribute_path": "name", - "skip_org_role_sync": "true", "team_ids": "first, second", }, } diff --git a/pkg/login/social/generic_oauth.go b/pkg/login/social/generic_oauth.go index d4de940255f..29f4f28bb43 100644 --- a/pkg/login/social/generic_oauth.go +++ b/pkg/login/social/generic_oauth.go @@ -17,7 +17,15 @@ import ( "github.com/grafana/grafana/pkg/util" ) -const genericOAuthProviderName = "generic_oauth" +const ( + GenericOAuthProviderName = "generic_oauth" + + nameAttributePathKey = "name_attribute_path" + loginAttributePathKey = "login_attribute_path" + idTokenAttributeNameKey = "id_token_attribute_name" // #nosec G101 not a hardcoded credential +) + +var ExtraGenericOAuthSettingKeys = []string{nameAttributePathKey, loginAttributePathKey, idTokenAttributeNameKey, teamIdsKey, allowedOrganizationsKey} type SocialGenericOAuth struct { *SocialBase @@ -42,20 +50,20 @@ func NewGenericOAuthProvider(settings map[string]any, cfg *setting.Cfg, features return nil, err } - config := createOAuthConfig(info, cfg, genericOAuthProviderName) + config := createOAuthConfig(info, cfg, GenericOAuthProviderName) provider := &SocialGenericOAuth{ - SocialBase: newSocialBase(genericOAuthProviderName, config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), + SocialBase: newSocialBase(GenericOAuthProviderName, config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), apiUrl: info.ApiUrl, teamsUrl: info.TeamsUrl, emailAttributeName: info.EmailAttributeName, emailAttributePath: info.EmailAttributePath, - nameAttributePath: info.Extra["name_attribute_path"], + nameAttributePath: info.Extra[nameAttributePathKey], groupsAttributePath: info.GroupsAttributePath, - loginAttributePath: info.Extra["login_attribute_path"], - idTokenAttributeName: info.Extra["id_token_attribute_name"], + loginAttributePath: info.Extra[loginAttributePathKey], + idTokenAttributeName: info.Extra[idTokenAttributeNameKey], teamIdsAttributePath: info.TeamIdsAttributePath, - teamIds: util.SplitString(info.Extra["team_ids"]), - allowedOrganizations: util.SplitString(info.Extra["allowed_organizations"]), + teamIds: util.SplitString(info.Extra[teamIdsKey]), + allowedOrganizations: util.SplitString(info.Extra[allowedOrganizationsKey]), allowedGroups: info.AllowedGroups, skipOrgRoleSync: cfg.GenericOAuthSkipOrgRoleSync, // FIXME: Move skipOrgRoleSync to OAuthInfo diff --git a/pkg/login/social/github_oauth.go b/pkg/login/social/github_oauth.go index 97a868924f5..252c86cf287 100644 --- a/pkg/login/social/github_oauth.go +++ b/pkg/login/social/github_oauth.go @@ -19,7 +19,9 @@ import ( "github.com/grafana/grafana/pkg/util/errutil" ) -const gitHubProviderName = "github" +const GitHubProviderName = "github" + +var ExtraGithubSettingKeys = []string{allowedOrganizationsKey, teamIdsKey} type SocialGithub struct { *SocialBase @@ -55,17 +57,17 @@ func NewGitHubProvider(settings map[string]any, cfg *setting.Cfg, features *feat return nil, err } - teamIds, err := mustInts(util.SplitString(info.Extra["team_ids"])) + teamIds, err := mustInts(util.SplitString(info.Extra[teamIdsKey])) if err != nil { return nil, err } - config := createOAuthConfig(info, cfg, gitHubProviderName) + config := createOAuthConfig(info, cfg, GitHubProviderName) provider := &SocialGithub{ - SocialBase: newSocialBase(gitHubProviderName, config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), + SocialBase: newSocialBase(GitHubProviderName, config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), apiUrl: info.ApiUrl, teamIds: teamIds, - allowedOrganizations: util.SplitString(info.Extra["allowed_organizations"]), + allowedOrganizations: util.SplitString(info.Extra[allowedOrganizationsKey]), skipOrgRoleSync: cfg.GitHubSkipOrgRoleSync, // FIXME: Move skipOrgRoleSync to OAuthInfo // skipOrgRoleSync: info.SkipOrgRoleSync diff --git a/pkg/login/social/gitlab_oauth.go b/pkg/login/social/gitlab_oauth.go index a985a29008a..bee41ccc693 100644 --- a/pkg/login/social/gitlab_oauth.go +++ b/pkg/login/social/gitlab_oauth.go @@ -19,7 +19,7 @@ import ( const ( groupPerPage = 50 accessLevelGuest = "10" - gitlabProviderName = "gitlab" + GitlabProviderName = "gitlab" ) type SocialGitlab struct { @@ -55,9 +55,9 @@ func NewGitLabProvider(settings map[string]any, cfg *setting.Cfg, features *feat return nil, err } - config := createOAuthConfig(info, cfg, gitlabProviderName) + config := createOAuthConfig(info, cfg, GitlabProviderName) provider := &SocialGitlab{ - SocialBase: newSocialBase(gitlabProviderName, config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), + SocialBase: newSocialBase(GitlabProviderName, config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), apiUrl: info.ApiUrl, skipOrgRoleSync: cfg.GitLabSkipOrgRoleSync, // FIXME: Move skipOrgRoleSync to OAuthInfo diff --git a/pkg/login/social/google_oauth.go b/pkg/login/social/google_oauth.go index c9b559c9247..4164c267a3f 100644 --- a/pkg/login/social/google_oauth.go +++ b/pkg/login/social/google_oauth.go @@ -18,7 +18,7 @@ const ( legacyAPIURL = "https://www.googleapis.com/oauth2/v1/userinfo" googleIAMGroupsEndpoint = "https://content-cloudidentity.googleapis.com/v1/groups/-/memberships:searchDirectGroups" googleIAMScope = "https://www.googleapis.com/auth/cloud-identity.groups.readonly" - googleProviderName = "google" + GoogleProviderName = "google" ) type SocialGoogle struct { @@ -42,9 +42,9 @@ func NewGoogleProvider(settings map[string]any, cfg *setting.Cfg, features *feat return nil, err } - config := createOAuthConfig(info, cfg, googleProviderName) + config := createOAuthConfig(info, cfg, GoogleProviderName) provider := &SocialGoogle{ - SocialBase: newSocialBase(googleProviderName, config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), + SocialBase: newSocialBase(GoogleProviderName, config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), hostedDomain: info.HostedDomain, apiUrl: info.ApiUrl, skipOrgRoleSync: cfg.GoogleSkipOrgRoleSync, diff --git a/pkg/login/social/grafana_com_oauth.go b/pkg/login/social/grafana_com_oauth.go index 56a9b167b13..6dce7b1d85b 100644 --- a/pkg/login/social/grafana_com_oauth.go +++ b/pkg/login/social/grafana_com_oauth.go @@ -15,7 +15,13 @@ import ( "github.com/grafana/grafana/pkg/util" ) -const grafanaComProviderName = "grafana_com" +const ( + GrafanaComProviderName = "grafana_com" + // legacy/old settings for the provider + GrafanaNetProviderName = "grafananet" +) + +var ExtraGrafanaComSettingKeys = []string{allowedOrganizationsKey} type SocialGrafanaCom struct { *SocialBase @@ -39,11 +45,11 @@ func NewGrafanaComProvider(settings map[string]any, cfg *setting.Cfg, features * info.TokenUrl = cfg.GrafanaComURL + "/api/oauth2/token" info.AuthStyle = "inheader" - config := createOAuthConfig(info, cfg, grafanaComProviderName) + config := createOAuthConfig(info, cfg, GrafanaComProviderName) provider := &SocialGrafanaCom{ - SocialBase: newSocialBase(grafanaComProviderName, config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), + SocialBase: newSocialBase(GrafanaComProviderName, config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), url: cfg.GrafanaComURL, - allowedOrganizations: util.SplitString(info.Extra["allowed_organizations"]), + allowedOrganizations: util.SplitString(info.Extra[allowedOrganizationsKey]), skipOrgRoleSync: cfg.GrafanaComSkipOrgRoleSync, // FIXME: Move skipOrgRoleSync to OAuthInfo // skipOrgRoleSync: info.SkipOrgRoleSync diff --git a/pkg/login/social/okta_oauth.go b/pkg/login/social/okta_oauth.go index 6faf82dd721..fe2d41c0a4d 100644 --- a/pkg/login/social/okta_oauth.go +++ b/pkg/login/social/okta_oauth.go @@ -15,7 +15,7 @@ import ( "github.com/grafana/grafana/pkg/setting" ) -const oktaProviderName = "okta" +const OktaProviderName = "okta" type SocialOkta struct { *SocialBase @@ -49,9 +49,9 @@ func NewOktaProvider(settings map[string]any, cfg *setting.Cfg, features *featur return nil, err } - config := createOAuthConfig(info, cfg, oktaProviderName) + config := createOAuthConfig(info, cfg, OktaProviderName) provider := &SocialOkta{ - SocialBase: newSocialBase(oktaProviderName, config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), + SocialBase: newSocialBase(OktaProviderName, config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), apiUrl: info.ApiUrl, allowedGroups: info.AllowedGroups, // FIXME: Move skipOrgRoleSync to OAuthInfo diff --git a/pkg/login/social/social.go b/pkg/login/social/social.go index 31f3e7827eb..a518e00df5a 100644 --- a/pkg/login/social/social.go +++ b/pkg/login/social/social.go @@ -33,6 +33,7 @@ import ( const ( OfflineAccessScope = "offline_access" + RoleGrafanaAdmin = "GrafanaAdmin" // For AzureAD for example this value cannot contain spaces ) type SocialService struct { @@ -43,40 +44,50 @@ type SocialService struct { } type OAuthInfo struct { + AllowAssignGrafanaAdmin bool `mapstructure:"allow_assign_grafana_admin" toml:"allow_assign_grafana_admin" json:"allowAssignGrafanaAdmin"` + AllowSignup bool `mapstructure:"allow_sign_up" toml:"allow_sign_up" json:"allowSignup"` + AllowedDomains []string `mapstructure:"allowed_domains" toml:"allowed_domains" json:"allowedDomains"` + AllowedGroups []string `mapstructure:"allowed_groups" toml:"allowed_groups" json:"allowedGroups"` ApiUrl string `mapstructure:"api_url" toml:"api_url" json:"apiUrl"` - AuthUrl string `mapstructure:"auth_url" toml:"auth_url" json:"authUrl"` AuthStyle string `mapstructure:"auth_style" toml:"auth_style" json:"authStyle"` + AuthUrl string `mapstructure:"auth_url" toml:"auth_url" json:"authUrl"` + AutoLogin bool `mapstructure:"auto_login" toml:"auto_login" json:"autoLogin"` ClientId string `mapstructure:"client_id" toml:"client_id" json:"clientId"` ClientSecret string `mapstructure:"client_secret" toml:"-" json:"clientSecret"` EmailAttributeName string `mapstructure:"email_attribute_name" toml:"email_attribute_name" json:"emailAttributeName"` EmailAttributePath string `mapstructure:"email_attribute_path" toml:"email_attribute_path" json:"emailAttributePath"` EmptyScopes bool `mapstructure:"empty_scopes" toml:"empty_scopes" json:"emptyScopes"` + Enabled bool `mapstructure:"enabled" toml:"enabled" json:"enabled"` GroupsAttributePath string `mapstructure:"groups_attribute_path" toml:"groups_attribute_path" json:"groupsAttributePath"` HostedDomain string `mapstructure:"hosted_domain" toml:"hosted_domain" json:"hostedDomain"` Icon string `mapstructure:"icon" toml:"icon" json:"icon"` Name string `mapstructure:"name" toml:"name" json:"name"` RoleAttributePath string `mapstructure:"role_attribute_path" toml:"role_attribute_path" json:"roleAttributePath"` + RoleAttributeStrict bool `mapstructure:"role_attribute_strict" toml:"role_attribute_strict" json:"roleAttributeStrict"` + Scopes []string `mapstructure:"scopes" toml:"scopes" json:"scopes"` + SignoutRedirectUrl string `mapstructure:"signout_redirect_url" toml:"signout_redirect_url" json:"signoutRedirectUrl"` + SkipOrgRoleSync bool `mapstructure:"skip_org_role_sync" toml:"skip_org_role_sync" json:"skipOrgRoleSync"` TeamIdsAttributePath string `mapstructure:"team_ids_attribute_path" toml:"team_ids_attribute_path" json:"teamIdsAttributePath"` TeamsUrl string `mapstructure:"teams_url" toml:"teams_url" json:"teamsUrl"` TlsClientCa string `mapstructure:"tls_client_ca" toml:"tls_client_ca" json:"tlsClientCa"` TlsClientCert string `mapstructure:"tls_client_cert" toml:"tls_client_cert" json:"tlsClientCert"` TlsClientKey string `mapstructure:"tls_client_key" toml:"tls_client_key" json:"tlsClientKey"` - TokenUrl string `mapstructure:"token_url" toml:"token_url" json:"tokenUrl"` - AllowedDomains []string `mapstructure:"allowed_domains" toml:"allowed_domains" json:"allowedDomains"` - AllowedGroups []string `mapstructure:"allowed_groups" toml:"allowed_groups" json:"allowedGroups"` - Scopes []string `mapstructure:"scopes" toml:"scopes" json:"scopes"` - AllowAssignGrafanaAdmin bool `mapstructure:"allow_assign_grafana_admin" toml:"allow_assign_grafana_admin" json:"allowAssignGrafanaAdmin"` - AllowSignup bool `mapstructure:"allow_sign_up" toml:"allow_sign_up" json:"allowSignup"` - AutoLogin bool `mapstructure:"auto_login" toml:"auto_login" json:"autoLogin"` - Enabled bool `mapstructure:"enabled" toml:"enabled" json:"enabled"` - RoleAttributeStrict bool `mapstructure:"role_attribute_strict" toml:"role_attribute_strict" json:"roleAttributeStrict"` TlsSkipVerify bool `mapstructure:"tls_skip_verify_insecure" toml:"tls_skip_verify_insecure" json:"tlsSkipVerify"` + TokenUrl string `mapstructure:"token_url" toml:"token_url" json:"tokenUrl"` UsePKCE bool `mapstructure:"use_pkce" toml:"use_pkce" json:"usePKCE"` UseRefreshToken bool `mapstructure:"use_refresh_token" toml:"use_refresh_token" json:"useRefreshToken"` - SignoutRedirectUrl string `mapstructure:"signout_redirect_url" toml:"signout_redirect_url" json:"signoutRedirectUrl"` Extra map[string]string `mapstructure:",remain" toml:"extra,omitempty" json:"extra"` } +func NewOAuthInfo() *OAuthInfo { + return &OAuthInfo{ + Scopes: []string{}, + AllowedDomains: []string{}, + AllowedGroups: []string{}, + Extra: map[string]string{}, + } +} + func ProvideService(cfg *setting.Cfg, features *featuremgmt.FeatureManager, usageStats usagestats.Service, @@ -105,8 +116,8 @@ func ProvideService(cfg *setting.Cfg, continue } - if name == "grafananet" { - name = grafanaCom + if name == GrafanaNetProviderName { + name = GrafanaComProviderName } conn, err := ss.createOAuthConnector(name, settingsKVs, cfg, features, cache) @@ -177,15 +188,11 @@ func (e Error) Error() string { return e.s } -const ( - grafanaCom = "grafana_com" - RoleGrafanaAdmin = "GrafanaAdmin" // For AzureAD for example this value cannot contain spaces -) - var ( SocialBaseUrl = "/login/" SocialMap = make(map[string]SocialConnector) - allOauthes = []string{"github", "gitlab", "google", "generic_oauth", "grafananet", grafanaCom, "azuread", "okta"} + allOauthes = []string{GitHubProviderName, GitlabProviderName, GoogleProviderName, GenericOAuthProviderName, GrafanaNetProviderName, + GrafanaComProviderName, AzureADProviderName, OktaProviderName} ) type Service interface { @@ -507,19 +514,19 @@ func (s *SocialBase) retrieveRawIDToken(idToken any) ([]byte, error) { func (ss *SocialService) createOAuthConnector(name string, settings map[string]any, cfg *setting.Cfg, features *featuremgmt.FeatureManager, cache remotecache.CacheStorage) (SocialConnector, error) { switch name { - case azureADProviderName: + case AzureADProviderName: return NewAzureADProvider(settings, cfg, features, cache) - case genericOAuthProviderName: + case GenericOAuthProviderName: return NewGenericOAuthProvider(settings, cfg, features) - case gitHubProviderName: + case GitHubProviderName: return NewGitHubProvider(settings, cfg, features) - case gitlabProviderName: + case GitlabProviderName: return NewGitLabProvider(settings, cfg, features) - case googleProviderName: + case GoogleProviderName: return NewGoogleProvider(settings, cfg, features) - case grafanaComProviderName: + case GrafanaComProviderName: return NewGrafanaComProvider(settings, cfg, features) - case oktaProviderName: + case OktaProviderName: return NewOktaProvider(settings, cfg, features) default: return nil, fmt.Errorf("unknown oauth provider: %s", name) diff --git a/pkg/services/ssosettings/models/models.go b/pkg/services/ssosettings/models/models.go index e5abefd188a..719ef4b01bf 100644 --- a/pkg/services/ssosettings/models/models.go +++ b/pkg/services/ssosettings/models/models.go @@ -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 diff --git a/pkg/services/ssosettings/ssosettings.go b/pkg/services/ssosettings/ssosettings.go index 5130f852184..5899353525b 100644 --- a/pkg/services/ssosettings/ssosettings.go +++ b/pkg/services/ssosettings/ssosettings.go @@ -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 } diff --git a/pkg/services/ssosettings/ssosettingsimpl/service.go b/pkg/services/ssosettings/ssosettingsimpl/service.go index 97cf4897484..ac800b41010 100644 --- a/pkg/services/ssosettings/ssosettingsimpl/service.go +++ b/pkg/services/ssosettings/ssosettingsimpl/service.go @@ -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 { diff --git a/pkg/services/ssosettings/ssosettingsimpl/service_test.go b/pkg/services/ssosettings/ssosettingsimpl/service_test.go index 1915625b6d4..4b2f92e522a 100644 --- a/pkg/services/ssosettings/ssosettingsimpl/service_test.go +++ b/pkg/services/ssosettings/ssosettingsimpl/service_test.go @@ -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{ diff --git a/pkg/services/ssosettings/ssosettingstests/fallback_strategy_fake.go b/pkg/services/ssosettings/ssosettingstests/fallback_strategy_fake.go index fe50a863115..110fcbffc44 100644 --- a/pkg/services/ssosettings/ssosettingstests/fallback_strategy_fake.go +++ b/pkg/services/ssosettings/ssosettingstests/fallback_strategy_fake.go @@ -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 } diff --git a/pkg/services/ssosettings/ssosettingstests/store_fake.go b/pkg/services/ssosettings/ssosettingstests/store_fake.go index 1467756a397..7cdb27e7e17 100644 --- a/pkg/services/ssosettings/ssosettingstests/store_fake.go +++ b/pkg/services/ssosettings/ssosettingstests/store_fake.go @@ -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 } diff --git a/pkg/services/ssosettings/strategies/oauth_strategy.go b/pkg/services/ssosettings/strategies/oauth_strategy.go index 515431deaa1..ae14b1f32eb 100644 --- a/pkg/services/ssosettings/strategies/oauth_strategy.go +++ b/pkg/services/ssosettings/strategies/oauth_strategy.go @@ -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) - - // 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(), - } - - // when empty_scopes parameter exists and is true, overwrite scope with empty value - if section.Key("empty_scopes").MustBool(false) { - result["scopes"] = []string{} - } - return result, nil +func (s *OAuthStrategy) GetProviderConfig(_ context.Context, provider string) (any, error) { + return s.settingsByProvider[provider], nil +} + +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 + } +} + +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 } diff --git a/pkg/services/ssosettings/strategies/oauth_strategy_test.go b/pkg/services/ssosettings/strategies/oauth_strategy_test.go new file mode 100644 index 00000000000..b7193ed242f --- /dev/null +++ b/pkg/services/ssosettings/strategies/oauth_strategy_test.go @@ -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") +}