Auth: Restore legacy behavior and add deprecation notice for empty org role in oauth (#55118)

* Auth: Add deprecation notice for empty org role

Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>

* fix recasts

* fix azure tests missing logger

* Adding test to gitlab oauth

* Covering more cases

* Cover more options

* Add role attributestrict check fail

* Adding one more edge case test

* Using legacy for gitlab

* Yet another edge case YAEC

* Reverting github oauth to legacy

Co-authored-by: Jguer <joao.guerreiro@grafana.com>

* Not using token

Co-authored-by: Jguer <joao.guerreiro@grafana.com>

* Nit.

* Adding warning in docs

Co-authored-by: Jguer <joao.guerreiro@grafana.com>

* add warning to generic oauth

Co-authored-by: Jguer <joao.guerreiro@grafana.com>

* Be more precise

Co-authored-by: Jguer <joao.guerreiro@grafana.com>

* Adding warning to github oauth

Co-authored-by: Jguer <joao.guerreiro@grafana.com>

* Adding warning to gitlab oauth

Co-authored-by: Jguer <joao.guerreiro@grafana.com>

* Adding warning to okta oauth

Co-authored-by: Jguer <joao.guerreiro@grafana.com>

* Add docs about mapping to AzureAD

Co-authored-by: Jguer <joao.guerreiro@grafana.com>

* Clarify oauth_skip_org_role_update_sync

Co-authored-by: Jguer <joao.guerreiro@grafana.com>

* Nit.

* Nit on Azure AD

Co-authored-by: Jguer <joao.guerreiro@grafana.com>

* Reorder docs index

Co-authored-by: Jguer <joao.guerreiro@grafana.com>

* Fix typo

Co-authored-by: Jguer <joao.guerreiro@grafana.com>

Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>
Co-authored-by: gamab <gabi.mabs@gmail.com>
This commit is contained in:
Jo
2022-09-15 17:35:59 +02:00
committed by GitHub
parent f1e8a528d1
commit 00e7324bf6
18 changed files with 320 additions and 77 deletions

View File

@ -792,8 +792,13 @@ Administrators can increase this if they experience OAuth login state mismatch e
### oauth_skip_org_role_update_sync ### oauth_skip_org_role_update_sync
Skip forced assignment of OrgID `1` or `auto_assign_org_id` for external logins. Default is `false`. 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. Use this setting to allow users with external login to be manually assigned to multiple organizations.
Otherwise, the users' organization would get reset on every new login, for example, via AzureAD.
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 ### api_key_max_seconds_to_live

View File

@ -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**. 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 ### Assign server administrator privileges
> Available in Grafana v9.2 and later versions. > Available in Grafana v9.2 and later versions.

View File

@ -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 Bitbucket](#set-up-oauth2-with-bitbucket)
- [Set up OAuth2 with Centrify](#set-up-oauth2-with-centrify) - [Set up OAuth2 with Centrify](#set-up-oauth2-with-centrify)
- [Set up OAuth2 with OneLogin](#set-up-oauth2-with-onelogin) - [Set up OAuth2 with OneLogin](#set-up-oauth2-with-onelogin)
- [JMESPath examples](#jmespath-examples) - [Role mapping](#role-mapping)
- [Role mapping](#role-mapping) - [Team synchronization](#team-synchronization)
- [Groups mapping](#groups-mapping)
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`. 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. 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. 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 ### 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. 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 = 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/. 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:** **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`. 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' 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. > Available in Grafana Enterprise v8.1 and later versions.

View File

@ -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/" >}}). 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: An example Query could look like the following:
```bash ```bash

View File

@ -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/" >}}). 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: An example Query could look like the following:
```ini ```ini

View File

@ -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/" >}}). 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" >}}). 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 #### Map server administrator privileges

View File

@ -275,7 +275,7 @@ func (hs *HTTPServer) buildExternalUserInfo(token *oauth2.Token, userInfo *socia
} }
if userInfo.Role != "" && !hs.Cfg.OAuthSkipOrgRoleUpdateSync { if userInfo.Role != "" && !hs.Cfg.OAuthSkipOrgRoleUpdateSync {
rt := org.RoleType(userInfo.Role) rt := userInfo.Role
if rt.IsValid() { if rt.IsValid() {
// The user will be assigned a role in either the auto-assigned organization or in the default one // The user will be assigned a role in either the auto-assigned organization or in the default one
var orgID int64 var orgID int64

View File

@ -68,10 +68,11 @@ func (s *SocialAzureAD) UserInfo(client *http.Client, token *oauth2.Token) (*Bas
return nil, ErrEmailNotFound return nil, ErrEmailNotFound
} }
role, grafanaAdmin := claims.extractRoleAndAdmin(s.autoAssignOrgRole, s.roleAttributeStrict) role, grafanaAdmin := s.extractRoleAndAdmin(&claims)
if role == "" { if s.roleAttributeStrict && !role.IsValid() {
return nil, ErrInvalidBasicRole return nil, ErrInvalidBasicRole
} }
logger.Debug("AzureAD OAuth: extracted role", "email", email, "role", role) logger.Debug("AzureAD OAuth: extracted role", "email", email, "role", role)
groups, err := extractGroups(client, claims, token) groups, err := extractGroups(client, claims, token)
@ -94,7 +95,7 @@ func (s *SocialAzureAD) UserInfo(client *http.Client, token *oauth2.Token) (*Bas
Name: claims.Name, Name: claims.Name,
Email: email, Email: email,
Login: email, Login: email,
Role: string(role), Role: role,
IsGrafanaAdmin: isGrafanaAdmin, IsGrafanaAdmin: isGrafanaAdmin,
Groups: groups, Groups: groups,
}, nil }, 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. // 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 len(claims.Roles) == 0 {
if strictMode { return s.defaultRole(false), false
return org.RoleType(""), false
}
return org.RoleType(autoAssignRole), false
}
roleOrder := []org.RoleType{
RoleGrafanaAdmin,
org.RoleAdmin,
org.RoleEditor,
org.RoleViewer,
} }
roleOrder := []org.RoleType{RoleGrafanaAdmin, org.RoleAdmin, org.RoleEditor, org.RoleViewer}
for _, role := range roleOrder { for _, role := range roleOrder {
if found := hasRole(claims.Roles, role); found { if found := hasRole(claims.Roles, role); found {
if role == RoleGrafanaAdmin { if role == RoleGrafanaAdmin {
@ -153,11 +144,7 @@ func (claims *azureClaims) extractRoleAndAdmin(autoAssignRole string, strictMode
} }
} }
if strictMode { return s.defaultRole(false), false
return org.RoleType(""), false
}
return org.RoleViewer, false
} }
func hasRole(roles []string, role org.RoleType) bool { func hasRole(roles []string, role org.RoleType) bool {

View File

@ -5,7 +5,6 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"reflect"
"strings" "strings"
"testing" "testing"
"time" "time"
@ -54,7 +53,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) {
ID: "1234", ID: "1234",
}, },
fields: fields{ fields: fields{
SocialBase: &SocialBase{autoAssignOrgRole: "Viewer"}, SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Viewer"),
}, },
want: &BasicUserInfo{ want: &BasicUserInfo{
Id: "1234", Id: "1234",
@ -93,7 +92,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) {
ID: "1234", ID: "1234",
}, },
fields: fields{ fields: fields{
SocialBase: &SocialBase{autoAssignOrgRole: "Viewer"}, SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Viewer"),
}, },
want: &BasicUserInfo{ want: &BasicUserInfo{
Id: "1234", Id: "1234",
@ -142,6 +141,9 @@ func TestSocialAzureAD_UserInfo(t *testing.T) {
}, },
{ {
name: "Only other roles", name: "Only other roles",
fields: fields{
SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Viewer"),
},
claims: &azureClaims{ claims: &azureClaims{
Email: "me@example.com", Email: "me@example.com",
PreferredUsername: "", PreferredUsername: "",
@ -168,7 +170,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) {
ID: "1234", ID: "1234",
}, },
fields: fields{ fields: fields{
SocialBase: &SocialBase{autoAssignOrgRole: "Editor"}, SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Editor"),
}, },
want: &BasicUserInfo{ want: &BasicUserInfo{
Id: "1234", Id: "1234",
@ -217,7 +219,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) {
}, },
{ {
name: "Grafana Admin but setting is disabled", 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{ claims: &azureClaims{
Email: "me@example.com", Email: "me@example.com",
PreferredUsername: "", PreferredUsername: "",
@ -258,8 +260,9 @@ func TestSocialAzureAD_UserInfo(t *testing.T) {
}, },
}, },
{ {
name: "Grafana Admin and Editor roles in claim", name: "Grafana Admin and Editor roles in claim",
fields: fields{SocialBase: &SocialBase{allowAssignGrafanaAdmin: true}}, fields: fields{SocialBase: newSocialBase("azuread",
&oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: true}, "")},
claims: &azureClaims{ claims: &azureClaims{
Email: "me@example.com", Email: "me@example.com",
PreferredUsername: "", PreferredUsername: "",
@ -297,7 +300,8 @@ func TestSocialAzureAD_UserInfo(t *testing.T) {
name: "Error if user is a member of allowed_groups", name: "Error if user is a member of allowed_groups",
fields: fields{ fields: fields{
allowedGroups: []string{"foo", "bar"}, allowedGroups: []string{"foo", "bar"},
SocialBase: &SocialBase{autoAssignOrgRole: "Viewer"}, SocialBase: newSocialBase("azuread",
&oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: false}, "Viewer"),
}, },
claims: &azureClaims{ claims: &azureClaims{
Email: "me@example.com", Email: "me@example.com",
@ -443,9 +447,8 @@ func TestSocialAzureAD_UserInfo(t *testing.T) {
t.Errorf("UserInfo() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("UserInfo() error = %v, wantErr %v", err, tt.wantErr)
return return
} }
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("UserInfo() got = %v, want %v", got, tt.want) require.EqualValues(t, tt.want, got)
}
}) })
} }
} }

View File

@ -144,15 +144,11 @@ func (s *SocialGenericOAuth) UserInfo(client *http.Client, token *oauth2.Token)
} }
if userInfo.Role == "" { if userInfo.Role == "" {
role, grafanaAdmin := s.extractRoleAndAdmin(data.rawJSON, []string{}) role, grafanaAdmin := s.extractRoleAndAdmin(data.rawJSON, []string{}, true)
if role != "" { if role != "" {
if s.roleAttributeStrict && !role.IsValid() {
return nil, ErrInvalidBasicRole
}
s.log.Debug("Setting user info role from extracted role") s.log.Debug("Setting user info role from extracted role")
userInfo.Role = string(role) userInfo.Role = role
if s.allowAssignGrafanaAdmin { if s.allowAssignGrafanaAdmin {
userInfo.IsGrafanaAdmin = &grafanaAdmin 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 == "" { if userInfo.Email == "" {
var err error var err error
userInfo.Email, err = s.FetchPrivateEmail(client) userInfo.Email, err = s.FetchPrivateEmail(client)

View File

@ -13,6 +13,7 @@ import (
"github.com/go-kit/log/level" "github.com/go-kit/log/level"
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/org"
) )
func newLogger(name string, lev string) log.Logger { func newLogger(name string, lev string) log.Logger {
@ -251,7 +252,7 @@ func TestUserInfoSearchesForEmailAndRole(t *testing.T) {
OAuth2Extra interface{} OAuth2Extra interface{}
RoleAttributePath string RoleAttributePath string
ExpectedEmail string ExpectedEmail string
ExpectedRole string ExpectedRole org.RoleType
ExpectedGrafanaAdmin *bool ExpectedGrafanaAdmin *bool
}{ }{
{ {

View File

@ -201,7 +201,7 @@ func (s *SocialGithub) UserInfo(client *http.Client, token *oauth2.Token) (*Basi
teams := convertToGroupList(teamMemberships) teams := convertToGroupList(teamMemberships)
role, grafanaAdmin := s.extractRoleAndAdmin(response.Body, teams) role, grafanaAdmin := s.extractRoleAndAdmin(response.Body, teams, true)
if s.roleAttributeStrict && !role.IsValid() { if s.roleAttributeStrict && !role.IsValid() {
return nil, ErrInvalidBasicRole return nil, ErrInvalidBasicRole
} }
@ -216,7 +216,7 @@ func (s *SocialGithub) UserInfo(client *http.Client, token *oauth2.Token) (*Basi
Login: data.Login, Login: data.Login,
Id: fmt.Sprintf("%d", data.Id), Id: fmt.Sprintf("%d", data.Id),
Email: data.Email, Email: data.Email,
Role: string(role), Role: role,
Groups: teams, Groups: teams,
IsGrafanaAdmin: isGrafanaAdmin, IsGrafanaAdmin: isGrafanaAdmin,
} }

View File

@ -165,8 +165,8 @@ func TestSocialGitHub_UserInfo(t *testing.T) {
Groups: []string{"https://github.com/orgs/github/teams/justice-league", "@github/justice-league"}, Groups: []string{"https://github.com/orgs/github/teams/justice-league", "@github/justice-league"},
}, },
}, },
{ { // Case that's going to change with Grafana 10
name: "auto assign org role", name: "No fallback to default org role (will change in Grafana 10)",
roleAttributePath: "", roleAttributePath: "",
userRawJSON: testGHUserJSON, userRawJSON: testGHUserJSON,
autoAssignOrgRole: "Editor", autoAssignOrgRole: "Editor",
@ -176,7 +176,7 @@ func TestSocialGitHub_UserInfo(t *testing.T) {
Name: "monalisa octocat", Name: "monalisa octocat",
Email: "octocat@github.com", Email: "octocat@github.com",
Login: "octocat", Login: "octocat",
Role: "Editor", Role: "",
Groups: []string{"https://github.com/orgs/github/teams/justice-league", "@github/justice-league"}, Groups: []string{"https://github.com/orgs/github/teams/justice-league", "@github/justice-league"},
}, },
}, },

View File

@ -89,7 +89,7 @@ func (s *SocialGitlab) GetGroupsPage(client *http.Client, url string) ([]string,
return fullPaths, next 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 { var data struct {
Id int Id int
Username string Username string
@ -113,7 +113,7 @@ func (s *SocialGitlab) UserInfo(client *http.Client, token *oauth2.Token) (*Basi
groups := s.GetGroups(client) groups := s.GetGroups(client)
role, grafanaAdmin := s.extractRoleAndAdmin(response.Body, groups) role, grafanaAdmin := s.extractRoleAndAdmin(response.Body, groups, true)
if s.roleAttributeStrict && !role.IsValid() { if s.roleAttributeStrict && !role.IsValid() {
return nil, ErrInvalidBasicRole return nil, ErrInvalidBasicRole
} }
@ -129,7 +129,7 @@ func (s *SocialGitlab) UserInfo(client *http.Client, token *oauth2.Token) (*Basi
Login: data.Username, Login: data.Username,
Email: data.Email, Email: data.Email,
Groups: groups, Groups: groups,
Role: string(role), Role: role,
IsGrafanaAdmin: isGrafanaAdmin, IsGrafanaAdmin: isGrafanaAdmin,
} }

View File

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

View File

@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/org"
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )
@ -44,7 +45,7 @@ func (s *SocialGrafanaCom) IsOrganizationMember(organizations []OrgRecord) bool
return false 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 { var data struct {
Id int `json:"id"` Id int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@ -69,7 +70,7 @@ func (s *SocialGrafanaCom) UserInfo(client *http.Client, token *oauth2.Token) (*
Name: data.Name, Name: data.Name,
Login: data.Login, Login: data.Login,
Email: data.Email, Email: data.Email,
Role: data.Role, Role: org.RoleType(data.Role),
} }
if !s.IsOrganizationMember(data.Orgs) { if !s.IsOrganizationMember(data.Orgs) {

View File

@ -80,7 +80,7 @@ func (s *SocialOkta) UserInfo(client *http.Client, token *oauth2.Token) (*BasicU
return nil, errMissingGroupMembership return nil, errMissingGroupMembership
} }
role, grafanaAdmin := s.extractRoleAndAdmin(data.rawJSON, groups) role, grafanaAdmin := s.extractRoleAndAdmin(data.rawJSON, groups, true)
if s.roleAttributeStrict && !role.IsValid() { if s.roleAttributeStrict && !role.IsValid() {
return nil, ErrInvalidBasicRole return nil, ErrInvalidBasicRole
} }
@ -95,7 +95,7 @@ func (s *SocialOkta) UserInfo(client *http.Client, token *oauth2.Token) (*BasicU
Name: claims.Name, Name: claims.Name,
Email: email, Email: email,
Login: email, Login: email,
Role: string(role), Role: role,
IsGrafanaAdmin: isGrafanaAdmin, IsGrafanaAdmin: isGrafanaAdmin,
Groups: groups, Groups: groups,
}, nil }, nil

View File

@ -228,7 +228,7 @@ type BasicUserInfo struct {
Name string Name string
Email string Email string
Login string Login string
Role string Role org.RoleType
IsGrafanaAdmin *bool // nil will avoid overriding user's set server admin setting IsGrafanaAdmin *bool // nil will avoid overriding user's set server admin setting
Groups []string Groups []string
} }
@ -312,13 +312,9 @@ type groupStruct struct {
Groups []string `json:"groups"` 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.roleAttributePath == "" {
if s.autoAssignOrgRole != "" { return s.defaultRole(legacy), false
return org.RoleType(s.autoAssignOrgRole), false
}
return "", false
} }
role, err := s.searchJSONForStringAttr(s.roleAttributePath, rawJSON) 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. // match grafana admin role and translate to org role and bool.