diff --git a/conf/defaults.ini b/conf/defaults.ini index 3bb57c3ac72..d111b3d24ef 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -371,6 +371,7 @@ client_secret = some_secret scopes = user:email email_attribute_name = email:primary email_attribute_path = +role_attribute_path = auth_url = token_url = api_url = diff --git a/conf/sample.ini b/conf/sample.ini index 04e09f70af2..22453fd5774 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -329,7 +329,8 @@ ;token_url = https://foo.bar/login/oauth/access_token ;api_url = https://foo.bar/user ;team_ids = -;allowed_organizations = +;allowed_organizations = +;role_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 404c6c2f4c4..58bf4188ac8 100644 --- a/docs/sources/auth/generic-oauth.md +++ b/docs/sources/auth/generic-oauth.md @@ -40,17 +40,26 @@ Set `api_url` to the resource that returns [OpenID UserInfo](https://connect2id. Grafana will attempt to determine the user's e-mail address by querying the OAuth provider as described below in the following order until an e-mail address is found: 1. Check for the presence of an e-mail address via the `email` field encoded in the OAuth `id_token` parameter. -2. Check for the presence of an e-mail address using the [JMES path](http://jmespath.org/examples.html) specified via the `email_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. +2. Check for the presence of an e-mail address using the [JMESPath](http://jmespath.org/examples.html) specified via the `email_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. **Note**: Only available in Grafana v6.4+. 3. Check for the presence of an e-mail address in the `attributes` map encoded in the OAuth `id_token` parameter. By default Grafana will perform a lookup into the attributes map using the `email:primary` key, however, this is configurable and can be adjusted by using the `email_attribute_name` configuration option. 4. Query the `/emails` endpoint of the OAuth provider's API (configured with `api_url`) and check for the presence of an e-mail address marked as a primary address. 5. If no e-mail address is found in steps (1-4), then the e-mail address of the user is set to the empty string. +Grafana will also attempt to do role mapping through OAuth as described below. + +> Only available in Grafana v6.5+. + +Check for the presence of an 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`. + +See [JMESPath examples](#jmespath-examples) for more information. + ## Set up OAuth2 with Okta First set up Grafana as an OpenId client "webapplication" in Okta. Then set the Base URIs to `https:///` and set the Login redirect URIs to `https:///login/generic_oauth`. Finally set up the generic oauth module like this: + ```bash [auth.generic_oauth] name = Okta @@ -225,6 +234,51 @@ send via POST body, which can be enabled via the following settings: send_client_credentials_via_post = true ``` -
+## JMESPath examples +To ease configuring a proper JMESPath expression you can test/evaluate expression with a custom payload at http://jmespath.org/. +### Role mapping + +**Basic example:** + +In the following example user will get `Editor` as role when authenticating. The value of the property `role` will be the resulting role if the role is a proper Grafana role, i.e. `Viewer`, `Editor` or `Admin`. + +Payload: +```json +{ + ... + "role": "Editor", + ... +} +``` + +Config: +```bash +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`. + +Payload: +```json +{ + ... + "info": { + ... + "groups": [ + "engineer", + "admin", + ], + ... + }, + ... +} +``` + +Config: +```bash +role_attribute_path = contains(info.groups[*], 'admin') && 'Admin' || contains(info.groups[*], 'editor') && 'Editor' || 'Viewer' +``` diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index f1b4bc22386..53349906939 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -184,7 +184,10 @@ func (hs *HTTPServer) OAuthLogin(ctx *m.ReqContext) { } if userInfo.Role != "" { - extUser.OrgRoles[1] = m.RoleType(userInfo.Role) + rt := m.RoleType(userInfo.Role) + if rt.IsValid() { + extUser.OrgRoles[1] = rt + } } // add/update user in grafana diff --git a/pkg/login/social/generic_oauth.go b/pkg/login/social/generic_oauth.go index 836b9b1d950..d0befc12942 100644 --- a/pkg/login/social/generic_oauth.go +++ b/pkg/login/social/generic_oauth.go @@ -22,6 +22,7 @@ type SocialGenericOAuth struct { allowSignup bool emailAttributeName string emailAttributePath string + roleAttributePath string teamIds []int } @@ -79,13 +80,13 @@ func (s *SocialGenericOAuth) IsOrganizationMember(client *http.Client) bool { return false } -// searchJSONForEmail searches the provided JSON response for an e-mail address -// using the configured e-mail attribute path associated with the generic OAuth +// searchJSONForAttr searches the provided JSON response for the given attribute +// using the configured attribute path associated with the generic OAuth // provider. -// Returns an empty string if an e-mail address is not found. -func (s *SocialGenericOAuth) searchJSONForEmail(data []byte) string { - if s.emailAttributePath == "" { - s.log.Error("No e-mail attribute path specified") +// Returns an empty string if an attribute is not found. +func (s *SocialGenericOAuth) searchJSONForAttr(attributePath string, data []byte) string { + if attributePath == "" { + s.log.Error("No attribute path specified") return "" } if len(data) == 0 { @@ -97,16 +98,16 @@ func (s *SocialGenericOAuth) searchJSONForEmail(data []byte) string { s.log.Error("Failed to unmarshal user info JSON response", "err", err.Error()) return "" } - val, err := jmespath.Search(s.emailAttributePath, buf) + val, err := jmespath.Search(attributePath, buf) if err != nil { - s.log.Error("Failed to search user info JSON response with provided path", "emailAttributePath", s.emailAttributePath, "err", err.Error()) + s.log.Error("Failed to search user info JSON response with provided path", "attributePath", attributePath, "err", err.Error()) return "" } strVal, ok := val.(string) if ok { return strVal } - s.log.Error("E-mail not found when searching JSON with provided path", "emailAttributePath", s.emailAttributePath) + s.log.Error("Attribute not found when searching JSON with provided path", "attributePath", attributePath) return "" } @@ -238,12 +239,15 @@ func (s *SocialGenericOAuth) UserInfo(client *http.Client, token *oauth2.Token) } } + role := s.extractRole(&data, rawUserInfoResponse.Body) + login := s.extractLogin(&data, email) userInfo := &BasicUserInfo{ Name: name, Login: login, Email: email, + Role: role, } if !s.IsTeamMember(client) { @@ -298,7 +302,7 @@ func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson, userInfoResp []byt } if s.emailAttributePath != "" { - email := s.searchJSONForEmail(userInfoResp) + email := s.searchJSONForAttr(s.emailAttributePath, userInfoResp) if email != "" { return email } @@ -320,6 +324,16 @@ func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson, userInfoResp []byt return "" } +func (s *SocialGenericOAuth) extractRole(data *UserInfoJson, userInfoResp []byte) string { + if s.roleAttributePath != "" { + role := s.searchJSONForAttr(s.roleAttributePath, userInfoResp) + if role != "" { + return role + } + } + return "" +} + func (s *SocialGenericOAuth) extractLogin(data *UserInfoJson, email string) string { if data.Login != "" { return data.Login diff --git a/pkg/login/social/generic_oauth_test.go b/pkg/login/social/generic_oauth_test.go index 488aeaa3716..01b2a929e7a 100644 --- a/pkg/login/social/generic_oauth_test.go +++ b/pkg/login/social/generic_oauth_test.go @@ -78,7 +78,61 @@ func TestSearchJSONForEmail(t *testing.T) { for _, test := range tests { provider.emailAttributePath = test.EmailAttributePath Convey(test.Name, func() { - actualResult := provider.searchJSONForEmail(test.UserInfoJSONResponse) + actualResult := provider.searchJSONForAttr(test.EmailAttributePath, test.UserInfoJSONResponse) + So(actualResult, ShouldEqual, test.ExpectedResult) + }) + } + }) +} + +func TestSearchJSONForRole(t *testing.T) { + Convey("Given a generic OAuth provider", t, func() { + provider := SocialGenericOAuth{ + SocialBase: &SocialBase{ + log: log.New("generic_oauth_test"), + }, + } + + tests := []struct { + Name string + UserInfoJSONResponse []byte + RoleAttributePath string + ExpectedResult string + }{ + { + Name: "Given an invalid user info JSON response", + UserInfoJSONResponse: []byte("{"), + RoleAttributePath: "attributes.role", + ExpectedResult: "", + }, + { + Name: "Given an empty user info JSON response and empty JMES path", + UserInfoJSONResponse: []byte{}, + RoleAttributePath: "", + ExpectedResult: "", + }, + { + Name: "Given an empty user info JSON response and valid JMES path", + UserInfoJSONResponse: []byte{}, + RoleAttributePath: "attributes.role", + ExpectedResult: "", + }, + { + Name: "Given a simple user info JSON response and valid JMES path", + UserInfoJSONResponse: []byte(`{ + "attributes": { + "role": "admin" + } +}`), + RoleAttributePath: "attributes.role", + ExpectedResult: "admin", + }, + } + + for _, test := range tests { + provider.roleAttributePath = test.RoleAttributePath + Convey(test.Name, func() { + actualResult := provider.searchJSONForAttr(test.RoleAttributePath, test.UserInfoJSONResponse) So(actualResult, ShouldEqual, test.ExpectedResult) }) } diff --git a/pkg/login/social/social.go b/pkg/login/social/social.go index 101df1994eb..f677d0fdc79 100644 --- a/pkg/login/social/social.go +++ b/pkg/login/social/social.go @@ -74,6 +74,7 @@ func NewOAuthService() { Enabled: sec.Key("enabled").MustBool(), EmailAttributeName: sec.Key("email_attribute_name").String(), EmailAttributePath: sec.Key("email_attribute_path").String(), + RoleAttributePath: sec.Key("role_attribute_path").String(), AllowedDomains: util.SplitString(sec.Key("allowed_domains").String()), HostedDomain: sec.Key("hosted_domain").String(), AllowSignup: sec.Key("allow_sign_up").MustBool(), @@ -169,6 +170,7 @@ func NewOAuthService() { allowSignup: info.AllowSignup, emailAttributeName: info.EmailAttributeName, emailAttributePath: info.EmailAttributePath, + roleAttributePath: info.RoleAttributePath, teamIds: sec.Key("team_ids").Ints(","), allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()), } diff --git a/pkg/setting/setting_oauth.go b/pkg/setting/setting_oauth.go index 76f77954e2c..2d46ed7977d 100644 --- a/pkg/setting/setting_oauth.go +++ b/pkg/setting/setting_oauth.go @@ -7,6 +7,7 @@ type OAuthInfo struct { Enabled bool EmailAttributeName string EmailAttributePath string + RoleAttributePath string AllowedDomains []string HostedDomain string ApiUrl string