diff --git a/conf/defaults.ini b/conf/defaults.ini index ad5affa2e7a..e06be50e952 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -467,6 +467,8 @@ api_url = https://api.github.com/user allowed_domains = team_ids = allowed_organizations = +role_attribute_path = +role_attribute_strict = false #################################### GitLab Auth ######################### [auth.gitlab] diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/github.md b/docs/sources/setup-grafana/configure-security/configure-authentication/github.md index 889768001f9..557ef871282 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/github.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/github.md @@ -101,6 +101,35 @@ allow_sign_up = true allowed_organizations = github google ``` +### Map roles + +You can use GitHub OAuth to map roles. During mapping, Grafana checks for the presence of a role using the [JMESPath](http://jmespath.org/examples.html) specified via the `role_attribute_path` configuration option. + +For the path lookup, Grafana uses JSON obtained from querying GitHub's API [`/api/user`](https://docs.github.com/en/rest/users/users#get-the-authenticated-user=) endpoint and a `groups` key containing all of the user's teams (retrieved from `/api/user/teams`). + +The result of evaluating the `role_attribute_path` JMESPath expression must be a valid Grafana role, for example, `Viewer`, `Editor` or `Admin`. For more information about roles and permissions in Grafana, refer to [Roles and permissions]({{< relref "../../../administration/roles-and-permissions/" >}}). + +An example Query could look like the following: + +```bash +role_attribute_path = [login==octocat] && 'Admin' || 'Viewer' +``` + +This allows the user with login "octocat" to be mapped to the `Admin` role, +but all other users to be mapped to the `Viewer` role. + +#### Map roles using teams + +Teams can also be used to map roles. For instance, +if you have a team called 'example-group' you can use the following snippet to +ensure those members inherit the role 'Editor'. + +```bash +role_attribute_path = contains(groups[*], '@github/example-group') && 'Editor' || 'Viewer' +``` + +Note: If a match is found in other fields, teams will be ignored. + ### Team Sync (Enterprise only) > Only available in Grafana Enterprise v6.3+ diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab.md b/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab.md index 7a37e83e12e..0638161f0e0 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab.md @@ -122,7 +122,7 @@ role_attribute_path = is_admin && 'Admin' || 'Viewer' You can use GitLab OAuth to map roles. During mapping, Grafana checks for the presence of a role using the [JMESPath](http://jmespath.org/examples.html) specified via the `role_attribute_path` configuration option. -For the path lookup, Grafana uses JSON obtained from querying GitLab's API [`/api/v4/user`](https://docs.gitlab.com/ee/api/users.html#list-current-user-for-normal-users) endpoint. The result of evaluating the `role_attribute_path` JMESPath expression must be a valid Grafana role, for example, `Viewer`, `Editor` or `Admin`. For more information about roles and permissions in Grafana, refer to [Roles and permissions]({{< relref "../../../administration/roles-and-permissions/" >}}). +For the path lookup, Grafana uses JSON obtained from querying GitLab's API [`/api/v4/user`](https://docs.gitlab.com/ee/api/users.html#list-current-user-for-normal-users) endpoint and a `groups` key containing all of the user's teams. The result of evaluating the `role_attribute_path` JMESPath expression must be a valid Grafana role, for example, `Viewer`, `Editor` or `Admin`. For more information about roles and permissions in Grafana, refer to [Roles and permissions]({{< relref "../../../administration/roles-and-permissions/" >}}). An example Query could look like the following: @@ -132,6 +132,19 @@ role_attribute_path = is_admin && 'Admin' || 'Viewer' This allows every GitLab Admin to be an Admin in Grafana. +#### Map roles using groups + +Groups can also be used to map roles. Group name (lowercased and unique) is used instead of display name for identifying groups + +For instance, if you have a group with display name 'Example-Group' you can use the following snippet to +ensure those members inherit the role 'Editor'. + +```bash +role_attribute_path = contains(groups[*], 'example-group') && 'Editor' || 'Viewer' +``` + +Note: If a match is found in other fields, groups will be ignored. + ### Team Sync (Enterprise only) > Only available in Grafana Enterprise v6.4+ diff --git a/pkg/login/social/azuread_oauth_test.go b/pkg/login/social/azuread_oauth_test.go index a213943e3c6..a91c3da3516 100644 --- a/pkg/login/social/azuread_oauth_test.go +++ b/pkg/login/social/azuread_oauth_test.go @@ -258,7 +258,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "Fetch groups when ClaimsNames and ClaimsSources is set", fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}), + SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, ""), }, claims: &azureClaims{ ID: "1", diff --git a/pkg/login/social/github_oauth.go b/pkg/login/social/github_oauth.go index 3d160e8e783..a44145b36e6 100644 --- a/pkg/login/social/github_oauth.go +++ b/pkg/login/social/github_oauth.go @@ -190,23 +190,31 @@ func (s *SocialGithub) UserInfo(client *http.Client, token *oauth2.Token) (*Basi return nil, fmt.Errorf("error getting user info: %s", err) } - err = json.Unmarshal(response.Body, &data) - if err != nil { - return nil, fmt.Errorf("Error getting user info: %s", err) + if err = json.Unmarshal(response.Body, &data); err != nil { + return nil, fmt.Errorf("error unmarshalling user info: %s", err) } teamMemberships, err := s.FetchTeamMemberships(client) if err != nil { - return nil, fmt.Errorf("Error getting user teams: %s", err) + return nil, fmt.Errorf("error getting user teams: %s", err) } teams := convertToGroupList(teamMemberships) + role, err := s.extractRole(response.Body, teams) + if err != nil { + s.log.Error("Failed to extract role", "error", err) + } + if s.roleAttributeStrict && !role.IsValid() { + return nil, errors.New("invalid role") + } + userInfo := &BasicUserInfo{ Name: data.Login, Login: data.Login, Id: fmt.Sprintf("%d", data.Id), Email: data.Email, + Role: string(role), Groups: teams, } if data.Name != "" { diff --git a/pkg/login/social/github_oauth_test.go b/pkg/login/social/github_oauth_test.go new file mode 100644 index 00000000000..305fa125ccb --- /dev/null +++ b/pkg/login/social/github_oauth_test.go @@ -0,0 +1,229 @@ +package social + +import ( + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +const testGHUserTeamsJSON = `[ + { + "id": 1, + "node_id": "MDQ6VGVhbTE=", + "url": "https://api.github.com/teams/1", + "html_url": "https://github.com/orgs/github/teams/justice-league", + "name": "Justice League", + "slug": "justice-league", + "description": "A great team.", + "privacy": "closed", + "permission": "admin", + "members_url": "https://api.github.com/teams/1/members{/member}", + "repositories_url": "https://api.github.com/teams/1/repos", + "parent": null, + "members_count": 3, + "repos_count": 10, + "created_at": "2017-07-14T16:53:42Z", + "updated_at": "2017-08-17T12:37:15Z", + "organization": { + "login": "github", + "id": 1, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjE=", + "url": "https://api.github.com/orgs/github", + "repos_url": "https://api.github.com/orgs/github/repos", + "events_url": "https://api.github.com/orgs/github/events", + "hooks_url": "https://api.github.com/orgs/github/hooks", + "issues_url": "https://api.github.com/orgs/github/issues", + "members_url": "https://api.github.com/orgs/github/members{/member}", + "public_members_url": "https://api.github.com/orgs/github/public_members{/member}", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "description": "A great organization", + "name": "github", + "company": "GitHub", + "blog": "https://github.com/blog", + "location": "San Francisco", + "email": "octocat@github.com", + "is_verified": true, + "has_organization_projects": true, + "has_repository_projects": true, + "public_repos": 2, + "public_gists": 1, + "followers": 20, + "following": 0, + "html_url": "https://github.com/octocat", + "created_at": "2008-01-14T04:33:35Z", + "updated_at": "2017-08-17T12:37:15Z", + "type": "Organization" + } + } +]` + +const testGHUserJSON = `{ + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false, + "name": "monalisa octocat", + "company": "GitHub", + "blog": "https://github.com/blog", + "location": "San Francisco", + "email": "octocat@github.com", + "hireable": false, + "bio": "There once was...", + "twitter_username": "monatheoctocat", + "public_repos": 2, + "public_gists": 1, + "followers": 20, + "following": 0, + "created_at": "2008-01-14T04:33:35Z", + "updated_at": "2008-01-14T04:33:35Z", + "private_gists": 81, + "total_private_repos": 100, + "owned_private_repos": 100, + "disk_usage": 10000, + "collaborators": 8, + "two_factor_authentication": true, + "plan": { + "name": "Medium", + "space": 400, + "private_repos": 20, + "collaborators": 0 + } +}` + +func TestSocialGitHub_UserInfo(t *testing.T) { + tests := []struct { + name string + userRawJSON string + userTeamsRawJSON string + settingAutoAssignOrgRole string + roleAttributePath string + autoAssignOrgRole string + want *BasicUserInfo + wantErr bool + }{ + { + name: "Basic User info", + userRawJSON: testGHUserJSON, + userTeamsRawJSON: testGHUserTeamsJSON, + autoAssignOrgRole: "", + roleAttributePath: "", + want: &BasicUserInfo{ + Id: "1", + Name: "monalisa octocat", + Email: "octocat@github.com", + Login: "octocat", + Company: "", + Role: "", + Groups: []string{"https://github.com/orgs/github/teams/justice-league", "@github/justice-league"}, + }, + }, + { + name: "Admin mapping takes precedence over auto assign org role", + roleAttributePath: "[login==octocat] && 'Admin' || 'Viewer'", + userRawJSON: testGHUserJSON, + autoAssignOrgRole: "Editor", + userTeamsRawJSON: testGHUserTeamsJSON, + want: &BasicUserInfo{ + Id: "1", + Name: "monalisa octocat", + Email: "octocat@github.com", + Login: "octocat", + Company: "", + Role: "Admin", + Groups: []string{"https://github.com/orgs/github/teams/justice-league", "@github/justice-league"}, + }, + }, + { + name: "Editor mapping via groups", + roleAttributePath: "contains(groups[*], '@github/justice-league') && 'Editor' || 'Viewer'", + userRawJSON: testGHUserJSON, + autoAssignOrgRole: "Editor", + userTeamsRawJSON: testGHUserTeamsJSON, + want: &BasicUserInfo{ + Id: "1", + Name: "monalisa octocat", + Email: "octocat@github.com", + Login: "octocat", + Company: "", + Role: "Editor", + Groups: []string{"https://github.com/orgs/github/teams/justice-league", "@github/justice-league"}, + }, + }, + { + name: "auto assign org role", + roleAttributePath: "", + userRawJSON: testGHUserJSON, + autoAssignOrgRole: "Editor", + userTeamsRawJSON: testGHUserTeamsJSON, + want: &BasicUserInfo{ + Id: "1", + Name: "monalisa octocat", + Email: "octocat@github.com", + Login: "octocat", + Company: "", + Role: "Editor", + Groups: []string{"https://github.com/orgs/github/teams/justice-league", "@github/justice-league"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(http.StatusOK) + // return JSON if matches user endpoint + if strings.HasSuffix(request.URL.String(), "/user") { + writer.Header().Set("Content-Type", "application/json") + _, err := writer.Write([]byte(tt.userRawJSON)) + require.NoError(t, err) + } else if strings.HasSuffix(request.URL.String(), "/user/teams?per_page=100") { + writer.Header().Set("Content-Type", "application/json") + _, err := writer.Write([]byte(tt.userTeamsRawJSON)) + require.NoError(t, err) + } else { + writer.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + s := &SocialGithub{ + SocialBase: newSocialBase("github", &oauth2.Config{}, + &OAuthInfo{RoleAttributePath: tt.roleAttributePath}, tt.autoAssignOrgRole), + allowedOrganizations: []string{}, + apiUrl: server.URL + "/user", + teamIds: []int{}, + } + + token := &oauth2.Token{ + AccessToken: "fake_token", + } + + got, err := s.UserInfo(server.Client(), token) + if (err != nil) != tt.wantErr { + t.Errorf("UserInfo() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("UserInfo() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/login/social/gitlab_oauth.go b/pkg/login/social/gitlab_oauth.go index f169c6a8a03..326ac0b06c5 100644 --- a/pkg/login/social/gitlab_oauth.go +++ b/pkg/login/social/gitlab_oauth.go @@ -14,10 +14,8 @@ import ( type SocialGitlab struct { *SocialBase - allowedGroups []string - apiUrl string - roleAttributePath string - roleAttributeStrict bool + allowedGroups []string + apiUrl string } func (s *SocialGitlab) Type() int { @@ -106,8 +104,7 @@ func (s *SocialGitlab) UserInfo(client *http.Client, token *oauth2.Token) (*Basi return nil, fmt.Errorf("Error getting user info: %s", err) } - err = json.Unmarshal(response.Body, &data) - if err != nil { + if err = json.Unmarshal(response.Body, &data); err != nil { return nil, fmt.Errorf("error getting user info: %s", err) } @@ -117,11 +114,11 @@ func (s *SocialGitlab) UserInfo(client *http.Client, token *oauth2.Token) (*Basi groups := s.GetGroups(client) - role, err := s.extractRole(response.Body) + role, err := s.extractRole(response.Body, groups) if err != nil { s.log.Error("Failed to extract role", "error", err) } - if s.roleAttributeStrict && !models.RoleType(role).IsValid() { + if s.roleAttributeStrict && !role.IsValid() { return nil, errors.New("invalid role") } @@ -131,7 +128,7 @@ func (s *SocialGitlab) UserInfo(client *http.Client, token *oauth2.Token) (*Basi Login: data.Username, Email: data.Email, Groups: groups, - Role: role, + Role: string(role), } if !s.IsGroupMember(groups) { @@ -140,16 +137,3 @@ func (s *SocialGitlab) UserInfo(client *http.Client, token *oauth2.Token) (*Basi return userInfo, nil } - -func (s *SocialGitlab) extractRole(rawJSON []byte) (string, error) { - if s.roleAttributePath == "" { - return "", nil - } - - role, err := s.searchJSONForStringAttr(s.roleAttributePath, rawJSON) - - if err != nil { - return "", err - } - return role, nil -} diff --git a/pkg/login/social/social.go b/pkg/login/social/social.go index 07928a0dacd..d172c362a5a 100644 --- a/pkg/login/social/social.go +++ b/pkg/login/social/social.go @@ -3,6 +3,7 @@ package social import ( "crypto/tls" "crypto/x509" + "encoding/json" "fmt" "io/ioutil" "net/http" @@ -13,6 +14,7 @@ import ( "golang.org/x/oauth2" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -133,7 +135,7 @@ func ProvideService(cfg *setting.Cfg) *SocialService { // GitHub. if name == "github" { ss.socialMap["github"] = &SocialGithub{ - SocialBase: newSocialBase(name, &config, info), + SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole), apiUrl: info.ApiUrl, teamIds: sec.Key("team_ids").Ints(","), allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()), @@ -143,18 +145,16 @@ func ProvideService(cfg *setting.Cfg) *SocialService { // GitLab. if name == "gitlab" { ss.socialMap["gitlab"] = &SocialGitlab{ - SocialBase: newSocialBase(name, &config, info), - apiUrl: info.ApiUrl, - allowedGroups: util.SplitString(sec.Key("allowed_groups").String()), - roleAttributePath: info.RoleAttributePath, - roleAttributeStrict: info.RoleAttributeStrict, + SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole), + apiUrl: info.ApiUrl, + allowedGroups: util.SplitString(sec.Key("allowed_groups").String()), } } // Google. if name == "google" { ss.socialMap["google"] = &SocialGoogle{ - SocialBase: newSocialBase(name, &config, info), + SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole), hostedDomain: info.HostedDomain, apiUrl: info.ApiUrl, } @@ -163,7 +163,7 @@ func ProvideService(cfg *setting.Cfg) *SocialService { // AzureAD. if name == "azuread" { ss.socialMap["azuread"] = &SocialAzureAD{ - SocialBase: newSocialBase(name, &config, info), + SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole), allowedGroups: util.SplitString(sec.Key("allowed_groups").String()), autoAssignOrgRole: cfg.AutoAssignOrgRole, roleAttributeStrict: info.RoleAttributeStrict, @@ -173,7 +173,7 @@ func ProvideService(cfg *setting.Cfg) *SocialService { // Okta if name == "okta" { ss.socialMap["okta"] = &SocialOkta{ - SocialBase: newSocialBase(name, &config, info), + SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole), apiUrl: info.ApiUrl, allowedGroups: util.SplitString(sec.Key("allowed_groups").String()), roleAttributePath: info.RoleAttributePath, @@ -184,7 +184,7 @@ func ProvideService(cfg *setting.Cfg) *SocialService { // Generic - Uses the same scheme as GitHub. if name == "generic_oauth" { ss.socialMap["generic_oauth"] = &SocialGenericOAuth{ - SocialBase: newSocialBase(name, &config, info), + SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole), apiUrl: info.ApiUrl, teamsUrl: info.TeamsUrl, emailAttributeName: info.EmailAttributeName, @@ -215,7 +215,8 @@ func ProvideService(cfg *setting.Cfg) *SocialService { } ss.socialMap[grafanaCom] = &SocialGrafanaCom{ - SocialBase: newSocialBase(name, &config, info), + SocialBase: newSocialBase(name, &config, info, + cfg.AutoAssignOrgRole), url: cfg.GrafanaComURL, allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()), } @@ -234,6 +235,11 @@ type BasicUserInfo struct { Groups []string } +func (b *BasicUserInfo) String() string { + return fmt.Sprintf("Id: %s, Name: %s, Email: %s, Login: %s, Company: %s, Role: %s, Groups: %v", + b.Id, b.Name, b.Email, b.Login, b.Company, b.Role, b.Groups) +} + type SocialConnector interface { Type() int UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) @@ -251,6 +257,10 @@ type SocialBase struct { log log.Logger allowSignup bool allowedDomains []string + + roleAttributePath string + roleAttributeStrict bool + autoAssignOrgRole string } type Error struct { @@ -279,17 +289,52 @@ type Service interface { GetOAuthInfoProviders() map[string]*OAuthInfo } -func newSocialBase(name string, config *oauth2.Config, info *OAuthInfo) *SocialBase { +func newSocialBase(name string, + config *oauth2.Config, + info *OAuthInfo, + autoAssignOrgRole string, +) *SocialBase { logger := log.New("oauth." + name) return &SocialBase{ - Config: config, - log: logger, - allowSignup: info.AllowSignup, - allowedDomains: info.AllowedDomains, + Config: config, + log: logger, + allowSignup: info.AllowSignup, + allowedDomains: info.AllowedDomains, + autoAssignOrgRole: autoAssignOrgRole, + roleAttributePath: info.RoleAttributePath, + roleAttributeStrict: info.RoleAttributeStrict, } } +type groupStruct struct { + Groups []string `json:"groups"` +} + +func (s *SocialBase) extractRole(rawJSON []byte, groups []string) (models.RoleType, error) { + if s.roleAttributePath == "" { + if s.autoAssignOrgRole != "" { + return models.RoleType(s.autoAssignOrgRole), nil + } + + return "", nil + } + + role, err := s.searchJSONForStringAttr(s.roleAttributePath, rawJSON) + if err == nil && role != "" { + return models.RoleType(role), nil + } + + if groupBytes, err := json.Marshal(groupStruct{groups}); err == nil { + if role, err := s.searchJSONForStringAttr( + s.roleAttributePath, groupBytes); err == nil && role != "" { + return models.RoleType(role), nil + } + } + + return "", nil +} + // GetOAuthProviders returns available oauth providers and if they're enabled or not func (ss *SocialService) GetOAuthProviders() map[string]bool { result := map[string]bool{}