diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index fb65099f760..ece945a6895 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -792,8 +792,13 @@ Administrators can increase this if they experience OAuth login state mismatch e ### oauth_skip_org_role_update_sync Skip forced assignment of OrgID `1` or `auto_assign_org_id` for external logins. Default is `false`. -Use this setting to distribute users with external login to multiple organizations. -Otherwise, the users' organization would get reset on every new login, for example, via AzureAD. +Use this setting to allow users with external login to be manually assigned to multiple organizations. + +By default, the users' organization and role is reset on every new login. + +> **Warning**: Currently if no organization role mapping is found for a user, Grafana doesn't update the user's organization role. +> With Grafana 10, if `oauth_skip_org_role_update_sync` option is set to `false`, users with no mapping will be +> reset to the default organization role on every login. [See `auto_assign_org_role` option]({{< relref ".#auto_assign_org_role" >}}). ### api_key_max_seconds_to_live diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/azuread.md b/docs/sources/setup-grafana/configure-security/configure-authentication/azuread.md index 29053aa016d..c34cef88111 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/azuread.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/azuread.md @@ -100,6 +100,22 @@ To enable the Azure AD OAuth2, register your application with Azure AD. 1. Click on **Users and Groups** and add Users/Groups to the Grafana roles by using **Add User**. +### Map roles + +By default, Azure AD authentication will map users to organization roles based on the most privileged application role assigned to the user in AzureAD. + +If no application 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`. +It denies user access if no role or an invalid role is returned. + +**On every login** the user organization role will be reset to match AzureAD's application role and +their organization membership will be reset to the default organization. + +If Azure AD authentication is not intended to sync user roles and organization membership, +`oauth_skip_org_role_update_sync` should be enabled. +See [configure-grafana]({{< relref "../../configure-grafana#oauth_skip_org_role_update_sync" >}}) for more details. + ### Assign server administrator privileges > Available in Grafana v9.2 and later versions. diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth.md b/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth.md index 1141bbf9055..9d7c15b93a3 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth.md @@ -21,9 +21,8 @@ You can configure many different OAuth2 authentication services with Grafana usi - [Set up OAuth2 with Bitbucket](#set-up-oauth2-with-bitbucket) - [Set up OAuth2 with Centrify](#set-up-oauth2-with-centrify) - [Set up OAuth2 with OneLogin](#set-up-oauth2-with-onelogin) - - [JMESPath examples](#jmespath-examples) - - [Role mapping](#role-mapping) - - [Groups mapping](#groups-mapping) + - [Role mapping](#role-mapping) + - [Team synchronization](#team-synchronization) This callback URL must match the full HTTP address that you use in your browser to access Grafana, but with the suffixed path of `/login/generic_oauth`. @@ -80,12 +79,6 @@ Grafana determines a user's email address by querying the OAuth provider until i 1. Query the `/emails` endpoint of the OAuth provider's API (configured with `api_url`), then check for the presence of an email address marked as a primary address. 1. If no email address is found in steps (1-4), then the email address of the user is set to an empty string. -### Roles - -Grafana checks for the presence of a role using the [JMESPath](http://jmespath.org/examples.html) specified via the `role_attribute_path` configuration option. The JMESPath is applied to the `id_token` first. If there is no match, then the UserInfo endpoint specified via the `api_url` configuration option is tried next. The result after evaluation of the `role_attribute_path` JMESPath expression should be a valid Grafana role, for example, `Viewer`, `Editor` or `Admin`. - -For more information, refer to the [JMESPath examples](#jmespath-examples). - ### Groups / Teams Similarly, group mappings are made using [JMESPath](http://jmespath.org/examples.html) with the `groups_attribute_path` configuration option. The `id_token` is attempted first, followed by the UserInfo from the `api_url`. The result of the JMESPath expression should be a string array of groups. @@ -241,14 +234,32 @@ allowed_organizations = allowed_organizations = ``` -## JMESPath examples +## Role Mapping + +Grafana checks for the presence of a role using the [JMESPath](http://jmespath.org/examples.html) specified via the `role_attribute_path` configuration option. The JMESPath is applied to the `id_token` first. If there is no match, then the UserInfo endpoint specified via the `api_url` configuration option is tried next. The result after evaluation of the `role_attribute_path` JMESPath expression should be a valid Grafana role, for example, `Viewer`, `Editor` or `Admin`. + +For more information, refer to the [JMESPath examples](#jmespath-examples). + +> **Warning**: Currently if no organization role mapping is found for a user, Grafana doesn't +> update the user's organization role. This is going to change in Grafana 10. To avoid overriding manually set roles, +> enable the `oauth_skip_org_role_update_sync` option. +> See [configure-grafana]({{< relref "../../configure-grafana#oauth_skip_org_role_update_sync" >}}) for more information. + +On first login, if the`role_attribute_path` property does not return a role, then 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`. +It denies user access if no role or an invalid role is returned. + +> **Warning**: With Grafana 10, **on every login**, if the`role_attribute_path` property does not return a role, +> then the user is assigned the role specified by +> [the `auto_assign_org_role` option]({{< relref "../../configure-grafana#auto_assign_org_role" >}}). + +### JMESPath examples + +#### Map user organization role To ease configuration of a proper JMESPath expression, you can test/evaluate expressions with custom payloads at http://jmespath.org/. -### Role mapping - -If  the`role_attribute_path` property does not return a role, then the user is assigned the `Viewer` role by default. You can disable the role assignment by setting `role_attribute_strict = true`. It denies user access if no role or an invalid role is returned. - **Basic example:** In the following example user will get `Editor` as role when authenticating. The value of the property `role` will be the resulting role if the role is a proper Grafana role, i.e. `Viewer`, `Editor` or `Admin`. @@ -317,7 +328,7 @@ Example: role_attribute_path = contains(info.roles[*], 'admin') && 'GrafanaAdmin' || contains(info.roles[*], 'editor') && 'Editor' || 'Viewer' ``` -### Groups mapping +## Team synchronization > Available in Grafana Enterprise v8.1 and later versions. diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/github.md b/docs/sources/setup-grafana/configure-security/configure-authentication/github.md index eed78dcdac5..c1bcdd4aa09 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/github.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/github.md @@ -109,6 +109,20 @@ For the path lookup, Grafana uses JSON obtained from querying GitHub's API [`/ap The result of evaluating the `role_attribute_path` JMESPath expression must be a valid Grafana role, for example, `Viewer`, `Editor` or `Admin`. For more information about roles and permissions in Grafana, refer to [Roles and permissions]({{< relref "../../../administration/roles-and-permissions/" >}}). +> **Warning**: Currently if no organization role mapping is found for a user, Grafana doesn't +> update the user's organization role. This is going to change in Grafana 10. To avoid overriding manually set roles, +> enable the `oauth_skip_org_role_update_sync` option. +> See [configure-grafana]({{< relref "../../configure-grafana#oauth_skip_org_role_update_sync" >}}) for more information. + +On first login, if the`role_attribute_path` property does not return a role, then 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`. +It denies user access if no role or an invalid role is returned. + +> **Warning**: With Grafana 10, **on every login**, if the`role_attribute_path` property does not return a role, +> then the user is assigned the role specified by +> [the `auto_assign_org_role` option]({{< relref "../../configure-grafana#auto_assign_org_role" >}}). + An example Query could look like the following: ```bash diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab.md b/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab.md index 4ef5a47739c..a503795ddc4 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab.md @@ -129,6 +129,20 @@ You can use GitLab OAuth to map roles. During mapping, Grafana checks for the pr For the path lookup, Grafana uses JSON obtained from querying GitLab's API [`/api/v4/user`](https://docs.gitlab.com/ee/api/users.html#list-current-user-for-normal-users) endpoint and a `groups` key containing all of the user's teams. The result of evaluating the `role_attribute_path` JMESPath expression must be a valid Grafana role, for example, `Viewer`, `Editor` or `Admin`. For more information about roles and permissions in Grafana, refer to [Roles and permissions]({{< relref "../../../administration/roles-and-permissions/" >}}). +> **Warning**: Currently if no organization role mapping is found for a user, Grafana doesn't +> update the user's organization role. This is going to change in Grafana 10. To avoid overriding manually set roles, +> enable the `oauth_skip_org_role_update_sync` option. +> See [configure-grafana]({{< relref "../../configure-grafana#oauth_skip_org_role_update_sync" >}}) for more information. + +On first login, if the`role_attribute_path` property does not return a role, then 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`. +It denies user access if no role or an invalid role is returned. + +> **Warning**: With Grafana 10, **on every login**, if the`role_attribute_path` property does not return a role, +> then the user is assigned the role specified by +> [the `auto_assign_org_role` option]({{< relref "../../configure-grafana#auto_assign_org_role" >}}). + An example Query could look like the following: ```ini diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/okta.md b/docs/sources/setup-grafana/configure-security/configure-authentication/okta.md index 04453993eb5..49202442c0e 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/okta.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/okta.md @@ -75,6 +75,20 @@ Grafana can attempt to do role mapping through Okta OAuth. In order to achieve t Grafana uses JSON obtained from querying the `/userinfo` endpoint for the path lookup. The result after evaluating the `role_attribute_path` JMESPath expression needs to be a valid Grafana role, i.e. `Viewer`, `Editor` or `Admin`. For more information about roles and permissions in Grafana, refer to [Roles and permissions]({{< relref "../../../administration/roles-and-permissions/" >}}). +> **Warning**: Currently if no organization role mapping is found for a user, Grafana doesn't +> update the user's organization role. This is going to change in Grafana 10. To avoid overriding manually set roles, +> enable the `oauth_skip_org_role_update_sync` option. +> See [configure-grafana]({{< relref "../../configure-grafana#oauth_skip_org_role_update_sync" >}}) for more information. + +On first login, if the`role_attribute_path` property does not return a role, then 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`. +It denies user access if no role or an invalid role is returned. + +> **Warning**: With Grafana 10, **on every login**, if the`role_attribute_path` property does not return a role, +> then the user is assigned the role specified by +> [the `auto_assign_org_role` option]({{< relref "../../configure-grafana#auto_assign_org_role" >}}). + Read about how to [add custom claims](https://developer.okta.com/docs/guides/customize-tokens-returned-from-okta/add-custom-claim/) to the user info in Okta. Also, check Generic OAuth page for [JMESPath examples]({{< relref "generic-oauth/#jmespath-examples" >}}). #### Map server administrator privileges diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index 34ff4ee91cb..22014aee433 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -275,7 +275,7 @@ func (hs *HTTPServer) buildExternalUserInfo(token *oauth2.Token, userInfo *socia } if userInfo.Role != "" && !hs.Cfg.OAuthSkipOrgRoleUpdateSync { - rt := org.RoleType(userInfo.Role) + rt := userInfo.Role if rt.IsValid() { // The user will be assigned a role in either the auto-assigned organization or in the default one var orgID int64 diff --git a/pkg/login/social/azuread_oauth.go b/pkg/login/social/azuread_oauth.go index 880652a7e8b..c5c4fb4ca94 100644 --- a/pkg/login/social/azuread_oauth.go +++ b/pkg/login/social/azuread_oauth.go @@ -68,10 +68,11 @@ func (s *SocialAzureAD) UserInfo(client *http.Client, token *oauth2.Token) (*Bas return nil, ErrEmailNotFound } - role, grafanaAdmin := claims.extractRoleAndAdmin(s.autoAssignOrgRole, s.roleAttributeStrict) - if role == "" { + role, grafanaAdmin := s.extractRoleAndAdmin(&claims) + if s.roleAttributeStrict && !role.IsValid() { return nil, ErrInvalidBasicRole } + logger.Debug("AzureAD OAuth: extracted role", "email", email, "role", role) groups, err := extractGroups(client, claims, token) @@ -94,7 +95,7 @@ func (s *SocialAzureAD) UserInfo(client *http.Client, token *oauth2.Token) (*Bas Name: claims.Name, Email: email, Login: email, - Role: string(role), + Role: role, IsGrafanaAdmin: isGrafanaAdmin, Groups: groups, }, nil @@ -127,22 +128,12 @@ func (claims *azureClaims) extractEmail() string { } // extractRoleAndAdmin extracts the role from the claims and returns the role and whether the user is a Grafana admin. -func (claims *azureClaims) extractRoleAndAdmin(autoAssignRole string, strictMode bool) (org.RoleType, bool) { +func (s *SocialAzureAD) extractRoleAndAdmin(claims *azureClaims) (org.RoleType, bool) { if len(claims.Roles) == 0 { - if strictMode { - return org.RoleType(""), false - } - - return org.RoleType(autoAssignRole), false - } - - roleOrder := []org.RoleType{ - RoleGrafanaAdmin, - org.RoleAdmin, - org.RoleEditor, - org.RoleViewer, + return s.defaultRole(false), false } + roleOrder := []org.RoleType{RoleGrafanaAdmin, org.RoleAdmin, org.RoleEditor, org.RoleViewer} for _, role := range roleOrder { if found := hasRole(claims.Roles, role); found { if role == RoleGrafanaAdmin { @@ -153,11 +144,7 @@ func (claims *azureClaims) extractRoleAndAdmin(autoAssignRole string, strictMode } } - if strictMode { - return org.RoleType(""), false - } - - return org.RoleViewer, false + return s.defaultRole(false), false } func hasRole(roles []string, role org.RoleType) bool { diff --git a/pkg/login/social/azuread_oauth_test.go b/pkg/login/social/azuread_oauth_test.go index 60d8f75076e..2d4ee77deea 100644 --- a/pkg/login/social/azuread_oauth_test.go +++ b/pkg/login/social/azuread_oauth_test.go @@ -5,7 +5,6 @@ import ( "encoding/json" "net/http" "net/http/httptest" - "reflect" "strings" "testing" "time" @@ -54,7 +53,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { ID: "1234", }, fields: fields{ - SocialBase: &SocialBase{autoAssignOrgRole: "Viewer"}, + SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Viewer"), }, want: &BasicUserInfo{ Id: "1234", @@ -93,7 +92,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { ID: "1234", }, fields: fields{ - SocialBase: &SocialBase{autoAssignOrgRole: "Viewer"}, + SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Viewer"), }, want: &BasicUserInfo{ Id: "1234", @@ -142,6 +141,9 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { }, { name: "Only other roles", + fields: fields{ + SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Viewer"), + }, claims: &azureClaims{ Email: "me@example.com", PreferredUsername: "", @@ -168,7 +170,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { ID: "1234", }, fields: fields{ - SocialBase: &SocialBase{autoAssignOrgRole: "Editor"}, + SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Editor"), }, want: &BasicUserInfo{ Id: "1234", @@ -217,7 +219,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { }, { name: "Grafana Admin but setting is disabled", - fields: fields{SocialBase: &SocialBase{allowAssignGrafanaAdmin: false}}, + fields: fields{SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: false}, "Editor")}, claims: &azureClaims{ Email: "me@example.com", PreferredUsername: "", @@ -258,8 +260,9 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { }, }, { - name: "Grafana Admin and Editor roles in claim", - fields: fields{SocialBase: &SocialBase{allowAssignGrafanaAdmin: true}}, + name: "Grafana Admin and Editor roles in claim", + fields: fields{SocialBase: newSocialBase("azuread", + &oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: true}, "")}, claims: &azureClaims{ Email: "me@example.com", PreferredUsername: "", @@ -297,7 +300,8 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { name: "Error if user is a member of allowed_groups", fields: fields{ allowedGroups: []string{"foo", "bar"}, - SocialBase: &SocialBase{autoAssignOrgRole: "Viewer"}, + SocialBase: newSocialBase("azuread", + &oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: false}, "Viewer"), }, claims: &azureClaims{ Email: "me@example.com", @@ -443,9 +447,8 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { t.Errorf("UserInfo() error = %v, wantErr %v", err, tt.wantErr) return } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("UserInfo() got = %v, want %v", got, tt.want) - } + + require.EqualValues(t, tt.want, got) }) } } diff --git a/pkg/login/social/generic_oauth.go b/pkg/login/social/generic_oauth.go index 819c99b862a..18e9f97bac9 100644 --- a/pkg/login/social/generic_oauth.go +++ b/pkg/login/social/generic_oauth.go @@ -144,15 +144,11 @@ func (s *SocialGenericOAuth) UserInfo(client *http.Client, token *oauth2.Token) } if userInfo.Role == "" { - role, grafanaAdmin := s.extractRoleAndAdmin(data.rawJSON, []string{}) + role, grafanaAdmin := s.extractRoleAndAdmin(data.rawJSON, []string{}, true) if role != "" { - if s.roleAttributeStrict && !role.IsValid() { - return nil, ErrInvalidBasicRole - } - s.log.Debug("Setting user info role from extracted role") - userInfo.Role = string(role) + userInfo.Role = role if s.allowAssignGrafanaAdmin { userInfo.IsGrafanaAdmin = &grafanaAdmin } @@ -170,6 +166,10 @@ func (s *SocialGenericOAuth) UserInfo(client *http.Client, token *oauth2.Token) } } + if s.roleAttributeStrict && !userInfo.Role.IsValid() { + return nil, ErrInvalidBasicRole + } + if userInfo.Email == "" { var err error userInfo.Email, err = s.FetchPrivateEmail(client) diff --git a/pkg/login/social/generic_oauth_test.go b/pkg/login/social/generic_oauth_test.go index 3c5e50fe5dc..c11d2fde527 100644 --- a/pkg/login/social/generic_oauth_test.go +++ b/pkg/login/social/generic_oauth_test.go @@ -13,6 +13,7 @@ import ( "github.com/go-kit/log/level" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/org" ) func newLogger(name string, lev string) log.Logger { @@ -251,7 +252,7 @@ func TestUserInfoSearchesForEmailAndRole(t *testing.T) { OAuth2Extra interface{} RoleAttributePath string ExpectedEmail string - ExpectedRole string + ExpectedRole org.RoleType ExpectedGrafanaAdmin *bool }{ { diff --git a/pkg/login/social/github_oauth.go b/pkg/login/social/github_oauth.go index 399072f25c0..c36c95b1ea8 100644 --- a/pkg/login/social/github_oauth.go +++ b/pkg/login/social/github_oauth.go @@ -201,7 +201,7 @@ func (s *SocialGithub) UserInfo(client *http.Client, token *oauth2.Token) (*Basi teams := convertToGroupList(teamMemberships) - role, grafanaAdmin := s.extractRoleAndAdmin(response.Body, teams) + role, grafanaAdmin := s.extractRoleAndAdmin(response.Body, teams, true) if s.roleAttributeStrict && !role.IsValid() { return nil, ErrInvalidBasicRole } @@ -216,7 +216,7 @@ func (s *SocialGithub) UserInfo(client *http.Client, token *oauth2.Token) (*Basi Login: data.Login, Id: fmt.Sprintf("%d", data.Id), Email: data.Email, - Role: string(role), + Role: role, Groups: teams, IsGrafanaAdmin: isGrafanaAdmin, } diff --git a/pkg/login/social/github_oauth_test.go b/pkg/login/social/github_oauth_test.go index bd31ba18b8e..2b96ab2e508 100644 --- a/pkg/login/social/github_oauth_test.go +++ b/pkg/login/social/github_oauth_test.go @@ -165,8 +165,8 @@ func TestSocialGitHub_UserInfo(t *testing.T) { Groups: []string{"https://github.com/orgs/github/teams/justice-league", "@github/justice-league"}, }, }, - { - name: "auto assign org role", + { // Case that's going to change with Grafana 10 + name: "No fallback to default org role (will change in Grafana 10)", roleAttributePath: "", userRawJSON: testGHUserJSON, autoAssignOrgRole: "Editor", @@ -176,7 +176,7 @@ func TestSocialGitHub_UserInfo(t *testing.T) { Name: "monalisa octocat", Email: "octocat@github.com", Login: "octocat", - Role: "Editor", + Role: "", Groups: []string{"https://github.com/orgs/github/teams/justice-league", "@github/justice-league"}, }, }, diff --git a/pkg/login/social/gitlab_oauth.go b/pkg/login/social/gitlab_oauth.go index 0cda9623fd1..a9b7eb5d862 100644 --- a/pkg/login/social/gitlab_oauth.go +++ b/pkg/login/social/gitlab_oauth.go @@ -89,7 +89,7 @@ func (s *SocialGitlab) GetGroupsPage(client *http.Client, url string) ([]string, return fullPaths, next } -func (s *SocialGitlab) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) { +func (s *SocialGitlab) UserInfo(client *http.Client, _ *oauth2.Token) (*BasicUserInfo, error) { var data struct { Id int Username string @@ -113,7 +113,7 @@ func (s *SocialGitlab) UserInfo(client *http.Client, token *oauth2.Token) (*Basi groups := s.GetGroups(client) - role, grafanaAdmin := s.extractRoleAndAdmin(response.Body, groups) + role, grafanaAdmin := s.extractRoleAndAdmin(response.Body, groups, true) if s.roleAttributeStrict && !role.IsValid() { return nil, ErrInvalidBasicRole } @@ -129,7 +129,7 @@ func (s *SocialGitlab) UserInfo(client *http.Client, token *oauth2.Token) (*Basi Login: data.Username, Email: data.Email, Groups: groups, - Role: string(role), + Role: role, IsGrafanaAdmin: isGrafanaAdmin, } diff --git a/pkg/login/social/gitlab_oauth_test.go b/pkg/login/social/gitlab_oauth_test.go new file mode 100644 index 00000000000..989523f480d --- /dev/null +++ b/pkg/login/social/gitlab_oauth_test.go @@ -0,0 +1,159 @@ +package social + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/grafana/grafana/pkg/services/org" + "github.com/stretchr/testify/require" +) + +const ( + apiURI = "/api/v4" + userURI = "/api/v4/user" + groupsURI = "/api/v4/groups" + + gitlabAttrPath = `is_admin && 'GrafanaAdmin' || contains(groups[*], 'admins') && 'Admin' || contains(groups[*], 'editors') && 'Editor' || contains(groups[*], 'viewers') && 'Viewer'` + + rootUserRespBody = `{"id":1,"username":"root","name":"Administrator","state":"active","email":"root@example.org","is_admin":true,"namespace_id":1}` + editorUserRespBody = `{"id":3,"username":"gitlab-editor","name":"Gitlab Editor","state":"active","email":"gitlab-editor@example.org","is_admin":false,"namespace_id":1}` + + adminGroup = `{"id":4,"web_url":"http://grafana-gitlab.local/groups/admins","name":"Admins","path":"admins","project_creation_level":"developer","full_name":"Admins","full_path":"admins","created_at":"2022-09-13T19:38:04.891Z"}` + editorGroup = `{"id":5,"web_url":"http://grafana-gitlab.local/groups/editors","name":"Editors","path":"editors","project_creation_level":"developer","full_name":"Editors","full_path":"editors","created_at":"2022-09-13T19:38:15.074Z"}` + viewerGroup = `{"id":6,"web_url":"http://grafana-gitlab.local/groups/viewers","name":"Viewers","path":"viewers","project_creation_level":"developer","full_name":"Viewers","full_path":"viewers","created_at":"2022-09-13T19:38:25.777Z"}` + // serverAdminGroup = `{"id":7,"web_url":"http://grafana-gitlab.local/groups/serveradmins","name":"ServerAdmins","path":"serveradmins","project_creation_level":"developer","full_name":"ServerAdmins","full_path":"serveradmins","created_at":"2022-09-13T19:38:36.227Z"}` +) + +func TestSocialGitlab_UserInfo(t *testing.T) { + provider := SocialGitlab{ + SocialBase: &SocialBase{ + log: newLogger("gitlab_oauth_test", "debug"), + }, + } + + type conf struct { + AllowAssignGrafanaAdmin bool + RoleAttributeStrict bool + AutoAssignOrgRole org.RoleType + } + + tests := []struct { + Name string + Cfg conf + UserRespBody string + GroupsRespBody string + RoleAttributePath string + ExpectedLogin string + ExpectedEmail string + ExpectedRole org.RoleType + ExpectedGrafanaAdmin *bool + ExpectedError error + }{ + { + Name: "Server Admin Allowed", + Cfg: conf{AllowAssignGrafanaAdmin: true}, + UserRespBody: rootUserRespBody, + GroupsRespBody: "[" + strings.Join([]string{adminGroup, editorGroup, viewerGroup}, ",") + "]", + RoleAttributePath: gitlabAttrPath, + ExpectedLogin: "root", + ExpectedEmail: "root@example.org", + ExpectedRole: "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 + Name: "Server Admin Disabled", + Cfg: conf{AllowAssignGrafanaAdmin: false}, + UserRespBody: rootUserRespBody, + GroupsRespBody: "[" + strings.Join([]string{viewerGroup}, ",") + "]", + RoleAttributePath: gitlabAttrPath, + ExpectedLogin: "root", + ExpectedEmail: "root@example.org", + ExpectedRole: "Admin", + }, + { + Name: "Editor", + Cfg: conf{AllowAssignGrafanaAdmin: true}, + UserRespBody: editorUserRespBody, + GroupsRespBody: "[" + strings.Join([]string{viewerGroup, editorGroup}, ",") + "]", + RoleAttributePath: gitlabAttrPath, + ExpectedLogin: "gitlab-editor", + ExpectedEmail: "gitlab-editor@example.org", + ExpectedRole: "Editor", + ExpectedGrafanaAdmin: falseBoolPtr(), + }, + { // Case that's going to change with Grafana 10 + Name: "No fallback to default org role (will change in Grafana 10)", + Cfg: conf{AutoAssignOrgRole: org.RoleViewer}, + UserRespBody: editorUserRespBody, + GroupsRespBody: "[" + strings.Join([]string{}, ",") + "]", + RoleAttributePath: gitlabAttrPath, + ExpectedLogin: "gitlab-editor", + ExpectedEmail: "gitlab-editor@example.org", + ExpectedRole: "", + }, + { + Name: "Strict mode prevents fallback to default", + Cfg: conf{RoleAttributeStrict: true, AutoAssignOrgRole: org.RoleViewer}, + UserRespBody: editorUserRespBody, + GroupsRespBody: "[" + strings.Join([]string{}, ",") + "]", + RoleAttributePath: gitlabAttrPath, + ExpectedError: ErrInvalidBasicRole, + }, + { // Edge case, no match, no strict mode and no fallback => User has an empty role + Name: "Fallback with no default will create a user with an empty role", + Cfg: conf{}, + UserRespBody: editorUserRespBody, + GroupsRespBody: "[" + strings.Join([]string{}, ",") + "]", + RoleAttributePath: gitlabAttrPath, + ExpectedLogin: "gitlab-editor", + ExpectedEmail: "gitlab-editor@example.org", + ExpectedRole: "", + }, + { // Edge case, no attribute path with strict mode => User has an empty role + Name: "Strict mode with no attribute path", + Cfg: conf{RoleAttributeStrict: true, AutoAssignOrgRole: org.RoleViewer}, + UserRespBody: editorUserRespBody, + GroupsRespBody: "[" + strings.Join([]string{editorGroup}, ",") + "]", + RoleAttributePath: "", + ExpectedError: ErrInvalidBasicRole, + }, + } + + for _, test := range tests { + provider.roleAttributePath = test.RoleAttributePath + provider.allowAssignGrafanaAdmin = test.Cfg.AllowAssignGrafanaAdmin + provider.autoAssignOrgRole = string(test.Cfg.AutoAssignOrgRole) + provider.roleAttributeStrict = test.Cfg.RoleAttributeStrict + + 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: + _, err := w.Write([]byte(test.UserRespBody)) + require.NoError(t, err) + case groupsURI: + _, err := w.Write([]byte(test.GroupsRespBody)) + require.NoError(t, err) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + provider.apiUrl = ts.URL + apiURI + actualResult, err := provider.UserInfo(ts.Client(), nil) + if test.ExpectedError != nil { + require.Equal(t, err, test.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) + }) + } +} diff --git a/pkg/login/social/grafana_com_oauth.go b/pkg/login/social/grafana_com_oauth.go index 2757bc6aca0..95f08f29ed7 100644 --- a/pkg/login/social/grafana_com_oauth.go +++ b/pkg/login/social/grafana_com_oauth.go @@ -6,6 +6,7 @@ import ( "net/http" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/org" "golang.org/x/oauth2" ) @@ -44,7 +45,7 @@ func (s *SocialGrafanaCom) IsOrganizationMember(organizations []OrgRecord) bool return false } -func (s *SocialGrafanaCom) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) { +func (s *SocialGrafanaCom) UserInfo(client *http.Client, _ *oauth2.Token) (*BasicUserInfo, error) { var data struct { Id int `json:"id"` Name string `json:"name"` @@ -69,7 +70,7 @@ func (s *SocialGrafanaCom) UserInfo(client *http.Client, token *oauth2.Token) (* Name: data.Name, Login: data.Login, Email: data.Email, - Role: data.Role, + Role: org.RoleType(data.Role), } if !s.IsOrganizationMember(data.Orgs) { diff --git a/pkg/login/social/okta_oauth.go b/pkg/login/social/okta_oauth.go index 770b06858d3..f70c7c8d0db 100644 --- a/pkg/login/social/okta_oauth.go +++ b/pkg/login/social/okta_oauth.go @@ -80,7 +80,7 @@ func (s *SocialOkta) UserInfo(client *http.Client, token *oauth2.Token) (*BasicU return nil, errMissingGroupMembership } - role, grafanaAdmin := s.extractRoleAndAdmin(data.rawJSON, groups) + role, grafanaAdmin := s.extractRoleAndAdmin(data.rawJSON, groups, true) if s.roleAttributeStrict && !role.IsValid() { return nil, ErrInvalidBasicRole } @@ -95,7 +95,7 @@ func (s *SocialOkta) UserInfo(client *http.Client, token *oauth2.Token) (*BasicU Name: claims.Name, Email: email, Login: email, - Role: string(role), + Role: role, IsGrafanaAdmin: isGrafanaAdmin, Groups: groups, }, nil diff --git a/pkg/login/social/social.go b/pkg/login/social/social.go index 3b710e9cc07..83878253825 100644 --- a/pkg/login/social/social.go +++ b/pkg/login/social/social.go @@ -228,7 +228,7 @@ type BasicUserInfo struct { Name string Email string Login string - Role string + Role org.RoleType IsGrafanaAdmin *bool // nil will avoid overriding user's set server admin setting Groups []string } @@ -312,13 +312,9 @@ type groupStruct struct { Groups []string `json:"groups"` } -func (s *SocialBase) extractRoleAndAdmin(rawJSON []byte, groups []string) (org.RoleType, bool) { +func (s *SocialBase) extractRoleAndAdmin(rawJSON []byte, groups []string, legacy bool) (org.RoleType, bool) { if s.roleAttributePath == "" { - if s.autoAssignOrgRole != "" { - return org.RoleType(s.autoAssignOrgRole), false - } - - return "", false + return s.defaultRole(legacy), false } role, err := s.searchJSONForStringAttr(s.roleAttributePath, rawJSON) @@ -333,7 +329,29 @@ func (s *SocialBase) extractRoleAndAdmin(rawJSON []byte, groups []string) (org.R } } - return "", false + return s.defaultRole(legacy), false +} + +// defaultRole returns the default role for the user based on the autoAssignOrgRole setting +// if legacy is enabled "" is returned indicating the previous role assignment is used. +func (s *SocialBase) defaultRole(legacy bool) org.RoleType { + if s.roleAttributeStrict { + s.log.Debug("RoleAttributeStrict is set, returning no role.") + return "" + } + + if s.autoAssignOrgRole != "" && !legacy { + s.log.Debug("No role found, returning default.") + return org.RoleType(s.autoAssignOrgRole) + } + + if legacy { + s.log.Warn("No valid role found. Skipping role sync. " + + "In Grafana 10, this will result in the user being assigned the default role and overriding manual assignment. " + + "If role sync is not desired, set oauth_skip_org_role_update_sync to false") + } + + return "" } // match grafana admin role and translate to org role and bool.