mirror of
https://github.com/grafana/grafana.git
synced 2026-08-14 07:04:57 -05: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:
co-authored by
Leonard Gram
achatterjee-grafana
Dan Cech
Marcus Efraimsson
parent
d2f18f8b7d
commit
b255f3db3f
@@ -493,6 +493,7 @@ login_attribute_path =
|
|||||||
name_attribute_path =
|
name_attribute_path =
|
||||||
role_attribute_path =
|
role_attribute_path =
|
||||||
role_attribute_strict = false
|
role_attribute_strict = false
|
||||||
|
groups_attribute_path =
|
||||||
id_token_attribute_name =
|
id_token_attribute_name =
|
||||||
auth_url =
|
auth_url =
|
||||||
token_url =
|
token_url =
|
||||||
|
|||||||
@@ -491,6 +491,7 @@
|
|||||||
;allowed_organizations =
|
;allowed_organizations =
|
||||||
;role_attribute_path =
|
;role_attribute_path =
|
||||||
;role_attribute_strict = false
|
;role_attribute_strict = false
|
||||||
|
;groups_attribute_path =
|
||||||
;tls_skip_verify_insecure = false
|
;tls_skip_verify_insecure = false
|
||||||
;tls_client_cert =
|
;tls_client_cert =
|
||||||
;tls_client_key =
|
;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)
|
- [Set up OAuth2 with OneLogin](#set-up-oauth2-with-onelogin)
|
||||||
- [JMESPath examples](#jmespath-examples)
|
- [JMESPath examples](#jmespath-examples)
|
||||||
- [Role mapping](#role-mapping)
|
- [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`.
|
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`.
|
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.
|
See [JMESPath examples](#jmespath-examples) for more information.
|
||||||
|
|
||||||
Customize user login using `login_attribute_path` configuration option. Order of operations is as follows:
|
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:**
|
**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:
|
Payload:
|
||||||
```json
|
```json
|
||||||
@@ -223,7 +228,7 @@ Payload:
|
|||||||
...
|
...
|
||||||
"info": {
|
"info": {
|
||||||
...
|
...
|
||||||
"groups": [
|
"roles": [
|
||||||
"engineer",
|
"engineer",
|
||||||
"admin",
|
"admin",
|
||||||
],
|
],
|
||||||
@@ -235,5 +240,38 @@ Payload:
|
|||||||
|
|
||||||
Config:
|
Config:
|
||||||
```bash
|
```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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SocialBase) searchJSONForAttr(attributePath string, data []byte) (string, error) {
|
func (s *SocialBase) searchJSONForAttr(attributePath string, data []byte) (interface{}, error) {
|
||||||
if attributePath == "" {
|
if attributePath == "" {
|
||||||
return "", errors.New("no attribute path specified")
|
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 "", 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)
|
strVal, ok := val.(string)
|
||||||
if ok {
|
if ok {
|
||||||
return strVal, nil
|
return strVal, nil
|
||||||
@@ -100,3 +109,24 @@ func (s *SocialBase) searchJSONForAttr(attributePath string, data []byte) (strin
|
|||||||
|
|
||||||
return "", nil
|
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
|
nameAttributePath string
|
||||||
roleAttributePath string
|
roleAttributePath string
|
||||||
roleAttributeStrict bool
|
roleAttributeStrict bool
|
||||||
|
groupsAttributePath string
|
||||||
idTokenAttributeName string
|
idTokenAttributeName string
|
||||||
teamIds []int
|
teamIds []int
|
||||||
}
|
}
|
||||||
@@ -119,7 +120,7 @@ func (s *SocialGenericOAuth) UserInfo(client *http.Client, token *oauth2.Token)
|
|||||||
} else {
|
} else {
|
||||||
if s.loginAttributePath != "" {
|
if s.loginAttributePath != "" {
|
||||||
s.log.Debug("Searching for login among JSON", "loginAttributePath", 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 {
|
if err != nil {
|
||||||
s.log.Error("Failed to search JSON for login attribute", "error", err)
|
s.log.Error("Failed to search JSON for login attribute", "error", err)
|
||||||
} else if login != "" {
|
} else if login != "" {
|
||||||
@@ -151,6 +152,14 @@ func (s *SocialGenericOAuth) UserInfo(client *http.Client, token *oauth2.Token)
|
|||||||
userInfo.Role = role
|
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 == "" {
|
if userInfo.Email == "" {
|
||||||
@@ -286,7 +295,7 @@ func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.emailAttributePath != "" {
|
if s.emailAttributePath != "" {
|
||||||
email, err := s.searchJSONForAttr(s.emailAttributePath, data.rawJSON)
|
email, err := s.searchJSONForStringAttr(s.emailAttributePath, data.rawJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("Failed to search JSON for attribute", "error", err)
|
s.log.Error("Failed to search JSON for attribute", "error", err)
|
||||||
} else if email != "" {
|
} else if email != "" {
|
||||||
@@ -312,7 +321,7 @@ func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson) string {
|
|||||||
|
|
||||||
func (s *SocialGenericOAuth) extractUserName(data *UserInfoJson) string {
|
func (s *SocialGenericOAuth) extractUserName(data *UserInfoJson) string {
|
||||||
if s.nameAttributePath != "" {
|
if s.nameAttributePath != "" {
|
||||||
name, err := s.searchJSONForAttr(s.nameAttributePath, data.rawJSON)
|
name, err := s.searchJSONForStringAttr(s.nameAttributePath, data.rawJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("Failed to search JSON for attribute", "error", err)
|
s.log.Error("Failed to search JSON for attribute", "error", err)
|
||||||
} else if name != "" {
|
} else if name != "" {
|
||||||
@@ -340,13 +349,22 @@ func (s *SocialGenericOAuth) extractRole(data *UserInfoJson) (string, error) {
|
|||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
role, err := s.searchJSONForAttr(s.roleAttributePath, data.rawJSON)
|
role, err := s.searchJSONForStringAttr(s.roleAttributePath, data.rawJSON)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return role, nil
|
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) {
|
func (s *SocialGenericOAuth) FetchPrivateEmail(client *http.Client) (string, error) {
|
||||||
type Record struct {
|
type Record struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
|||||||
@@ -106,7 +106,70 @@ func TestSearchJSONForEmail(t *testing.T) {
|
|||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
provider.emailAttributePath = test.EmailAttributePath
|
provider.emailAttributePath = test.EmailAttributePath
|
||||||
t.Run(test.Name, func(t *testing.T) {
|
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 == "" {
|
if test.ExpectedError == "" {
|
||||||
require.NoError(t, err, "Testing case %q", test.Name)
|
require.NoError(t, err, "Testing case %q", test.Name)
|
||||||
} else {
|
} else {
|
||||||
@@ -169,7 +232,7 @@ func TestSearchJSONForRole(t *testing.T) {
|
|||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
provider.roleAttributePath = test.RoleAttributePath
|
provider.roleAttributePath = test.RoleAttributePath
|
||||||
t.Run(test.Name, func(t *testing.T) {
|
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 == "" {
|
if test.ExpectedError == "" {
|
||||||
require.NoError(t, err, "Testing case %q", test.Name)
|
require.NoError(t, err, "Testing case %q", test.Name)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ func (s *SocialOkta) extractRole(data *OktaUserInfoJson) (string, error) {
|
|||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
role, err := s.searchJSONForAttr(s.roleAttributePath, data.rawJSON)
|
role, err := s.searchJSONForStringAttr(s.roleAttributePath, data.rawJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ func NewOAuthService(cfg *setting.Cfg) {
|
|||||||
EmailAttributePath: sec.Key("email_attribute_path").String(),
|
EmailAttributePath: sec.Key("email_attribute_path").String(),
|
||||||
RoleAttributePath: sec.Key("role_attribute_path").String(),
|
RoleAttributePath: sec.Key("role_attribute_path").String(),
|
||||||
RoleAttributeStrict: sec.Key("role_attribute_strict").MustBool(),
|
RoleAttributeStrict: sec.Key("role_attribute_strict").MustBool(),
|
||||||
|
GroupsAttributePath: sec.Key("groups_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(),
|
||||||
@@ -193,6 +194,7 @@ func NewOAuthService(cfg *setting.Cfg) {
|
|||||||
nameAttributePath: sec.Key("name_attribute_path").String(),
|
nameAttributePath: sec.Key("name_attribute_path").String(),
|
||||||
roleAttributePath: info.RoleAttributePath,
|
roleAttributePath: info.RoleAttributePath,
|
||||||
roleAttributeStrict: info.RoleAttributeStrict,
|
roleAttributeStrict: info.RoleAttributeStrict,
|
||||||
|
groupsAttributePath: info.GroupsAttributePath,
|
||||||
loginAttributePath: sec.Key("login_attribute_path").String(),
|
loginAttributePath: sec.Key("login_attribute_path").String(),
|
||||||
idTokenAttributeName: sec.Key("id_token_attribute_name").String(),
|
idTokenAttributeName: sec.Key("id_token_attribute_name").String(),
|
||||||
teamIds: sec.Key("team_ids").Ints(","),
|
teamIds: sec.Key("team_ids").Ints(","),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type OAuthInfo struct {
|
|||||||
EmailAttributePath string
|
EmailAttributePath string
|
||||||
RoleAttributePath string
|
RoleAttributePath string
|
||||||
RoleAttributeStrict bool
|
RoleAttributeStrict bool
|
||||||
|
GroupsAttributePath string
|
||||||
AllowedDomains []string
|
AllowedDomains []string
|
||||||
HostedDomain string
|
HostedDomain string
|
||||||
ApiUrl string
|
ApiUrl string
|
||||||
|
|||||||
Reference in New Issue
Block a user