From b255f3db3f84d897f33deedb063e6ea5fd86eada Mon Sep 17 00:00:00 2001 From: Ward Bekker Date: Thu, 1 Jul 2021 22:40:46 +0200 Subject: [PATCH] Team Sync: Add group mapping to support team sync in the Generic OAuth provider (#36307) Added group mapping to support team sync in the Generic OAuth provider. Co-authored-by: Leonard Gram Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com> Co-authored-by: Dan Cech Co-authored-by: Marcus Efraimsson --- conf/defaults.ini | 1 + conf/sample.ini | 1 + docs/sources/auth/generic-oauth.md | 44 +++++++++++++++-- pkg/login/social/common.go | 32 +++++++++++- pkg/login/social/generic_oauth.go | 26 ++++++++-- pkg/login/social/generic_oauth_test.go | 67 +++++++++++++++++++++++++- pkg/login/social/okta_oauth.go | 2 +- pkg/login/social/social.go | 2 + pkg/setting/setting_oauth.go | 1 + 9 files changed, 165 insertions(+), 11 deletions(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index c361b82e670..629e4b2c67f 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -493,6 +493,7 @@ login_attribute_path = name_attribute_path = role_attribute_path = role_attribute_strict = false +groups_attribute_path = id_token_attribute_name = auth_url = token_url = diff --git a/conf/sample.ini b/conf/sample.ini index 3e58183c5b7..43ef261f9f4 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -491,6 +491,7 @@ ;allowed_organizations = ;role_attribute_path = ;role_attribute_strict = false +;groups_attribute_path = ;tls_skip_verify_insecure = false ;tls_client_cert = ;tls_client_key = diff --git a/docs/sources/auth/generic-oauth.md b/docs/sources/auth/generic-oauth.md index 7da36b16259..b21e6ab2854 100755 --- a/docs/sources/auth/generic-oauth.md +++ b/docs/sources/auth/generic-oauth.md @@ -15,6 +15,7 @@ You can configure many different OAuth2 authentication services with Grafana usi - [Set up OAuth2 with OneLogin](#set-up-oauth2-with-onelogin) - [JMESPath examples](#jmespath-examples) - [Role mapping](#role-mapping) + - [Groups mapping](#groups-mapping) This callback URL must match the full HTTP address that you use in your browser to access Grafana, but with the suffixed path of `/login/generic_oauth`. @@ -65,6 +66,10 @@ Grafana will also attempt to do role mapping through OAuth as described below. Check for the presence of a role using the [JMESPath](http://jmespath.org/examples.html) specified via the `role_attribute_path` configuration option. The JSON used for the path lookup is the HTTP response obtained from querying the UserInfo endpoint specified via the `api_url` configuration option. The result after evaluating the `role_attribute_path` JMESPath expression needs to be a valid Grafana role, i.e. `Viewer`, `Editor` or `Admin`. +Grafana also attempts to map teams through OAuth as described below. + +Check for the presence of groups using the [JMESPath](http://jmespath.org/examples.html) specified via the `groups_attribute_path` configuration option. The JSON used for the path lookup is the HTTP response obtained from querying the UserInfo endpoint specified via the `api_url` configuration option. After evaluating the `groups_attribute_path` JMESPath expression, the result should be a string array of groups. + See [JMESPath examples](#jmespath-examples) for more information. Customize user login using `login_attribute_path` configuration option. Order of operations is as follows: @@ -215,7 +220,7 @@ role_attribute_path = role **Advanced example:** -In the following example user will get `Admin` as role when authenticating since it has a group `admin`. If a user has a group `editor` it will get `Editor` as role, otherwise `Viewer`. +In the following example user will get `Admin` as role when authenticating since it has a role `admin`. If a user has a role `editor` it will get `Editor` as role, otherwise `Viewer`. Payload: ```json @@ -223,7 +228,7 @@ Payload: ... "info": { ... - "groups": [ + "roles": [ "engineer", "admin", ], @@ -235,5 +240,38 @@ Payload: Config: ```bash -role_attribute_path = contains(info.groups[*], 'admin') && 'Admin' || contains(info.groups[*], 'editor') && 'Editor' || 'Viewer' +role_attribute_path = contains(info.roles[*], 'admin') && 'Admin' || contains(info.roles[*], 'editor') && 'Editor' || 'Viewer' +``` + + +### Groups mapping + +> Available in Grafana Enterprise v8.1 and later versions. + +With Team Sync you can map your Generic OAuth groups to teams in Grafana so that the users are automatically added to the correct teams. + +Generic OAuth groups can be referenced by group ID, like `8bab1c86-8fba-33e5-2089-1d1c80ec267d` or `myteam`. + +[Learn more about Team Sync]({{< relref "team-sync.md" >}}) + +Config: + +```bash +groups_attribute_path = info.groups ``` + +Payload: +```json +{ + ... + "info": { + ... + "groups": [ + "engineers", + "analysts", + ], + ... + }, + ... +} +``` \ No newline at end of file diff --git a/pkg/login/social/common.go b/pkg/login/social/common.go index ac3fd5bf45d..376406cf86c 100644 --- a/pkg/login/social/common.go +++ b/pkg/login/social/common.go @@ -74,7 +74,7 @@ func (s *SocialBase) httpGet(client *http.Client, url string) (response httpGetR return } -func (s *SocialBase) searchJSONForAttr(attributePath string, data []byte) (string, error) { +func (s *SocialBase) searchJSONForAttr(attributePath string, data []byte) (interface{}, error) { if attributePath == "" { return "", errors.New("no attribute path specified") } @@ -93,6 +93,15 @@ func (s *SocialBase) searchJSONForAttr(attributePath string, data []byte) (strin return "", errutil.Wrapf(err, "failed to search user info JSON response with provided path: %q", attributePath) } + return val, nil +} + +func (s *SocialBase) searchJSONForStringAttr(attributePath string, data []byte) (string, error) { + val, err := s.searchJSONForAttr(attributePath, data) + if err != nil { + return "", err + } + strVal, ok := val.(string) if ok { return strVal, nil @@ -100,3 +109,24 @@ func (s *SocialBase) searchJSONForAttr(attributePath string, data []byte) (strin return "", nil } + +func (s *SocialBase) searchJSONForStringArrayAttr(attributePath string, data []byte) ([]string, error) { + val, err := s.searchJSONForAttr(attributePath, data) + if err != nil { + return []string{}, err + } + + ifArr, ok := val.([]interface{}) + if !ok { + return []string{}, nil + } + + result := []string{} + for _, v := range ifArr { + if strVal, ok := v.(string); ok { + result = append(result, strVal) + } + } + + return result, nil +} diff --git a/pkg/login/social/generic_oauth.go b/pkg/login/social/generic_oauth.go index 8d7368d22fe..903afaf5218 100644 --- a/pkg/login/social/generic_oauth.go +++ b/pkg/login/social/generic_oauth.go @@ -27,6 +27,7 @@ type SocialGenericOAuth struct { nameAttributePath string roleAttributePath string roleAttributeStrict bool + groupsAttributePath string idTokenAttributeName string teamIds []int } @@ -119,7 +120,7 @@ func (s *SocialGenericOAuth) UserInfo(client *http.Client, token *oauth2.Token) } else { if s.loginAttributePath != "" { s.log.Debug("Searching for login among JSON", "loginAttributePath", s.loginAttributePath) - login, err := s.searchJSONForAttr(s.loginAttributePath, data.rawJSON) + login, err := s.searchJSONForStringAttr(s.loginAttributePath, data.rawJSON) if err != nil { s.log.Error("Failed to search JSON for login attribute", "error", err) } else if login != "" { @@ -151,6 +152,14 @@ func (s *SocialGenericOAuth) UserInfo(client *http.Client, token *oauth2.Token) userInfo.Role = role } } + + groups, err := s.extractGroups(data) + if err != nil { + s.log.Error("Failed to extract groups", "error", err) + } else if len(groups) > 0 { + s.log.Debug("Setting user info groups from extracted groups") + userInfo.Groups = groups + } } if userInfo.Email == "" { @@ -286,7 +295,7 @@ func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson) string { } if s.emailAttributePath != "" { - email, err := s.searchJSONForAttr(s.emailAttributePath, data.rawJSON) + email, err := s.searchJSONForStringAttr(s.emailAttributePath, data.rawJSON) if err != nil { s.log.Error("Failed to search JSON for attribute", "error", err) } else if email != "" { @@ -312,7 +321,7 @@ func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson) string { func (s *SocialGenericOAuth) extractUserName(data *UserInfoJson) string { if s.nameAttributePath != "" { - name, err := s.searchJSONForAttr(s.nameAttributePath, data.rawJSON) + name, err := s.searchJSONForStringAttr(s.nameAttributePath, data.rawJSON) if err != nil { s.log.Error("Failed to search JSON for attribute", "error", err) } else if name != "" { @@ -340,13 +349,22 @@ func (s *SocialGenericOAuth) extractRole(data *UserInfoJson) (string, error) { return "", nil } - role, err := s.searchJSONForAttr(s.roleAttributePath, data.rawJSON) + role, err := s.searchJSONForStringAttr(s.roleAttributePath, data.rawJSON) + if err != nil { return "", err } return role, nil } +func (s *SocialGenericOAuth) extractGroups(data *UserInfoJson) ([]string, error) { + if s.groupsAttributePath == "" { + return []string{}, nil + } + + return s.searchJSONForStringArrayAttr(s.groupsAttributePath, data.rawJSON) +} + func (s *SocialGenericOAuth) FetchPrivateEmail(client *http.Client) (string, error) { type Record struct { Email string `json:"email"` diff --git a/pkg/login/social/generic_oauth_test.go b/pkg/login/social/generic_oauth_test.go index 618fa67898d..2bfcaa51b1b 100644 --- a/pkg/login/social/generic_oauth_test.go +++ b/pkg/login/social/generic_oauth_test.go @@ -106,7 +106,70 @@ func TestSearchJSONForEmail(t *testing.T) { for _, test := range tests { provider.emailAttributePath = test.EmailAttributePath t.Run(test.Name, func(t *testing.T) { - actualResult, err := provider.searchJSONForAttr(test.EmailAttributePath, test.UserInfoJSONResponse) + actualResult, err := provider.searchJSONForStringAttr(test.EmailAttributePath, test.UserInfoJSONResponse) + if test.ExpectedError == "" { + require.NoError(t, err, "Testing case %q", test.Name) + } else { + require.EqualError(t, err, test.ExpectedError, "Testing case %q", test.Name) + } + require.Equal(t, test.ExpectedResult, actualResult) + }) + } + }) +} + +func TestSearchJSONForGroups(t *testing.T) { + t.Run("Given a generic OAuth provider", func(t *testing.T) { + provider := SocialGenericOAuth{ + SocialBase: &SocialBase{ + log: newLogger("generic_oauth_test", log15.LvlDebug), + }, + } + + tests := []struct { + Name string + UserInfoJSONResponse []byte + GroupsAttributePath string + ExpectedResult []string + ExpectedError string + }{ + { + Name: "Given an invalid user info JSON response", + UserInfoJSONResponse: []byte("{"), + GroupsAttributePath: "attributes.groups", + ExpectedResult: []string{}, + ExpectedError: "failed to unmarshal user info JSON response: unexpected end of JSON input", + }, + { + Name: "Given an empty user info JSON response and empty JMES path", + UserInfoJSONResponse: []byte{}, + GroupsAttributePath: "", + ExpectedResult: []string{}, + ExpectedError: "no attribute path specified", + }, + { + Name: "Given an empty user info JSON response and valid JMES path", + UserInfoJSONResponse: []byte{}, + GroupsAttributePath: "attributes.groups", + ExpectedResult: []string{}, + ExpectedError: "empty user info JSON response provided", + }, + { + Name: "Given a simple user info JSON response and valid JMES path", + UserInfoJSONResponse: []byte(`{ + "attributes": { + "groups": ["foo", "bar"] + } +}`), + GroupsAttributePath: "attributes.groups[]", + ExpectedResult: []string{"foo", "bar"}, + }, + } + + for _, test := range tests { + provider.groupsAttributePath = test.GroupsAttributePath + t.Run(test.Name, func(t *testing.T) { + actualResult, err := provider.searchJSONForStringArrayAttr(test.GroupsAttributePath, test.UserInfoJSONResponse) if test.ExpectedError == "" { require.NoError(t, err, "Testing case %q", test.Name) } else { @@ -169,7 +232,7 @@ func TestSearchJSONForRole(t *testing.T) { for _, test := range tests { provider.roleAttributePath = test.RoleAttributePath t.Run(test.Name, func(t *testing.T) { - actualResult, err := provider.searchJSONForAttr(test.RoleAttributePath, test.UserInfoJSONResponse) + actualResult, err := provider.searchJSONForStringAttr(test.RoleAttributePath, test.UserInfoJSONResponse) if test.ExpectedError == "" { require.NoError(t, err, "Testing case %q", test.Name) } else { diff --git a/pkg/login/social/okta_oauth.go b/pkg/login/social/okta_oauth.go index 9f0e12545d9..82cbe0ef37c 100644 --- a/pkg/login/social/okta_oauth.go +++ b/pkg/login/social/okta_oauth.go @@ -125,7 +125,7 @@ func (s *SocialOkta) extractRole(data *OktaUserInfoJson) (string, error) { return "", nil } - role, err := s.searchJSONForAttr(s.roleAttributePath, data.rawJSON) + role, err := s.searchJSONForStringAttr(s.roleAttributePath, data.rawJSON) if err != nil { return "", err } diff --git a/pkg/login/social/social.go b/pkg/login/social/social.go index 7d379a220ab..5d87dd4eee3 100644 --- a/pkg/login/social/social.go +++ b/pkg/login/social/social.go @@ -98,6 +98,7 @@ func NewOAuthService(cfg *setting.Cfg) { EmailAttributePath: sec.Key("email_attribute_path").String(), RoleAttributePath: sec.Key("role_attribute_path").String(), RoleAttributeStrict: sec.Key("role_attribute_strict").MustBool(), + GroupsAttributePath: sec.Key("groups_attribute_path").String(), AllowedDomains: util.SplitString(sec.Key("allowed_domains").String()), HostedDomain: sec.Key("hosted_domain").String(), AllowSignup: sec.Key("allow_sign_up").MustBool(), @@ -193,6 +194,7 @@ func NewOAuthService(cfg *setting.Cfg) { nameAttributePath: sec.Key("name_attribute_path").String(), roleAttributePath: info.RoleAttributePath, roleAttributeStrict: info.RoleAttributeStrict, + groupsAttributePath: info.GroupsAttributePath, loginAttributePath: sec.Key("login_attribute_path").String(), idTokenAttributeName: sec.Key("id_token_attribute_name").String(), teamIds: sec.Key("team_ids").Ints(","), diff --git a/pkg/setting/setting_oauth.go b/pkg/setting/setting_oauth.go index a8eb30a2aa5..d388ff0ab52 100644 --- a/pkg/setting/setting_oauth.go +++ b/pkg/setting/setting_oauth.go @@ -9,6 +9,7 @@ type OAuthInfo struct { EmailAttributePath string RoleAttributePath string RoleAttributeStrict bool + GroupsAttributePath string AllowedDomains []string HostedDomain string ApiUrl string