mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
17f36d0492
commit
7a3d1c0e4b
@ -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 =
|
||||||
|
@ -330,6 +330,7 @@
|
|||||||
;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 =
|
||||||
|
@ -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'
|
||||||
|
```
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -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()),
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user