mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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>
This commit is contained in:
@@ -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
|
||||
|
Reference in New Issue
Block a user