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 10:32:04 -04:00
committed by GitHub
co-authored by Gabriel MABILLE Vardan Torosyan
parent 01d561224c
commit 5c4aa4a7ac
8 changed files with 354 additions and 44 deletions
+1 -1
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",
+12 -4
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 != "" {
+229
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)
}
})
}
}
+6 -22
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
}
+61 -16
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{}