OAuth: Allow role mapping from GitHub and GitLab groups (#52407)

* OAuth: Add extract role support to github

OAuth: correct github errors

Oauth: add github tests

Oauth: Allow mapping via group memberships

Oauth: Add markdown instructions to the new mappers

fix lint

* Apply suggestions from code review

Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>
Co-authored-by: Vardan Torosyan <vardants@gmail.com>

* Apply suggestions from code review

Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>
Co-authored-by: Vardan Torosyan <vardants@gmail.com>
This commit is contained in:
Jo 2022-07-20 14:32:04 +00:00 committed by GitHub
parent 01d561224c
commit 5c4aa4a7ac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 354 additions and 44 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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 != "" {

View File

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

View File

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

View File

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