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:
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user