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>
pull/87758/head
Karl Persson 1 year ago committed by GitHub
parent 1ceb9e8e9d
commit f28905f8c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      conf/defaults.ini
  2. 2
      conf/sample.ini
  3. 18
      docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md
  4. 70
      pkg/login/social/connectors/gitlab_oauth.go
  5. 123
      pkg/login/social/connectors/gitlab_oauth_test.go
  6. 4
      pkg/services/authn/clients/oauth.go

@ -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

@ -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

@ -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 `<ExternalGitlabGroupName>:<OrgIdOrName>:<Role>` 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. | |

@ -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
}

@ -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,
},
},
}

@ -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

Loading…
Cancel
Save