diff --git a/conf/defaults.ini b/conf/defaults.ini index eb00ac144ca..5da264003de 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -705,6 +705,7 @@ hosted_domain = allowed_groups = role_attribute_path = role_attribute_strict = false +org_mapping = allow_assign_grafana_admin = false skip_org_role_sync = true tls_skip_verify_insecure = false diff --git a/conf/sample.ini b/conf/sample.ini index 3bb52662287..f9c7554ca78 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -659,6 +659,7 @@ ;allowed_groups = ;role_attribute_path = ;role_attribute_strict = false +;org_mapping = ;allow_assign_grafana_admin = false ;skip_org_role_sync = false ;use_pkce = true diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md index eca7ab7253a..ae4287a20e2 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md @@ -201,8 +201,7 @@ The user's role is retrieved using a [JMESPath](http://jmespath.org/examples.htm To map the server administrator role, use the `allow_assign_grafana_admin` configuration option. 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" >}}). -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. +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. To ease configuration of a proper JMESPath expression, go to [JMESPath](http://jmespath.org/) to test and evaluate expressions with custom payloads. @@ -212,6 +211,20 @@ To ease configuration of a proper JMESPath expression, go to [JMESPath](http://j This section includes examples of JMESPath expressions used for role mapping. +##### Org roles mapping example + +The Google integration uses the external users' groups in the `org_mapping` configuration to map organizations and roles based on their Google group membership. + +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. + +The external user is part of the following Google groups: `group-1` and `group-2`. + +Config: + +```ini +org_mapping = group-1:org_foo:Viewer group-2:org_bar:Editor *:org_baz:Editor +``` + ###### Map roles using user information from OAuth token In this example, the user with email `admin@company.com` has been granted the `Admin` role. diff --git a/pkg/login/social/connectors/google_oauth.go b/pkg/login/social/connectors/google_oauth.go index a8da90b9edc..fba95f5c640 100644 --- a/pkg/login/social/connectors/google_oauth.go +++ b/pkg/login/social/connectors/google_oauth.go @@ -140,26 +140,31 @@ func (s *SocialGoogle) UserInfo(ctx context.Context, client *http.Client, token } userInfo := &social.BasicUserInfo{ - Id: data.ID, - Name: data.Name, - Email: data.Email, - Login: data.Email, - Role: "", - IsGrafanaAdmin: nil, - Groups: groups, + Id: data.ID, + Name: data.Name, + Email: data.Email, + Login: data.Email, + Groups: groups, + } + + if s.info.AllowAssignGrafanaAdmin && s.info.SkipOrgRoleSync { + s.log.Debug("AllowAssignGrafanaAdmin and skipOrgRoleSync are both set, Grafana Admin role will not be synced, consider setting one or the other") } if !s.info.SkipOrgRoleSync { - role, grafanaAdmin, errRole := s.extractRoleAndAdmin(data.rawJSON, groups) - if errRole != nil { - return nil, errRole + directlyMappedRole, grafanaAdmin, err := s.extractRoleAndAdminOptional(data.rawJSON, userInfo.Groups) + if err != nil { + s.log.Warn("Failed to extract role", "err", err) } if s.info.AllowAssignGrafanaAdmin { userInfo.IsGrafanaAdmin = &grafanaAdmin } - userInfo.Role = role + userInfo.OrgRoles = s.orgRoleMapper.MapOrgRoles(s.orgMappingCfg, userInfo.Groups, directlyMappedRole) + if s.info.RoleAttributeStrict && len(userInfo.OrgRoles) == 0 { + return nil, errRoleAttributeStrictViolation.Errorf("could not evaluate any valid roles using IdP provided data") + } } s.log.Debug("Resolved user info", "data", fmt.Sprintf("%+v", userInfo)) diff --git a/pkg/login/social/connectors/google_oauth_test.go b/pkg/login/social/connectors/google_oauth_test.go index 971d58ba7bd..a599e454a5b 100644 --- a/pkg/login/social/connectors/google_oauth_test.go +++ b/pkg/login/social/connectors/google_oauth_test.go @@ -15,9 +15,10 @@ import ( "golang.org/x/oauth2" "github.com/grafana/grafana/pkg/login/social" - "github.com/grafana/grafana/pkg/models/roletype" "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/org/orgtest" "github.com/grafana/grafana/pkg/services/ssosettings" ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models" "github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests" @@ -224,6 +225,21 @@ func (f *roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) return f.fn(req) } +const googleGroupsJSON = ` +{ + "memberships": [ + { + "group": "test-group", + "groupKey": { + "id": "test-group@google.com" + }, + "displayName": "Test Group" + } + ], + "nextPageToken": "" +} +` + func TestSocialGoogle_UserInfo(t *testing.T) { cl := jwt.Claims{ Subject: "88888888888888", @@ -250,6 +266,16 @@ func TestSocialGoogle_UserInfo(t *testing.T) { tokenWithoutID := &oauth2.Token{} + groupClient := &http.Client{ + Transport: &roundTripperFunc{ + fn: func(req *http.Request) (*http.Response, error) { + resp := httptest.NewRecorder() + _, _ = resp.WriteString(googleGroupsJSON) + return resp.Result(), nil + }, + }, + } + type fields struct { Scopes []string apiURL string @@ -257,6 +283,7 @@ func TestSocialGoogle_UserInfo(t *testing.T) { roleAttributePath string roleAttributeStrict bool allowAssignGrafanaAdmin bool + orgMapping []string skipOrgRoleSync bool } type args struct { @@ -295,27 +322,8 @@ func TestSocialGoogle_UserInfo(t *testing.T) { skipOrgRoleSync: true, }, 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 - }, - }, - }, + token: tokenWithID, + client: groupClient, }, wantData: &social.BasicUserInfo{ Id: "88888888888888", @@ -507,27 +515,8 @@ func TestSocialGoogle_UserInfo(t *testing.T) { 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 - }, - }, - }, + token: tokenWithID, + client: groupClient, }, wantData: &social.BasicUserInfo{ Id: "88888888888888", @@ -558,7 +547,7 @@ func TestSocialGoogle_UserInfo(t *testing.T) { Groups: []string{"test-group@google.com"}, }, wantErr: true, - wantErrMsg: "idP did not return a role attribute, but role_attribute_strict is set", + wantErrMsg: "[oauth.role_attribute_strict_violation] could not evaluate any valid roles using IdP provided data", }, { name: "role mapping from id_token - no allowed assign Grafana Admin", @@ -575,7 +564,7 @@ func TestSocialGoogle_UserInfo(t *testing.T) { Login: "test@example.com", Email: "test@example.com", Name: "Test User", - Role: roletype.RoleAdmin, + OrgRoles: map[int64]org.RoleType{1: org.RoleAdmin}, IsGrafanaAdmin: nil, }, wantErr: false, @@ -595,7 +584,7 @@ func TestSocialGoogle_UserInfo(t *testing.T) { Login: "test@example.com", Email: "test@example.com", Name: "Test User", - Role: roletype.RoleAdmin, + OrgRoles: map[int64]org.RoleType{1: org.RoleAdmin}, IsGrafanaAdmin: trueBoolPtr(), }, wantErr: false, @@ -607,55 +596,103 @@ func TestSocialGoogle_UserInfo(t *testing.T) { 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 - }, - }, - }, + token: tokenWithID, + client: groupClient, }, wantData: &social.BasicUserInfo{ - Id: "88888888888888", - Login: "test@example.com", - Email: "test@example.com", - Name: "Test User", - Role: "Editor", - Groups: []string{"test-group@google.com"}, + Id: "88888888888888", + Login: "test@example.com", + Email: "test@example.com", + Name: "Test User", + OrgRoles: map[int64]org.RoleType{1: org.RoleEditor}, + Groups: []string{"test-group@google.com"}, }, 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: groupClient, + }, + wantData: &social.BasicUserInfo{ + Id: "88888888888888", + Login: "test@example.com", + Email: "test@example.com", + Name: "Test User", + OrgRoles: map[int64]org.RoleType{1: org.RoleEditor}, + Groups: []string{"test-group@google.com"}, + }, + wantErr: false, + }, + { + name: "Should map role when only org mapping is set", + fields: fields{ + Scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"}, + orgMapping: []string{"test-group@google.com:Org4:Editor", "*:Org5:Viewer"}, + }, + args: args{ + token: tokenWithID, + client: groupClient, + }, + wantData: &social.BasicUserInfo{ + Id: "88888888888888", + Login: "test@example.com", + Email: "test@example.com", + Name: "Test User", + OrgRoles: map[int64]org.RoleType{4: org.RoleEditor, 5: org.RoleViewer}, + Groups: []string{"test-group@google.com"}, + }, + wantErr: false, + }, + { + name: "Should return error when neither role attribute path nor org mapping evaluates to a role and role attribute strict is enabled", + fields: fields{ + Scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"}, + orgMapping: []string{"other@google.com:Org4:Editor"}, + roleAttributeStrict: true, + }, + args: args{ + token: tokenWithID, + client: groupClient, + }, + wantErr: true, + }, + { + name: "Should return error when neither role attribute path nor org mapping is set and role attribute strict is enabled", + fields: fields{ + Scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"}, + roleAttributeStrict: true, + }, + args: args{ + token: tokenWithID, + client: groupClient, + }, + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + cfg := setting.NewCfg() + s := NewGoogleProvider( &social.OAuthInfo{ ApiUrl: tt.fields.apiURL, Scopes: tt.fields.Scopes, AllowedGroups: tt.fields.allowedGroups, - AllowSignup: false, RoleAttributePath: tt.fields.roleAttributePath, RoleAttributeStrict: tt.fields.roleAttributeStrict, AllowAssignGrafanaAdmin: tt.fields.allowAssignGrafanaAdmin, SkipOrgRoleSync: tt.fields.skipOrgRoleSync, + OrgMapping: tt.fields.orgMapping, }, - &setting.Cfg{}, - nil, + cfg, + ProvideOrgRoleMapper(cfg, &orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}), &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) diff --git a/pkg/login/social/connectors/social_base.go b/pkg/login/social/connectors/social_base.go index dd7ddc701a5..ffacee4bf68 100644 --- a/pkg/login/social/connectors/social_base.go +++ b/pkg/login/social/connectors/social_base.go @@ -153,15 +153,6 @@ func (s *SocialBase) extractRoleAndAdminOptional(rawJSON []byte, groups []string return "", false, nil } -func (s *SocialBase) extractRoleAndAdmin(rawJSON []byte, groups []string) (org.RoleType, bool, error) { - role, gAdmin, err := s.extractRoleAndAdminOptional(rawJSON, groups) - if role == "" { - role = s.defaultRole() - } - - return role, gAdmin, err -} - func (s *SocialBase) searchRole(rawJSON []byte, groups []string) (org.RoleType, bool) { role, err := util.SearchJSONForStringAttr(s.info.RoleAttributePath, rawJSON) if err == nil && role != "" { diff --git a/pkg/services/authn/clients/oauth.go b/pkg/services/authn/clients/oauth.go index ec8371aad39..7db433bcbcf 100644 --- a/pkg/services/authn/clients/oauth.go +++ b/pkg/services/authn/clients/oauth.go @@ -168,7 +168,8 @@ func (c *OAuth) Authenticate(ctx context.Context, r *authn.Request) (*authn.Iden // This is required to implement OrgRole mapping for OAuth providers step by step switch c.providerName { - case social.GenericOAuthProviderName, social.GitHubProviderName, social.GitlabProviderName, social.OktaProviderName: + case social.GenericOAuthProviderName, social.GitHubProviderName, + social.GitlabProviderName, social.OktaProviderName, social.GoogleProviderName: // Do nothing, these providers already supports OrgRole mapping default: userInfo.OrgRoles, userInfo.IsGrafanaAdmin, _ = getRoles(c.cfg, func() (org.RoleType, *bool, error) {