Auth: Add org to role mappings support to Google integration (#88891)

* Auth: Implement org role mapping for google oauth provider

* Update docs

* Remove unused function

Co-authored-by: Misi <mgyongyosi@users.noreply.github.com>
This commit is contained in:
Karl Persson 2024-06-07 14:07:35 +02:00 committed by GitHub
parent 5095ea84b2
commit f3efd95bb4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 148 additions and 99 deletions

View File

@ -705,6 +705,7 @@ hosted_domain =
allowed_groups =
role_attribute_path =
role_attribute_strict = false
org_mapping =
allow_assign_grafana_admin = false
skip_org_role_sync = true
tls_skip_verify_insecure = false

View File

@ -659,6 +659,7 @@
;allowed_groups =
;role_attribute_path =
;role_attribute_strict = false
;org_mapping =
;allow_assign_grafana_admin = false
;skip_org_role_sync = false
;use_pkce = true

View File

@ -201,8 +201,7 @@ The user's role is retrieved using a [JMESPath](http://jmespath.org/examples.htm
To map the server administrator role, use the `allow_assign_grafana_admin` configuration option.
If no valid role is found, the user is assigned the role specified by [the `auto_assign_org_role` option]({{< relref "../../../configure-grafana#auto_assign_org_role" >}}).
You can disable this default role assignment by setting `role_attribute_strict = true`.
This setting denies user access if no role or an invalid role is returned.
You can disable this default role assignment by setting `role_attribute_strict = true`. This setting denies user access if no role or an invalid role is returned after evaluating the `role_attribute_path` and the `org_mapping` expressions.
To ease configuration of a proper JMESPath expression, go to [JMESPath](http://jmespath.org/) to test and evaluate expressions with custom payloads.
@ -212,6 +211,20 @@ To ease configuration of a proper JMESPath expression, go to [JMESPath](http://j
This section includes examples of JMESPath expressions used for role mapping.
##### Org roles mapping example
The Google integration uses the external users' groups in the `org_mapping` configuration to map organizations and roles based on their Google group membership.
In this example, the user has been granted the role of a `Viewer` in the `org_foo` organization, and the role of an `Editor` in the `org_bar` and `org_baz` orgs.
The external user is part of the following Google groups: `group-1` and `group-2`.
Config:
```ini
org_mapping = group-1:org_foo:Viewer group-2:org_bar:Editor *:org_baz:Editor
```
###### Map roles using user information from OAuth token
In this example, the user with email `admin@company.com` has been granted the `Admin` role.

View File

@ -140,26 +140,31 @@ func (s *SocialGoogle) UserInfo(ctx context.Context, client *http.Client, token
}
userInfo := &social.BasicUserInfo{
Id: data.ID,
Name: data.Name,
Email: data.Email,
Login: data.Email,
Role: "",
IsGrafanaAdmin: nil,
Groups: groups,
Id: data.ID,
Name: data.Name,
Email: data.Email,
Login: data.Email,
Groups: groups,
}
if s.info.AllowAssignGrafanaAdmin && s.info.SkipOrgRoleSync {
s.log.Debug("AllowAssignGrafanaAdmin and skipOrgRoleSync are both set, Grafana Admin role will not be synced, consider setting one or the other")
}
if !s.info.SkipOrgRoleSync {
role, grafanaAdmin, errRole := s.extractRoleAndAdmin(data.rawJSON, groups)
if errRole != nil {
return nil, errRole
directlyMappedRole, grafanaAdmin, err := s.extractRoleAndAdminOptional(data.rawJSON, userInfo.Groups)
if err != nil {
s.log.Warn("Failed to extract role", "err", err)
}
if s.info.AllowAssignGrafanaAdmin {
userInfo.IsGrafanaAdmin = &grafanaAdmin
}
userInfo.Role = role
userInfo.OrgRoles = s.orgRoleMapper.MapOrgRoles(s.orgMappingCfg, userInfo.Groups, directlyMappedRole)
if s.info.RoleAttributeStrict && len(userInfo.OrgRoles) == 0 {
return nil, errRoleAttributeStrictViolation.Errorf("could not evaluate any valid roles using IdP provided data")
}
}
s.log.Debug("Resolved user info", "data", fmt.Sprintf("%+v", userInfo))

View File

@ -15,9 +15,10 @@ import (
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/models/roletype"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/org/orgtest"
"github.com/grafana/grafana/pkg/services/ssosettings"
ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models"
"github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests"
@ -224,6 +225,21 @@ func (f *roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error)
return f.fn(req)
}
const googleGroupsJSON = `
{
"memberships": [
{
"group": "test-group",
"groupKey": {
"id": "test-group@google.com"
},
"displayName": "Test Group"
}
],
"nextPageToken": ""
}
`
func TestSocialGoogle_UserInfo(t *testing.T) {
cl := jwt.Claims{
Subject: "88888888888888",
@ -250,6 +266,16 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
tokenWithoutID := &oauth2.Token{}
groupClient := &http.Client{
Transport: &roundTripperFunc{
fn: func(req *http.Request) (*http.Response, error) {
resp := httptest.NewRecorder()
_, _ = resp.WriteString(googleGroupsJSON)
return resp.Result(), nil
},
},
}
type fields struct {
Scopes []string
apiURL string
@ -257,6 +283,7 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
roleAttributePath string
roleAttributeStrict bool
allowAssignGrafanaAdmin bool
orgMapping []string
skipOrgRoleSync bool
}
type args struct {
@ -295,27 +322,8 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
skipOrgRoleSync: true,
},
args: args{
token: tokenWithID,
client: &http.Client{
Transport: &roundTripperFunc{
fn: func(req *http.Request) (*http.Response, error) {
resp := httptest.NewRecorder()
_, _ = resp.WriteString(`{
"memberships": [
{
"group": "test-group",
"groupKey": {
"id": "test-group@google.com"
},
"displayName": "Test Group"
}
],
"nextPageToken": ""
}`)
return resp.Result(), nil
},
},
},
token: tokenWithID,
client: groupClient,
},
wantData: &social.BasicUserInfo{
Id: "88888888888888",
@ -507,27 +515,8 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
allowedGroups: []string{"not-that-one"},
},
args: args{
token: tokenWithID,
client: &http.Client{
Transport: &roundTripperFunc{
fn: func(req *http.Request) (*http.Response, error) {
resp := httptest.NewRecorder()
_, _ = resp.WriteString(`{
"memberships": [
{
"group": "test-group",
"groupKey": {
"id": "test-group@google.com"
},
"displayName": "Test Group"
}
],
"nextPageToken": ""
}`)
return resp.Result(), nil
},
},
},
token: tokenWithID,
client: groupClient,
},
wantData: &social.BasicUserInfo{
Id: "88888888888888",
@ -558,7 +547,7 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
Groups: []string{"test-group@google.com"},
},
wantErr: true,
wantErrMsg: "idP did not return a role attribute, but role_attribute_strict is set",
wantErrMsg: "[oauth.role_attribute_strict_violation] could not evaluate any valid roles using IdP provided data",
},
{
name: "role mapping from id_token - no allowed assign Grafana Admin",
@ -575,7 +564,7 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
Login: "test@example.com",
Email: "test@example.com",
Name: "Test User",
Role: roletype.RoleAdmin,
OrgRoles: map[int64]org.RoleType{1: org.RoleAdmin},
IsGrafanaAdmin: nil,
},
wantErr: false,
@ -595,7 +584,7 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
Login: "test@example.com",
Email: "test@example.com",
Name: "Test User",
Role: roletype.RoleAdmin,
OrgRoles: map[int64]org.RoleType{1: org.RoleAdmin},
IsGrafanaAdmin: trueBoolPtr(),
},
wantErr: false,
@ -607,55 +596,103 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
roleAttributePath: "contains(groups[*], 'test-group@google.com') && 'Editor'",
},
args: args{
token: tokenWithID,
client: &http.Client{
Transport: &roundTripperFunc{
fn: func(req *http.Request) (*http.Response, error) {
resp := httptest.NewRecorder()
_, _ = resp.WriteString(`{
"memberships": [
{
"group": "test-group",
"groupKey": {
"id": "test-group@google.com"
},
"displayName": "Test Group"
}
],
"nextPageToken": ""
}`)
return resp.Result(), nil
},
},
},
token: tokenWithID,
client: groupClient,
},
wantData: &social.BasicUserInfo{
Id: "88888888888888",
Login: "test@example.com",
Email: "test@example.com",
Name: "Test User",
Role: "Editor",
Groups: []string{"test-group@google.com"},
Id: "88888888888888",
Login: "test@example.com",
Email: "test@example.com",
Name: "Test User",
OrgRoles: map[int64]org.RoleType{1: org.RoleEditor},
Groups: []string{"test-group@google.com"},
},
wantErr: false,
},
{
name: "mapping from groups",
fields: fields{
Scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"},
roleAttributePath: "contains(groups[*], 'test-group@google.com') && 'Editor'",
},
args: args{
token: tokenWithID,
client: groupClient,
},
wantData: &social.BasicUserInfo{
Id: "88888888888888",
Login: "test@example.com",
Email: "test@example.com",
Name: "Test User",
OrgRoles: map[int64]org.RoleType{1: org.RoleEditor},
Groups: []string{"test-group@google.com"},
},
wantErr: false,
},
{
name: "Should map role when only org mapping is set",
fields: fields{
Scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"},
orgMapping: []string{"test-group@google.com:Org4:Editor", "*:Org5:Viewer"},
},
args: args{
token: tokenWithID,
client: groupClient,
},
wantData: &social.BasicUserInfo{
Id: "88888888888888",
Login: "test@example.com",
Email: "test@example.com",
Name: "Test User",
OrgRoles: map[int64]org.RoleType{4: org.RoleEditor, 5: org.RoleViewer},
Groups: []string{"test-group@google.com"},
},
wantErr: false,
},
{
name: "Should return error when neither role attribute path nor org mapping evaluates to a role and role attribute strict is enabled",
fields: fields{
Scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"},
orgMapping: []string{"other@google.com:Org4:Editor"},
roleAttributeStrict: true,
},
args: args{
token: tokenWithID,
client: groupClient,
},
wantErr: true,
},
{
name: "Should return error when neither role attribute path nor org mapping is set and role attribute strict is enabled",
fields: fields{
Scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"},
roleAttributeStrict: true,
},
args: args{
token: tokenWithID,
client: groupClient,
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := setting.NewCfg()
s := NewGoogleProvider(
&social.OAuthInfo{
ApiUrl: tt.fields.apiURL,
Scopes: tt.fields.Scopes,
AllowedGroups: tt.fields.allowedGroups,
AllowSignup: false,
RoleAttributePath: tt.fields.roleAttributePath,
RoleAttributeStrict: tt.fields.roleAttributeStrict,
AllowAssignGrafanaAdmin: tt.fields.allowAssignGrafanaAdmin,
SkipOrgRoleSync: tt.fields.skipOrgRoleSync,
OrgMapping: tt.fields.orgMapping,
},
&setting.Cfg{},
nil,
cfg,
ProvideOrgRoleMapper(cfg, &orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}),
&ssosettingstests.MockService{},
featuremgmt.WithFeatures())

View File

@ -153,15 +153,6 @@ func (s *SocialBase) extractRoleAndAdminOptional(rawJSON []byte, groups []string
return "", false, nil
}
func (s *SocialBase) extractRoleAndAdmin(rawJSON []byte, groups []string) (org.RoleType, bool, error) {
role, gAdmin, err := s.extractRoleAndAdminOptional(rawJSON, groups)
if role == "" {
role = s.defaultRole()
}
return role, gAdmin, err
}
func (s *SocialBase) searchRole(rawJSON []byte, groups []string) (org.RoleType, bool) {
role, err := util.SearchJSONForStringAttr(s.info.RoleAttributePath, rawJSON)
if err == nil && role != "" {

View File

@ -168,7 +168,8 @@ func (c *OAuth) Authenticate(ctx context.Context, r *authn.Request) (*authn.Iden
// This is required to implement OrgRole mapping for OAuth providers step by step
switch c.providerName {
case social.GenericOAuthProviderName, social.GitHubProviderName, social.GitlabProviderName, social.OktaProviderName:
case social.GenericOAuthProviderName, social.GitHubProviderName,
social.GitlabProviderName, social.OktaProviderName, social.GoogleProviderName:
// Do nothing, these providers already supports OrgRole mapping
default:
userInfo.OrgRoles, userInfo.IsGrafanaAdmin, _ = getRoles(c.cfg, func() (org.RoleType, *bool, error) {