From f28905f8c41e0ffbcc163becccbfaef764a12a05 Mon Sep 17 00:00:00 2001 From: Karl Persson Date: Wed, 5 Jun 2024 16:15:53 +0200 Subject: [PATCH] Auth: Add org to role mappings support to Gitlab integration (#88751) * Conf: Add org_mapping and org_attribute_path to github and gitlab conf * Gitlab: Implement org role mapping * Update docs --------- Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> --- conf/defaults.ini | 2 + conf/sample.ini | 2 + .../configure-authentication/gitlab/index.md | 18 ++- pkg/login/social/connectors/gitlab_oauth.go | 70 ++++------ .../social/connectors/gitlab_oauth_test.go | 123 +++++++++++------- pkg/services/authn/clients/oauth.go | 4 +- 6 files changed, 129 insertions(+), 90 deletions(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index 7d3d74f286e..639c21b97e5 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -647,6 +647,7 @@ team_ids = allowed_organizations = role_attribute_path = role_attribute_strict = false +org_mapping = allow_assign_grafana_admin = false skip_org_role_sync = false tls_skip_verify_insecure = false @@ -674,6 +675,7 @@ allowed_domains = allowed_groups = role_attribute_path = role_attribute_strict = false +org_mapping = allow_assign_grafana_admin = false skip_org_role_sync = false tls_skip_verify_insecure = false diff --git a/conf/sample.ini b/conf/sample.ini index 779800080cf..86127d55eed 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -608,6 +608,7 @@ ;allowed_organizations = ;role_attribute_path = ;role_attribute_strict = false +;org_mapping = ;allow_assign_grafana_admin = false ;skip_org_role_sync = false @@ -629,6 +630,7 @@ ;allowed_groups = ;role_attribute_path = ;role_attribute_strict = false +;org_mapping = ;allow_assign_grafana_admin = false ;skip_org_role_sync = false ;tls_skip_verify_insecure = false diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md index 9edc19c47b0..3084f5b24c2 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md @@ -149,8 +149,7 @@ To map the server administrator role, use the `allow_assign_grafana_admin` confi Refer to [configuration options]({{< relref "#configuration-options" >}}) for more information. 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. @@ -158,6 +157,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 GitLab integration uses the external users' groups in the `org_mapping` configuration to map organizations and roles based on their GitLab 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 GitLab groups: `groupd-1` and `group-2`. + +Config: + +```ini +org_mapping = group-1:org_foo:Viewer groupd-1: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. @@ -251,6 +264,7 @@ The table below describes all GitLab OAuth configuration options. Like any other | `auto_login` | No | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` | | `role_attribute_path` | No | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana role lookup. Grafana will first evaluate the expression using the GitLab OAuth token. If no role is found, Grafana creates a JSON data with `groups` key that maps to groups obtained from GitLab's `/oauth/userinfo` endpoint, and evaluates the expression using this data. Finally, if a valid role is still not found, the expression is evaluated against the user information retrieved from `api_url/users` endpoint and groups retrieved from `api_url/groups` endpoint. The result of the evaluation should be a valid Grafana role (`None`, `Viewer`, `Editor`, `Admin` or `GrafanaAdmin`). For more information on user role mapping, refer to [Configure role mapping]({{< relref "#configure-role-mapping" >}}). | | | `role_attribute_strict` | No | Set to `true` to deny user login if the Grafana role cannot be extracted using `role_attribute_path`. For more information on user role mapping, refer to [Configure role mapping]({{< relref "#configure-role-mapping" >}}). | `false` | +| `org_mapping` | No | List of comma- or space-separated `::` mappings. Value can be `*` meaning "All users". Role is optional and can have the following values: `None`, `Viewer`, `Editor` or `Admin`. For more information on external organization to role mapping, refer to [Org roles mapping example](#org-roles-mapping-example). | | | `allow_assign_grafana_admin` | No | Set to `true` to enable automatic sync of the Grafana server administrator role. If this option is set to `true` and the result of evaluating `role_attribute_path` for a user is `GrafanaAdmin`, Grafana grants the user the server administrator privileges and organization administrator role. If this option is set to `false` and the result of evaluating `role_attribute_path` for a user is `GrafanaAdmin`, Grafana grants the user only organization administrator role. For more information on user role mapping, refer to [Configure role mapping]({{< relref "#configure-role-mapping" >}}). | `false` | | `skip_org_role_sync` | No | Set to `true` to stop automatically syncing user roles. | `false` | | `allowed_domains` | No | List of comma or space-separated domains. User must belong to at least one domain to log in. | | diff --git a/pkg/login/social/connectors/gitlab_oauth.go b/pkg/login/social/connectors/gitlab_oauth.go index 4d94e0a1f8e..f934d01a27c 100644 --- a/pkg/login/social/connectors/gitlab_oauth.go +++ b/pkg/login/social/connectors/gitlab_oauth.go @@ -12,7 +12,6 @@ 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/ssosettings" @@ -49,9 +48,8 @@ type userData struct { Name string `json:"name"` Groups []string `json:"groups_direct"` - EmailVerified bool `json:"email_verified"` - Role roletype.RoleType `json:"-"` - IsGrafanaAdmin *bool `json:"-"` + EmailVerified bool `json:"email_verified"` + raw []byte } func NewGitLabProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper *OrgRoleMapper, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGitlab { @@ -195,23 +193,37 @@ func (s *SocialGitlab) UserInfo(ctx context.Context, client *http.Client, token } userInfo := &social.BasicUserInfo{ - Id: data.ID, - Name: data.Name, - Login: data.Login, - Email: data.Email, - Groups: data.Groups, - Role: data.Role, - IsGrafanaAdmin: data.IsGrafanaAdmin, - } - - if !s.isGroupMember(data.Groups) { - return nil, errMissingGroupMembership + Id: data.ID, + Name: data.Name, + Login: data.Login, + Email: data.Email, + Groups: data.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 { + directlyMappedRole, grafanaAdmin, err := s.extractRoleAndAdminOptional(data.raw, userInfo.Groups) + if err != nil { + s.log.Warn("Failed to extract role", "err", err) + } + + if s.info.AllowAssignGrafanaAdmin { + userInfo.IsGrafanaAdmin = &grafanaAdmin + } + + 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") + } + } + + if !s.isGroupMember(data.Groups) { + return nil, errMissingGroupMembership + } + return userInfo, nil } @@ -241,20 +253,7 @@ func (s *SocialGitlab) extractFromAPI(ctx context.Context, client *http.Client, Email: apiResp.Email, Name: apiResp.Name, Groups: s.getGroups(ctx, client), - } - - if !s.info.SkipOrgRoleSync { - var grafanaAdmin bool - role, grafanaAdmin, err := s.extractRoleAndAdmin(response.Body, idData.Groups) - if err != nil { - return nil, err - } - - if s.info.AllowAssignGrafanaAdmin { - idData.IsGrafanaAdmin = &grafanaAdmin - } - - idData.Role = role + raw: response.Body, } if s.cfg.Env == setting.Dev { @@ -300,19 +299,6 @@ func (s *SocialGitlab) extractFromToken(ctx context.Context, client *http.Client data.Groups = userInfo.Groups } - if !s.info.SkipOrgRoleSync { - role, grafanaAdmin, errRole := s.extractRoleAndAdmin(rawJSON, data.Groups) - if errRole != nil { - return nil, errRole - } - - if s.info.AllowAssignGrafanaAdmin { - data.IsGrafanaAdmin = &grafanaAdmin - } - - data.Role = role - } - s.log.Debug("Resolved user data", "data", fmt.Sprintf("%+v", data)) return &data, nil } diff --git a/pkg/login/social/connectors/gitlab_oauth_test.go b/pkg/login/social/connectors/gitlab_oauth_test.go index 75b0f339bdc..3f638d1005e 100644 --- a/pkg/login/social/connectors/gitlab_oauth_test.go +++ b/pkg/login/social/connectors/gitlab_oauth_test.go @@ -19,6 +19,7 @@ import ( "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" @@ -45,13 +46,12 @@ const ( func TestSocialGitlab_UserInfo(t *testing.T) { var nilPointer *bool - provider := NewGitLabProvider(&social.OAuthInfo{SkipOrgRoleSync: false}, &setting.Cfg{}, nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) - type conf struct { AllowAssignGrafanaAdmin bool RoleAttributeStrict bool AutoAssignOrgRole org.RoleType SkipOrgRoleSync bool + OrgMapping []string } tests := []struct { @@ -63,7 +63,7 @@ func TestSocialGitlab_UserInfo(t *testing.T) { RoleAttributePath string ExpectedLogin string ExpectedEmail string - ExpectedRole org.RoleType + ExpectedRoles map[int64]org.RoleType ExpectedGrafanaAdmin *bool ExpectedError error }{ @@ -81,7 +81,7 @@ func TestSocialGitlab_UserInfo(t *testing.T) { RoleAttributePath: gitlabAttrPath, ExpectedLogin: "root", ExpectedEmail: "root@example.org", - ExpectedRole: "Admin", + ExpectedRoles: map[int64]org.RoleType{1: "Admin"}, ExpectedGrafanaAdmin: trueBoolPtr(), }, { // Edge case, user in Viewer Group, Server Admin disabled but attribute path contains a condition for Server Admin => User has the Admin role @@ -98,7 +98,7 @@ func TestSocialGitlab_UserInfo(t *testing.T) { RoleAttributePath: gitlabAttrPath, ExpectedLogin: "root", ExpectedEmail: "root@example.org", - ExpectedRole: "Admin", + ExpectedRoles: map[int64]org.RoleType{1: "Admin"}, ExpectedGrafanaAdmin: nil, }, { @@ -109,7 +109,7 @@ func TestSocialGitlab_UserInfo(t *testing.T) { RoleAttributePath: gitlabAttrPath, ExpectedLogin: "gitlab-editor", ExpectedEmail: "gitlab-editor@example.org", - ExpectedRole: "Editor", + ExpectedRoles: map[int64]org.RoleType{1: "Editor"}, ExpectedGrafanaAdmin: falseBoolPtr(), GroupHeaders: map[string]string{ // All headers omitted to test that the provider does not make a second request @@ -123,7 +123,7 @@ func TestSocialGitlab_UserInfo(t *testing.T) { RoleAttributePath: gitlabAttrPath, ExpectedLogin: "gitlab-editor", ExpectedEmail: "gitlab-editor@example.org", - ExpectedRole: "", + ExpectedRoles: nil, ExpectedGrafanaAdmin: nilPointer, }, { // Fallback to autoAssignOrgRole @@ -134,7 +134,7 @@ func TestSocialGitlab_UserInfo(t *testing.T) { RoleAttributePath: gitlabAttrPath, ExpectedLogin: "gitlab-editor", ExpectedEmail: "gitlab-editor@example.org", - ExpectedRole: "Admin", + ExpectedRoles: map[int64]org.RoleType{1: "Admin"}, }, { Name: "Strict mode prevents fallback to default", @@ -146,13 +146,13 @@ func TestSocialGitlab_UserInfo(t *testing.T) { }, { // Edge case, no match, no strict mode and no fallback => User has the Viewer role (hard coded) Name: "Fallback with no default will create a user with a default role", - Cfg: conf{}, + Cfg: conf{AutoAssignOrgRole: org.RoleViewer}, UserRespBody: editorUserRespBody, GroupsRespBody: "[]", RoleAttributePath: gitlabAttrPath, ExpectedLogin: "gitlab-editor", ExpectedEmail: "gitlab-editor@example.org", - ExpectedRole: "Viewer", + ExpectedRoles: map[int64]org.RoleType{1: "Viewer"}, }, { // Edge case, no attribute path with strict mode => Error Name: "Strict mode with no attribute path", @@ -160,49 +160,88 @@ func TestSocialGitlab_UserInfo(t *testing.T) { UserRespBody: editorUserRespBody, GroupsRespBody: "[" + strings.Join([]string{editorGroup}, ",") + "]", RoleAttributePath: "", - ExpectedError: errRoleAttributePathNotSet, + ExpectedError: errRoleAttributeStrictViolation, + }, + { + Name: "Should map role when only org mapping is set", + Cfg: conf{OrgMapping: []string{"editors:Org4:Editor", "*:Org5:Viewer"}}, + UserRespBody: editorUserRespBody, + GroupsRespBody: "[" + strings.Join([]string{editorGroup}, ",") + "]", + ExpectedLogin: "gitlab-editor", + ExpectedEmail: "gitlab-editor@example.org", + ExpectedRoles: map[int64]org.RoleType{4: "Editor", 5: "Viewer"}, + }, + { + Name: "Should map role when only org mapping is set and role attribute strict is enabled", + Cfg: conf{OrgMapping: []string{"editors:Org4:Editor", "*:Org5:Viewer"}, RoleAttributeStrict: true}, + UserRespBody: editorUserRespBody, + GroupsRespBody: "[" + strings.Join([]string{editorGroup}, ",") + "]", + ExpectedLogin: "gitlab-editor", + ExpectedEmail: "gitlab-editor@example.org", + ExpectedRoles: map[int64]org.RoleType{4: "Editor", 5: "Viewer"}, + }, + { + Name: "Should return error when neither role attribute path nor org mapping evaluates to a role and role attribute strict is enabled", + Cfg: conf{RoleAttributeStrict: true, OrgMapping: []string{"other:Org4:Editor"}}, + UserRespBody: editorUserRespBody, + GroupsRespBody: "[" + strings.Join([]string{editorGroup}, ",") + "]", + ExpectedError: errRoleAttributeStrictViolation, + }, + { + Name: "should return error when neither role attribute path nor org mapping is set and role attribute strict is enabled", + Cfg: conf{RoleAttributeStrict: true}, + UserRespBody: editorUserRespBody, + GroupsRespBody: "[" + strings.Join([]string{editorGroup}, ",") + "]", + ExpectedError: errRoleAttributeStrictViolation, }, } - for _, test := range tests { - provider.info.RoleAttributePath = test.RoleAttributePath - provider.info.AllowAssignGrafanaAdmin = test.Cfg.AllowAssignGrafanaAdmin - provider.cfg.AutoAssignOrgRole = string(test.Cfg.AutoAssignOrgRole) - provider.info.RoleAttributeStrict = test.Cfg.RoleAttributeStrict - provider.info.SkipOrgRoleSync = test.Cfg.SkipOrgRoleSync + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + cfg := setting.NewCfg() + cfg.AutoAssignOrgRole = string(tt.Cfg.AutoAssignOrgRole) + + orgMapper := ProvideOrgRoleMapper(cfg, &orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}) + provider := NewGitLabProvider(&social.OAuthInfo{ + RoleAttributePath: tt.RoleAttributePath, + RoleAttributeStrict: tt.Cfg.RoleAttributeStrict, + AllowAssignGrafanaAdmin: tt.Cfg.AllowAssignGrafanaAdmin, + SkipOrgRoleSync: tt.Cfg.SkipOrgRoleSync, + OrgMapping: tt.Cfg.OrgMapping, + // OrgAttributePath: "", + }, cfg, orgMapper, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) - t.Run(test.Name, func(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") switch r.RequestURI { case userURI: w.WriteHeader(http.StatusOK) - _, err := w.Write([]byte(test.UserRespBody)) + _, err := w.Write([]byte(tt.UserRespBody)) require.NoError(t, err) case groupsURI: w.WriteHeader(http.StatusOK) - for k, v := range test.GroupHeaders { + for k, v := range tt.GroupHeaders { w.Header().Set(k, v) } - _, err := w.Write([]byte(test.GroupsRespBody)) + _, err := w.Write([]byte(tt.GroupsRespBody)) require.NoError(t, err) default: + w.WriteHeader(http.StatusOK) require.Fail(t, "unexpected request URI: "+r.RequestURI) } })) provider.info.ApiUrl = ts.URL + apiURI actualResult, err := provider.UserInfo(context.Background(), ts.Client(), &oauth2.Token{}) - if test.ExpectedError != nil { - require.ErrorIs(t, err, test.ExpectedError) + if tt.ExpectedError != nil { + require.ErrorIs(t, err, tt.ExpectedError) return } require.NoError(t, err) - require.Equal(t, test.ExpectedEmail, actualResult.Email) - require.Equal(t, test.ExpectedLogin, actualResult.Login) - require.Equal(t, test.ExpectedRole, actualResult.Role) - require.Equal(t, test.ExpectedGrafanaAdmin, actualResult.IsGrafanaAdmin) + require.Equal(t, tt.ExpectedEmail, actualResult.Email) + require.Equal(t, tt.ExpectedLogin, actualResult.Login) + require.Equal(t, tt.ExpectedRoles, actualResult.OrgRoles) + require.Equal(t, tt.ExpectedGrafanaAdmin, actualResult.IsGrafanaAdmin) }) } } @@ -267,14 +306,12 @@ func TestSocialGitlab_extractFromToken(t *testing.T) { }, }, wantUser: &userData{ - ID: "12345678", - Login: "johndoe", - Email: "johndoe@example.com", - Name: "John Doe", - Groups: []string{"admins", "editors", "viewers"}, - EmailVerified: true, - Role: "Viewer", - IsGrafanaAdmin: nil, + ID: "12345678", + Login: "johndoe", + Email: "johndoe@example.com", + Name: "John Doe", + Groups: []string{"admins", "editors", "viewers"}, + EmailVerified: true, }, }, { @@ -334,14 +371,12 @@ func TestSocialGitlab_extractFromToken(t *testing.T) { }, }, wantUser: &userData{ - ID: "12345678", - Login: "johndoe", - Email: "johndoe@example.com", - Name: "John Doe", - Groups: []string{"admins"}, - EmailVerified: true, - Role: "Viewer", - IsGrafanaAdmin: nil, + ID: "12345678", + Login: "johndoe", + Email: "johndoe@example.com", + Name: "John Doe", + Groups: []string{"admins"}, + EmailVerified: true, }, }, } diff --git a/pkg/services/authn/clients/oauth.go b/pkg/services/authn/clients/oauth.go index 312e80c577d..9ceb9bb55ee 100644 --- a/pkg/services/authn/clients/oauth.go +++ b/pkg/services/authn/clients/oauth.go @@ -168,8 +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: - // Do nothing, GenericOAuthProvider and GitHub already supports OrgRole mapping + case social.GenericOAuthProviderName, social.GitHubProviderName, social.GitlabProviderName: + // Do nothing, these providers already supports OrgRole mapping default: userInfo.OrgRoles, userInfo.IsGrafanaAdmin, _ = getRoles(c.cfg, func() (org.RoleType, *bool, error) { return userInfo.Role, userInfo.IsGrafanaAdmin, nil