Auth: Add support for role mapping and allowed groups in Google OIDC (#76266)

* support google oauth allowed_groups. unify allowed groups logic

* add role mapping for google oauth

* add documentation

* add addendums

* remove extra isGroupMember

* add to sample ini

* Apply suggestions from code review

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

---------

Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>
This commit is contained in:
Jo
2023-10-10 18:07:23 +02:00
committed by GitHub
co-authored by Gabriel MABILLE
parent 0d390382d3
commit cada1f040a
11 changed files with 297 additions and 72 deletions
+1 -18
View File
@@ -23,7 +23,6 @@ type SocialAzureAD struct {
*SocialBase
cache remotecache.CacheStorage
allowedOrganizations []string
allowedGroups []string
forceUseGraphAPI bool
skipOrgRoleSync bool
}
@@ -99,7 +98,7 @@ func (s *SocialAzureAD) UserInfo(ctx context.Context, client *http.Client, token
return nil, fmt.Errorf("failed to extract groups: %w", err)
}
s.log.Debug("AzureAD OAuth: extracted groups", "email", email, "groups", fmt.Sprintf("%v", groups))
if !s.IsGroupMember(groups) {
if !s.isGroupMember(groups) {
return nil, errMissingGroupMembership
}
@@ -182,22 +181,6 @@ func (s *SocialAzureAD) validateIDTokenSignature(ctx context.Context, client *ht
return nil, &Error{"AzureAD OAuth: signing key not found"}
}
func (s *SocialAzureAD) IsGroupMember(groups []string) bool {
if len(s.allowedGroups) == 0 {
return true
}
for _, allowedGroup := range s.allowedGroups {
for _, group := range groups {
if group == allowedGroup {
return true
}
}
}
return false
}
func (claims *azureClaims) extractEmail() string {
if claims.Email == "" {
if claims.PreferredUsername != "" {
+7 -3
View File
@@ -530,7 +530,6 @@ func TestSocialAzureAD_UserInfo(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
s := &SocialAzureAD{
SocialBase: tt.fields.SocialBase,
allowedGroups: tt.fields.allowedGroups,
allowedOrganizations: tt.fields.allowedOrganizations,
forceUseGraphAPI: tt.fields.forceUseGraphAPI,
cache: cache,
@@ -540,6 +539,10 @@ func TestSocialAzureAD_UserInfo(t *testing.T) {
s.SocialBase = newSocialBase("azuread", &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{}, "", false, *featuremgmt.WithFeatures())
}
if tt.fields.allowedGroups != nil {
s.allowedGroups = tt.fields.allowedGroups
}
if tt.fields.usGovURL {
s.SocialBase.Endpoint.AuthURL = usGovAuthURL
} else {
@@ -710,14 +713,15 @@ func TestSocialAzureAD_SkipOrgRole(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
s := &SocialAzureAD{
SocialBase: tt.fields.SocialBase,
allowedGroups: tt.fields.allowedGroups,
forceUseGraphAPI: tt.fields.forceUseGraphAPI,
skipOrgRoleSync: tt.fields.skipOrgRoleSync,
cache: cache,
}
if tt.fields.SocialBase == nil {
s.SocialBase = newSocialBase("azuread", &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{}, "", false, *featuremgmt.WithFeatures())
s.SocialBase = newSocialBase("azuread", &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{
AllowedGroups: tt.fields.allowedGroups,
}, "", false, *featuremgmt.WithFeatures())
}
s.SocialBase.Endpoint.AuthURL = authURL
-17
View File
@@ -22,7 +22,6 @@ const (
type SocialGitlab struct {
*SocialBase
allowedGroups []string
apiUrl string
skipOrgRoleSync bool
}
@@ -48,22 +47,6 @@ type userData struct {
IsGrafanaAdmin *bool `json:"-"`
}
func (s *SocialGitlab) isGroupMember(groups []string) bool {
if len(s.allowedGroups) == 0 {
return true
}
for _, allowedGroup := range s.allowedGroups {
for _, group := range groups {
if group == allowedGroup {
return true
}
}
}
return false
}
func (s *SocialGitlab) getGroups(ctx context.Context, client *http.Client) []string {
groups := make([]string, 0)
nextPage := new(int)
+21
View File
@@ -30,6 +30,7 @@ type googleUserData struct {
Email string `json:"email"`
Name string `json:"name"`
EmailVerified bool `json:"email_verified"`
rawJSON []byte `json:"-"`
}
func (s *SocialGoogle) UserInfo(ctx context.Context, client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) {
@@ -59,6 +60,10 @@ func (s *SocialGoogle) UserInfo(ctx context.Context, client *http.Client, token
s.log.Warn("Error retrieving groups", "error", errPage)
}
if !s.isGroupMember(groups) {
return nil, errMissingGroupMembership
}
userInfo := &BasicUserInfo{
Id: data.ID,
Name: data.Name,
@@ -69,6 +74,19 @@ func (s *SocialGoogle) UserInfo(ctx context.Context, client *http.Client, token
Groups: groups,
}
if !s.skipOrgRoleSync {
role, grafanaAdmin, errRole := s.extractRoleAndAdmin(data.rawJSON, groups)
if errRole != nil {
return nil, errRole
}
if s.allowAssignGrafanaAdmin {
userInfo.IsGrafanaAdmin = &grafanaAdmin
}
userInfo.Role = role
}
s.log.Debug("Resolved user info", "data", fmt.Sprintf("%+v", userInfo))
return userInfo, nil
@@ -98,6 +116,7 @@ func (s *SocialGoogle) extractFromAPI(ctx context.Context, client *http.Client)
Name: data.Name,
Email: data.Email,
EmailVerified: data.EmailVerified,
rawJSON: response.Body,
}, nil
}
@@ -145,6 +164,8 @@ func (s *SocialGoogle) extractFromToken(ctx context.Context, client *http.Client
return nil, fmt.Errorf("Error getting user info: %s", err)
}
data.rawJSON = rawJSON
return &data, nil
}
+165 -10
View File
@@ -15,6 +15,7 @@ import (
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models/roletype"
)
func TestSocialGoogle_retrieveGroups(t *testing.T) {
@@ -239,8 +240,13 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
tokenWithoutID := &oauth2.Token{}
type fields struct {
Scopes []string
apiURL string
Scopes []string
apiURL string
allowedGroups []string
roleAttributePath string
roleAttributeStrict bool
allowAssignGrafanaAdmin bool
skipOrgRoleSync bool
}
type args struct {
client *http.Client
@@ -257,7 +263,8 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
{
name: "Success id_token",
fields: fields{
Scopes: []string{},
Scopes: []string{},
skipOrgRoleSync: true,
},
args: args{
token: tokenWithID,
@@ -273,7 +280,8 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
{
name: "Success id_token - groups requested",
fields: fields{
Scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"},
Scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"},
skipOrgRoleSync: true,
},
args: args{
token: tokenWithID,
@@ -310,7 +318,8 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
{
name: "Legacy API URL",
fields: fields{
apiURL: legacyAPIURL,
apiURL: legacyAPIURL,
skipOrgRoleSync: true,
},
args: args{
token: tokenWithoutID,
@@ -340,7 +349,8 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
{
name: "Legacy API URL - no id provided",
fields: fields{
apiURL: legacyAPIURL,
apiURL: legacyAPIURL,
skipOrgRoleSync: true,
},
args: args{
token: tokenWithoutID,
@@ -426,7 +436,8 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
{
name: "Success",
fields: fields{
apiURL: "https://openidconnect.googleapis.com/v1/userinfo",
apiURL: "https://openidconnect.googleapis.com/v1/userinfo",
skipOrgRoleSync: true,
},
args: args{
token: tokenWithoutID,
@@ -478,6 +489,145 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
wantErr: true,
wantErrMsg: "email is not verified",
},
{
name: "not in allowed Groups",
fields: fields{
Scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"},
allowedGroups: []string{"not-that-one"},
},
args: args{
token: tokenWithID,
client: &http.Client{
Transport: &roundTripperFunc{
fn: func(req *http.Request) (*http.Response, error) {
resp := httptest.NewRecorder()
_, _ = resp.WriteString(`{
"memberships": [
{
"group": "test-group",
"groupKey": {
"id": "test-group@google.com"
},
"displayName": "Test Group"
}
],
"nextPageToken": ""
}`)
return resp.Result(), nil
},
},
},
},
wantData: &BasicUserInfo{
Id: "88888888888888",
Login: "test@example.com",
Email: "test@example.com",
Name: "Test User",
Groups: []string{"test-group@google.com"},
},
wantErr: true,
wantErrMsg: "user not a member of one of the required groups",
},
{
name: "Role mapping - strict",
fields: fields{
Scopes: []string{},
allowedGroups: []string{},
roleAttributePath: "this",
roleAttributeStrict: true,
},
args: args{
token: tokenWithID,
},
wantData: &BasicUserInfo{
Id: "88888888888888",
Login: "test@example.com",
Email: "test@example.com",
Name: "Test User",
Groups: []string{"test-group@google.com"},
},
wantErr: true,
wantErrMsg: "idP did not return a role attribute, but role_attribute_strict is set",
},
{
name: "role mapping from id_token - no allowed assign Grafana Admin",
fields: fields{
Scopes: []string{},
allowAssignGrafanaAdmin: false,
roleAttributePath: "email_verified && 'GrafanaAdmin'",
},
args: args{
token: tokenWithID,
},
wantData: &BasicUserInfo{
Id: "88888888888888",
Login: "test@example.com",
Email: "test@example.com",
Name: "Test User",
Role: roletype.RoleAdmin,
IsGrafanaAdmin: nil,
},
wantErr: false,
},
{
name: "role mapping from id_token - allowed assign Grafana Admin",
fields: fields{
Scopes: []string{},
allowAssignGrafanaAdmin: true,
roleAttributePath: "email_verified && 'GrafanaAdmin'",
},
args: args{
token: tokenWithID,
},
wantData: &BasicUserInfo{
Id: "88888888888888",
Login: "test@example.com",
Email: "test@example.com",
Name: "Test User",
Role: roletype.RoleAdmin,
IsGrafanaAdmin: trueBoolPtr(),
},
wantErr: false,
},
{
name: "mapping from groups",
fields: fields{
Scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"},
roleAttributePath: "contains(groups[*], 'test-group@google.com') && 'Editor'",
},
args: args{
token: tokenWithID,
client: &http.Client{
Transport: &roundTripperFunc{
fn: func(req *http.Request) (*http.Response, error) {
resp := httptest.NewRecorder()
_, _ = resp.WriteString(`{
"memberships": [
{
"group": "test-group",
"groupKey": {
"id": "test-group@google.com"
},
"displayName": "Test Group"
}
],
"nextPageToken": ""
}`)
return resp.Result(), nil
},
},
},
},
wantData: &BasicUserInfo{
Id: "88888888888888",
Login: "test@example.com",
Email: "test@example.com",
Name: "Test User",
Role: "Editor",
Groups: []string{"test-group@google.com"},
},
wantErr: false,
},
}
for _, tt := range tests {
@@ -485,10 +635,15 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
s := &SocialGoogle{
apiUrl: tt.fields.apiURL,
SocialBase: &SocialBase{
Config: &oauth2.Config{Scopes: tt.fields.Scopes},
log: log.NewNopLogger(),
allowSignup: false,
Config: &oauth2.Config{Scopes: tt.fields.Scopes},
log: log.NewNopLogger(),
allowSignup: false,
allowedGroups: tt.fields.allowedGroups,
roleAttributePath: tt.fields.roleAttributePath,
roleAttributeStrict: tt.fields.roleAttributeStrict,
allowAssignGrafanaAdmin: tt.fields.allowAssignGrafanaAdmin,
},
skipOrgRoleSync: tt.fields.skipOrgRoleSync,
}
gotData, err := s.UserInfo(context.Background(), tt.args.client, tt.args.token)
+21 -3
View File
@@ -63,6 +63,7 @@ type OAuthInfo struct {
TlsClientKey string `toml:"tls_client_key"`
TokenUrl string `toml:"token_url"`
AllowedDomains []string `toml:"allowed_domains"`
AllowedGroups []string `toml:"allowed_groups"`
Scopes []string `toml:"scopes"`
AllowAssignGrafanaAdmin bool `toml:"allow_assign_grafana_admin"`
AllowSignup bool `toml:"allow_signup"`
@@ -120,6 +121,7 @@ func ProvideService(cfg *setting.Cfg,
UseRefreshToken: sec.Key("use_refresh_token").MustBool(false),
AllowAssignGrafanaAdmin: sec.Key("allow_assign_grafana_admin").MustBool(false),
AutoLogin: sec.Key("auto_login").MustBool(false),
AllowedGroups: util.SplitString(sec.Key("allowed_groups").String()),
}
// when empty_scopes parameter exists and is true, overwrite scope with empty value
@@ -178,7 +180,6 @@ func ProvideService(cfg *setting.Cfg,
ss.socialMap["gitlab"] = &SocialGitlab{
SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features),
apiUrl: info.ApiUrl,
allowedGroups: util.SplitString(sec.Key("allowed_groups").String()),
skipOrgRoleSync: cfg.GitLabSkipOrgRoleSync,
}
}
@@ -202,7 +203,6 @@ func ProvideService(cfg *setting.Cfg,
SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features),
cache: cache,
allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()),
allowedGroups: util.SplitString(sec.Key("allowed_groups").String()),
forceUseGraphAPI: sec.Key("force_use_graph_api").MustBool(false),
skipOrgRoleSync: cfg.AzureADSkipOrgRoleSync,
}
@@ -305,6 +305,7 @@ type SocialBase struct {
allowSignup bool
allowAssignGrafanaAdmin bool
allowedDomains []string
allowedGroups []string
roleAttributePath string
roleAttributeStrict bool
@@ -356,9 +357,10 @@ func newSocialBase(name string,
allowSignup: info.AllowSignup,
allowAssignGrafanaAdmin: info.AllowAssignGrafanaAdmin,
allowedDomains: info.AllowedDomains,
autoAssignOrgRole: autoAssignOrgRole,
allowedGroups: info.AllowedGroups,
roleAttributePath: info.RoleAttributePath,
roleAttributeStrict: info.RoleAttributeStrict,
autoAssignOrgRole: autoAssignOrgRole,
skipOrgRoleSync: skipOrgRoleSync,
features: features,
useRefreshToken: info.UseRefreshToken,
@@ -571,6 +573,22 @@ func (ss *SocialService) getUsageStats(ctx context.Context) (map[string]interfac
return m, nil
}
func (s *SocialBase) isGroupMember(groups []string) bool {
if len(s.allowedGroups) == 0 {
return true
}
for _, allowedGroup := range s.allowedGroups {
for _, group := range groups {
if group == allowedGroup {
return true
}
}
}
return false
}
func (s *SocialBase) retrieveRawIDToken(idToken interface{}) ([]byte, error) {
tokenString, ok := idToken.(string)
if !ok {
+1 -3
View File
@@ -1497,9 +1497,7 @@ func readAuthGithubSettings(cfg *Cfg) {
func readAuthGoogleSettings(cfg *Cfg) {
sec := cfg.SectionWithEnvOverrides("auth.google")
cfg.GoogleAuthEnabled = sec.Key("enabled").MustBool(false)
// FIXME: for now we skip org role sync for google auth
// as we do not sync organization roles from Google
cfg.GoogleSkipOrgRoleSync = true
cfg.GoogleSkipOrgRoleSync = sec.Key("skip_org_role_sync").MustBool(true)
}
func readAuthGitlabSettings(cfg *Cfg) {