OAuth: Generic OAuth role mapping support (#17149)

Adds support for Generic OAuth role mapping. A new 
configuration setting for generic oauth is added named 
role_attribute_path which accepts a JMESPath expression.
Only Grafana roles named Viewer, Editor or Admin are
accepted.

Closes #9766
This commit is contained in:
Martin Reinhardt 2019-11-05 21:56:42 +01:00 committed by Marcus Efraimsson
parent 17f36d0492
commit 7a3d1c0e4b
8 changed files with 145 additions and 15 deletions

View File

@ -371,6 +371,7 @@ client_secret = some_secret
scopes = user:email scopes = user:email
email_attribute_name = email:primary email_attribute_name = email:primary
email_attribute_path = email_attribute_path =
role_attribute_path =
auth_url = auth_url =
token_url = token_url =
api_url = api_url =

View File

@ -329,7 +329,8 @@
;token_url = https://foo.bar/login/oauth/access_token ;token_url = https://foo.bar/login/oauth/access_token
;api_url = https://foo.bar/user ;api_url = https://foo.bar/user
;team_ids = ;team_ids =
;allowed_organizations = ;allowed_organizations =
;role_attribute_path =
;tls_skip_verify_insecure = false ;tls_skip_verify_insecure = false
;tls_client_cert = ;tls_client_cert =
;tls_client_key = ;tls_client_key =

View File

@ -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: 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. 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+. **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. 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. 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. 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 ## Set up OAuth2 with Okta
First set up Grafana as an OpenId client "webapplication" in Okta. Then set the Base URIs to `https://<grafana domain>/` and set the Login redirect URIs to `https://<grafana domain>/login/generic_oauth`. First set up Grafana as an OpenId client "webapplication" in Okta. Then set the Base URIs to `https://<grafana domain>/` and set the Login redirect URIs to `https://<grafana domain>/login/generic_oauth`.
Finally set up the generic oauth module like this: Finally set up the generic oauth module like this:
```bash ```bash
[auth.generic_oauth] [auth.generic_oauth]
name = Okta name = Okta
@ -225,6 +234,51 @@ send via POST body, which can be enabled via the following settings:
send_client_credentials_via_post = true send_client_credentials_via_post = true
``` ```
<hr> ## 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'
```

View File

@ -184,7 +184,10 @@ func (hs *HTTPServer) OAuthLogin(ctx *m.ReqContext) {
} }
if userInfo.Role != "" { 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 // add/update user in grafana

View File

@ -22,6 +22,7 @@ type SocialGenericOAuth struct {
allowSignup bool allowSignup bool
emailAttributeName string emailAttributeName string
emailAttributePath string emailAttributePath string
roleAttributePath string
teamIds []int teamIds []int
} }
@ -79,13 +80,13 @@ func (s *SocialGenericOAuth) IsOrganizationMember(client *http.Client) bool {
return false return false
} }
// searchJSONForEmail searches the provided JSON response for an e-mail address // searchJSONForAttr searches the provided JSON response for the given attribute
// using the configured e-mail attribute path associated with the generic OAuth // using the configured attribute path associated with the generic OAuth
// provider. // provider.
// Returns an empty string if an e-mail address is not found. // Returns an empty string if an attribute is not found.
func (s *SocialGenericOAuth) searchJSONForEmail(data []byte) string { func (s *SocialGenericOAuth) searchJSONForAttr(attributePath string, data []byte) string {
if s.emailAttributePath == "" { if attributePath == "" {
s.log.Error("No e-mail attribute path specified") s.log.Error("No attribute path specified")
return "" return ""
} }
if len(data) == 0 { 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()) s.log.Error("Failed to unmarshal user info JSON response", "err", err.Error())
return "" return ""
} }
val, err := jmespath.Search(s.emailAttributePath, buf) val, err := jmespath.Search(attributePath, buf)
if err != nil { 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 "" return ""
} }
strVal, ok := val.(string) strVal, ok := val.(string)
if ok { if ok {
return strVal 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 "" 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) login := s.extractLogin(&data, email)
userInfo := &BasicUserInfo{ userInfo := &BasicUserInfo{
Name: name, Name: name,
Login: login, Login: login,
Email: email, Email: email,
Role: role,
} }
if !s.IsTeamMember(client) { if !s.IsTeamMember(client) {
@ -298,7 +302,7 @@ func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson, userInfoResp []byt
} }
if s.emailAttributePath != "" { if s.emailAttributePath != "" {
email := s.searchJSONForEmail(userInfoResp) email := s.searchJSONForAttr(s.emailAttributePath, userInfoResp)
if email != "" { if email != "" {
return email return email
} }
@ -320,6 +324,16 @@ func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson, userInfoResp []byt
return "" 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 { func (s *SocialGenericOAuth) extractLogin(data *UserInfoJson, email string) string {
if data.Login != "" { if data.Login != "" {
return data.Login return data.Login

View File

@ -78,7 +78,61 @@ func TestSearchJSONForEmail(t *testing.T) {
for _, test := range tests { for _, test := range tests {
provider.emailAttributePath = test.EmailAttributePath provider.emailAttributePath = test.EmailAttributePath
Convey(test.Name, func() { 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) So(actualResult, ShouldEqual, test.ExpectedResult)
}) })
} }

View File

@ -74,6 +74,7 @@ func NewOAuthService() {
Enabled: sec.Key("enabled").MustBool(), Enabled: sec.Key("enabled").MustBool(),
EmailAttributeName: sec.Key("email_attribute_name").String(), EmailAttributeName: sec.Key("email_attribute_name").String(),
EmailAttributePath: sec.Key("email_attribute_path").String(), EmailAttributePath: sec.Key("email_attribute_path").String(),
RoleAttributePath: sec.Key("role_attribute_path").String(),
AllowedDomains: util.SplitString(sec.Key("allowed_domains").String()), AllowedDomains: util.SplitString(sec.Key("allowed_domains").String()),
HostedDomain: sec.Key("hosted_domain").String(), HostedDomain: sec.Key("hosted_domain").String(),
AllowSignup: sec.Key("allow_sign_up").MustBool(), AllowSignup: sec.Key("allow_sign_up").MustBool(),
@ -169,6 +170,7 @@ func NewOAuthService() {
allowSignup: info.AllowSignup, allowSignup: info.AllowSignup,
emailAttributeName: info.EmailAttributeName, emailAttributeName: info.EmailAttributeName,
emailAttributePath: info.EmailAttributePath, emailAttributePath: info.EmailAttributePath,
roleAttributePath: info.RoleAttributePath,
teamIds: sec.Key("team_ids").Ints(","), teamIds: sec.Key("team_ids").Ints(","),
allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()), allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()),
} }

View File

@ -7,6 +7,7 @@ type OAuthInfo struct {
Enabled bool Enabled bool
EmailAttributeName string EmailAttributeName string
EmailAttributePath string EmailAttributePath string
RoleAttributePath string
AllowedDomains []string AllowedDomains []string
HostedDomain string HostedDomain string
ApiUrl string ApiUrl string