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 <leo@xlson.com>
Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com>
Co-authored-by: Dan Cech <dan@aussiedan.com>
Co-authored-by: Marcus Efraimsson <marcus.efraimsson@gmail.com>
pull/36363/head
Ward Bekker 4 years ago committed by GitHub
parent d2f18f8b7d
commit b255f3db3f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      conf/defaults.ini
  2. 1
      conf/sample.ini
  3. 44
      docs/sources/auth/generic-oauth.md
  4. 32
      pkg/login/social/common.go
  5. 26
      pkg/login/social/generic_oauth.go
  6. 67
      pkg/login/social/generic_oauth_test.go
  7. 2
      pkg/login/social/okta_oauth.go
  8. 2
      pkg/login/social/social.go
  9. 1
      pkg/setting/setting_oauth.go

@ -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 =

@ -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 =

@ -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",
],
...
},
...
}
```

@ -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
}

@ -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"`

@ -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 {

@ -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
}

@ -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(","),

@ -9,6 +9,7 @@ type OAuthInfo struct {
EmailAttributePath string
RoleAttributePath string
RoleAttributeStrict bool
GroupsAttributePath string
AllowedDomains []string
HostedDomain string
ApiUrl string

Loading…
Cancel
Save