Skip to content

Commit f3efd95

Browse files
kalleepmgyongyosi
andauthored
Auth: Add org to role mappings support to Google integration (grafana#88891)
* Auth: Implement org role mapping for google oauth provider * Update docs * Remove unused function Co-authored-by: Misi <[email protected]>
1 parent 5095ea8 commit f3efd95

File tree

7 files changed

+148
-99
lines changed

7 files changed

+148
-99
lines changed

conf/defaults.ini

+1
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,7 @@ hosted_domain =
705705
allowed_groups =
706706
role_attribute_path =
707707
role_attribute_strict = false
708+
org_mapping =
708709
allow_assign_grafana_admin = false
709710
skip_org_role_sync = true
710711
tls_skip_verify_insecure = false

conf/sample.ini

+1
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,7 @@
659659
;allowed_groups =
660660
;role_attribute_path =
661661
;role_attribute_strict = false
662+
;org_mapping =
662663
;allow_assign_grafana_admin = false
663664
;skip_org_role_sync = false
664665
;use_pkce = true

docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md

+15-2
Original file line numberDiff line numberDiff line change
@@ -201,8 +201,7 @@ The user's role is retrieved using a [JMESPath](http://jmespath.org/examples.htm
201201
To map the server administrator role, use the `allow_assign_grafana_admin` configuration option.
202202

203203
If no valid role is found, the user is assigned the role specified by [the `auto_assign_org_role` option]({{< relref "../../../configure-grafana#auto_assign_org_role" >}}).
204-
You can disable this default role assignment by setting `role_attribute_strict = true`.
205-
This setting denies user access if no role or an invalid role is returned.
204+
You can disable this default role assignment by setting `role_attribute_strict = true`. This setting denies user access if no role or an invalid role is returned after evaluating the `role_attribute_path` and the `org_mapping` expressions.
206205

207206
To ease configuration of a proper JMESPath expression, go to [JMESPath](http://jmespath.org/) to test and evaluate expressions with custom payloads.
208207

@@ -212,6 +211,20 @@ To ease configuration of a proper JMESPath expression, go to [JMESPath](http://j
212211

213212
This section includes examples of JMESPath expressions used for role mapping.
214213

214+
##### Org roles mapping example
215+
216+
The Google integration uses the external users' groups in the `org_mapping` configuration to map organizations and roles based on their Google group membership.
217+
218+
In this example, the user has been granted the role of a `Viewer` in the `org_foo` organization, and the role of an `Editor` in the `org_bar` and `org_baz` orgs.
219+
220+
The external user is part of the following Google groups: `group-1` and `group-2`.
221+
222+
Config:
223+
224+
```ini
225+
org_mapping = group-1:org_foo:Viewer group-2:org_bar:Editor *:org_baz:Editor
226+
```
227+
215228
###### Map roles using user information from OAuth token
216229

217230
In this example, the user with email `[email protected]` has been granted the `Admin` role.

pkg/login/social/connectors/google_oauth.go

+16-11
Original file line numberDiff line numberDiff line change
@@ -140,26 +140,31 @@ func (s *SocialGoogle) UserInfo(ctx context.Context, client *http.Client, token
140140
}
141141

142142
userInfo := &social.BasicUserInfo{
143-
Id: data.ID,
144-
Name: data.Name,
145-
Email: data.Email,
146-
Login: data.Email,
147-
Role: "",
148-
IsGrafanaAdmin: nil,
149-
Groups: groups,
143+
Id: data.ID,
144+
Name: data.Name,
145+
Email: data.Email,
146+
Login: data.Email,
147+
Groups: groups,
148+
}
149+
150+
if s.info.AllowAssignGrafanaAdmin && s.info.SkipOrgRoleSync {
151+
s.log.Debug("AllowAssignGrafanaAdmin and skipOrgRoleSync are both set, Grafana Admin role will not be synced, consider setting one or the other")
150152
}
151153

152154
if !s.info.SkipOrgRoleSync {
153-
role, grafanaAdmin, errRole := s.extractRoleAndAdmin(data.rawJSON, groups)
154-
if errRole != nil {
155-
return nil, errRole
155+
directlyMappedRole, grafanaAdmin, err := s.extractRoleAndAdminOptional(data.rawJSON, userInfo.Groups)
156+
if err != nil {
157+
s.log.Warn("Failed to extract role", "err", err)
156158
}
157159

158160
if s.info.AllowAssignGrafanaAdmin {
159161
userInfo.IsGrafanaAdmin = &grafanaAdmin
160162
}
161163

162-
userInfo.Role = role
164+
userInfo.OrgRoles = s.orgRoleMapper.MapOrgRoles(s.orgMappingCfg, userInfo.Groups, directlyMappedRole)
165+
if s.info.RoleAttributeStrict && len(userInfo.OrgRoles) == 0 {
166+
return nil, errRoleAttributeStrictViolation.Errorf("could not evaluate any valid roles using IdP provided data")
167+
}
163168
}
164169

165170
s.log.Debug("Resolved user info", "data", fmt.Sprintf("%+v", userInfo))

pkg/login/social/connectors/google_oauth_test.go

+113-76
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ import (
1515
"golang.org/x/oauth2"
1616

1717
"github.com/grafana/grafana/pkg/login/social"
18-
"github.com/grafana/grafana/pkg/models/roletype"
1918
"github.com/grafana/grafana/pkg/services/auth/identity"
2019
"github.com/grafana/grafana/pkg/services/featuremgmt"
20+
"github.com/grafana/grafana/pkg/services/org"
21+
"github.com/grafana/grafana/pkg/services/org/orgtest"
2122
"github.com/grafana/grafana/pkg/services/ssosettings"
2223
ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models"
2324
"github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests"
@@ -224,6 +225,21 @@ func (f *roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error)
224225
return f.fn(req)
225226
}
226227

228+
const googleGroupsJSON = `
229+
{
230+
"memberships": [
231+
{
232+
"group": "test-group",
233+
"groupKey": {
234+
235+
},
236+
"displayName": "Test Group"
237+
}
238+
],
239+
"nextPageToken": ""
240+
}
241+
`
242+
227243
func TestSocialGoogle_UserInfo(t *testing.T) {
228244
cl := jwt.Claims{
229245
Subject: "88888888888888",
@@ -250,13 +266,24 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
250266

251267
tokenWithoutID := &oauth2.Token{}
252268

269+
groupClient := &http.Client{
270+
Transport: &roundTripperFunc{
271+
fn: func(req *http.Request) (*http.Response, error) {
272+
resp := httptest.NewRecorder()
273+
_, _ = resp.WriteString(googleGroupsJSON)
274+
return resp.Result(), nil
275+
},
276+
},
277+
}
278+
253279
type fields struct {
254280
Scopes []string
255281
apiURL string
256282
allowedGroups []string
257283
roleAttributePath string
258284
roleAttributeStrict bool
259285
allowAssignGrafanaAdmin bool
286+
orgMapping []string
260287
skipOrgRoleSync bool
261288
}
262289
type args struct {
@@ -295,27 +322,8 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
295322
skipOrgRoleSync: true,
296323
},
297324
args: args{
298-
token: tokenWithID,
299-
client: &http.Client{
300-
Transport: &roundTripperFunc{
301-
fn: func(req *http.Request) (*http.Response, error) {
302-
resp := httptest.NewRecorder()
303-
_, _ = resp.WriteString(`{
304-
"memberships": [
305-
{
306-
"group": "test-group",
307-
"groupKey": {
308-
309-
},
310-
"displayName": "Test Group"
311-
}
312-
],
313-
"nextPageToken": ""
314-
}`)
315-
return resp.Result(), nil
316-
},
317-
},
318-
},
325+
token: tokenWithID,
326+
client: groupClient,
319327
},
320328
wantData: &social.BasicUserInfo{
321329
Id: "88888888888888",
@@ -507,27 +515,8 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
507515
allowedGroups: []string{"not-that-one"},
508516
},
509517
args: args{
510-
token: tokenWithID,
511-
client: &http.Client{
512-
Transport: &roundTripperFunc{
513-
fn: func(req *http.Request) (*http.Response, error) {
514-
resp := httptest.NewRecorder()
515-
_, _ = resp.WriteString(`{
516-
"memberships": [
517-
{
518-
"group": "test-group",
519-
"groupKey": {
520-
521-
},
522-
"displayName": "Test Group"
523-
}
524-
],
525-
"nextPageToken": ""
526-
}`)
527-
return resp.Result(), nil
528-
},
529-
},
530-
},
518+
token: tokenWithID,
519+
client: groupClient,
531520
},
532521
wantData: &social.BasicUserInfo{
533522
Id: "88888888888888",
@@ -558,7 +547,7 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
558547
Groups: []string{"[email protected]"},
559548
},
560549
wantErr: true,
561-
wantErrMsg: "idP did not return a role attribute, but role_attribute_strict is set",
550+
wantErrMsg: "[oauth.role_attribute_strict_violation] could not evaluate any valid roles using IdP provided data",
562551
},
563552
{
564553
name: "role mapping from id_token - no allowed assign Grafana Admin",
@@ -575,7 +564,7 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
575564
576565
577566
Name: "Test User",
578-
Role: roletype.RoleAdmin,
567+
OrgRoles: map[int64]org.RoleType{1: org.RoleAdmin},
579568
IsGrafanaAdmin: nil,
580569
},
581570
wantErr: false,
@@ -595,7 +584,7 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
595584
596585
597586
Name: "Test User",
598-
Role: roletype.RoleAdmin,
587+
OrgRoles: map[int64]org.RoleType{1: org.RoleAdmin},
599588
IsGrafanaAdmin: trueBoolPtr(),
600589
},
601590
wantErr: false,
@@ -607,55 +596,103 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
607596
roleAttributePath: "contains(groups[*], '[email protected]') && 'Editor'",
608597
},
609598
args: args{
610-
token: tokenWithID,
611-
client: &http.Client{
612-
Transport: &roundTripperFunc{
613-
fn: func(req *http.Request) (*http.Response, error) {
614-
resp := httptest.NewRecorder()
615-
_, _ = resp.WriteString(`{
616-
"memberships": [
617-
{
618-
"group": "test-group",
619-
"groupKey": {
620-
621-
},
622-
"displayName": "Test Group"
623-
}
624-
],
625-
"nextPageToken": ""
626-
}`)
627-
return resp.Result(), nil
628-
},
629-
},
630-
},
599+
token: tokenWithID,
600+
client: groupClient,
631601
},
632602
wantData: &social.BasicUserInfo{
633-
Id: "88888888888888",
634-
635-
636-
Name: "Test User",
637-
Role: "Editor",
638-
Groups: []string{"[email protected]"},
603+
Id: "88888888888888",
604+
605+
606+
Name: "Test User",
607+
OrgRoles: map[int64]org.RoleType{1: org.RoleEditor},
608+
Groups: []string{"[email protected]"},
609+
},
610+
wantErr: false,
611+
},
612+
{
613+
name: "mapping from groups",
614+
fields: fields{
615+
Scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"},
616+
roleAttributePath: "contains(groups[*], '[email protected]') && 'Editor'",
617+
},
618+
args: args{
619+
token: tokenWithID,
620+
client: groupClient,
621+
},
622+
wantData: &social.BasicUserInfo{
623+
Id: "88888888888888",
624+
625+
626+
Name: "Test User",
627+
OrgRoles: map[int64]org.RoleType{1: org.RoleEditor},
628+
Groups: []string{"[email protected]"},
639629
},
640630
wantErr: false,
641631
},
632+
{
633+
name: "Should map role when only org mapping is set",
634+
fields: fields{
635+
Scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"},
636+
orgMapping: []string{"[email protected]:Org4:Editor", "*:Org5:Viewer"},
637+
},
638+
args: args{
639+
token: tokenWithID,
640+
client: groupClient,
641+
},
642+
wantData: &social.BasicUserInfo{
643+
Id: "88888888888888",
644+
645+
646+
Name: "Test User",
647+
OrgRoles: map[int64]org.RoleType{4: org.RoleEditor, 5: org.RoleViewer},
648+
Groups: []string{"[email protected]"},
649+
},
650+
wantErr: false,
651+
},
652+
{
653+
name: "Should return error when neither role attribute path nor org mapping evaluates to a role and role attribute strict is enabled",
654+
fields: fields{
655+
Scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"},
656+
orgMapping: []string{"[email protected]:Org4:Editor"},
657+
roleAttributeStrict: true,
658+
},
659+
args: args{
660+
token: tokenWithID,
661+
client: groupClient,
662+
},
663+
wantErr: true,
664+
},
665+
{
666+
name: "Should return error when neither role attribute path nor org mapping is set and role attribute strict is enabled",
667+
fields: fields{
668+
Scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"},
669+
roleAttributeStrict: true,
670+
},
671+
args: args{
672+
token: tokenWithID,
673+
client: groupClient,
674+
},
675+
wantErr: true,
676+
},
642677
}
643678

644679
for _, tt := range tests {
645680
t.Run(tt.name, func(t *testing.T) {
681+
cfg := setting.NewCfg()
682+
646683
s := NewGoogleProvider(
647684
&social.OAuthInfo{
648685
ApiUrl: tt.fields.apiURL,
649686
Scopes: tt.fields.Scopes,
650687
AllowedGroups: tt.fields.allowedGroups,
651-
AllowSignup: false,
652688
RoleAttributePath: tt.fields.roleAttributePath,
653689
RoleAttributeStrict: tt.fields.roleAttributeStrict,
654690
AllowAssignGrafanaAdmin: tt.fields.allowAssignGrafanaAdmin,
655691
SkipOrgRoleSync: tt.fields.skipOrgRoleSync,
692+
OrgMapping: tt.fields.orgMapping,
656693
},
657-
&setting.Cfg{},
658-
nil,
694+
cfg,
695+
ProvideOrgRoleMapper(cfg, &orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}),
659696
&ssosettingstests.MockService{},
660697
featuremgmt.WithFeatures())
661698

pkg/login/social/connectors/social_base.go

-9
Original file line numberDiff line numberDiff line change
@@ -153,15 +153,6 @@ func (s *SocialBase) extractRoleAndAdminOptional(rawJSON []byte, groups []string
153153
return "", false, nil
154154
}
155155

156-
func (s *SocialBase) extractRoleAndAdmin(rawJSON []byte, groups []string) (org.RoleType, bool, error) {
157-
role, gAdmin, err := s.extractRoleAndAdminOptional(rawJSON, groups)
158-
if role == "" {
159-
role = s.defaultRole()
160-
}
161-
162-
return role, gAdmin, err
163-
}
164-
165156
func (s *SocialBase) searchRole(rawJSON []byte, groups []string) (org.RoleType, bool) {
166157
role, err := util.SearchJSONForStringAttr(s.info.RoleAttributePath, rawJSON)
167158
if err == nil && role != "" {

pkg/services/authn/clients/oauth.go

+2-1
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,8 @@ func (c *OAuth) Authenticate(ctx context.Context, r *authn.Request) (*authn.Iden
168168

169169
// This is required to implement OrgRole mapping for OAuth providers step by step
170170
switch c.providerName {
171-
case social.GenericOAuthProviderName, social.GitHubProviderName, social.GitlabProviderName, social.OktaProviderName:
171+
case social.GenericOAuthProviderName, social.GitHubProviderName,
172+
social.GitlabProviderName, social.OktaProviderName, social.GoogleProviderName:
172173
// Do nothing, these providers already supports OrgRole mapping
173174
default:
174175
userInfo.OrgRoles, userInfo.IsGrafanaAdmin, _ = getRoles(c.cfg, func() (org.RoleType, *bool, error) {

0 commit comments

Comments
 (0)