OIDC: Support Generic OAuth org to role mappings (#87394)

* Social: link to OrgRoleMapper

* OIDC: support Generic Oauth org to role mappings

Fixes: #73448
Signed-off-by: Mathieu Parent <math.parent@gmail.com>

* Handle when getAllOrgs fails in the org_role_mapper

* Add more tests

* OIDC: ensure orgs are evaluated from API when not from token

Signed-off-by: Mathieu Parent <math.parent@gmail.com>

* OIDC: ensure AutoAssignOrg is applied with OrgMapping without RoleAttributeStrict

Signed-off-by: Mathieu Parent <math.parent@gmail.com>

* Extend docs

* Fix test, lint

---------

Signed-off-by: Mathieu Parent <math.parent@gmail.com>
Co-authored-by: Mihaly Gyongyosi <mgyongyosi@users.noreply.github.com>
This commit is contained in:
Mathieu Parent 2024-05-23 09:55:45 +02:00 committed by GitHub
parent 42126398be
commit b8c9ae0eb7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 673 additions and 359 deletions

View File

@ -807,6 +807,8 @@ login_attribute_path =
name_attribute_path =
role_attribute_path =
role_attribute_strict = false
org_attribute_path =
org_mapping =
groups_attribute_path =
id_token_attribute_name =
team_ids_attribute_path =

View File

@ -742,6 +742,8 @@
;allowed_organizations =
;role_attribute_path =
;role_attribute_strict = false
;org_attribute_path =
;org_mapping =
;groups_attribute_path =
;team_ids_attribute_path =
;tls_skip_verify_insecure = false

View File

@ -206,6 +206,8 @@ If no valid role is found, the user is assigned the role specified by [the `auto
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 use the `org_attribute_path` and `org_mapping` configuration options to add roles to other orgs. For more information, refer to [Org roles mapping examples]({{< relref "#org-roles-mapping-examples" >}}). If both org role mapping (`org_mapping`) and the regular role mapping (`role_attribute_path`) are specified, then the user will get the highest of the two mapped roles.
To ease configuration of a proper JMESPath expression, go to [JMESPath](http://jmespath.org/) to test and evaluate expressions with custom payloads.
#### Role mapping examples
@ -296,6 +298,43 @@ role_attribute_path = "'Viewer'"
skip_org_role_sync = false
```
#### Org roles mapping examples
This section includes examples of configuration settings used for org to role mapping.
##### Map organization roles
In this example, the user has been granted the role of a `Viewer` in the `org_foo` org, and the role of an `Editor` in the `org_bar` and `org_baz` orgs.
If the user was a member of the `admin` group, they would be granted the Grafana server administrator role.
Payload:
```json
{
...
"info": {
...
"roles": [
"org_foo",
"org_bar",
"another_org'
],
...
},
...
}
```
Config:
```ini
role_attribute_path = contains(info.roles[*], 'admin') && 'GrafanaAdmin' || 'None'
allow_assign_grafana_admin = true
org_attribute_path = info.roles
org_mapping = org_foo:org_foo:Viewer org_bar:org_bar:Editor *:org_baz:Editor
```
### Configure team synchronization
> **Note:** Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) and [Grafana Cloud](/docs/grafana-cloud/).
@ -358,7 +397,9 @@ The following table outlines the various generic OAuth2 configuration options. Y
| `email_attribute_path` | No | [JMESPath](http://jmespath.org/examples.html) expression to use for user email lookup from the user information. For more information on how user email is retrieved, refer to [Configure email address]({{< relref "#configure-email-address" >}}). | |
| `email_attribute_name` | No | Name of the key to use for user email lookup within the `attributes` map of OAuth2 ID token. For more information on how user email is retrieved, refer to [Configure email address]({{< relref "#configure-email-address" >}}). | `email:primary` |
| `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 OAuth2 ID token. If no role is found, the expression will be evaluated using the user information obtained from the UserInfo endpoint. The result of the evaluation should be a valid Grafana role (`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` |
| `role_attribute_strict` | No | Set to `true` to deny user login if the Grafana org role cannot be extracted using `role_attribute_path` or `org_mapping`. For more information on user role mapping, refer to [Configure role mapping]({{< relref "#configure-role-mapping" >}}). | `false` |
| `org_attribute_path` | No | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana org to role lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no value is returned, the expression will be evaluated using the user information obtained from the UserInfo endpoint. The result of the evaluation will be mapped to org roles based on `org_mapping`. For more information on org to role mapping, refer to [Org roles mapping examples]({{< relref "#org-roles-mapping-examples" >}}). | |
| `org_mapping` | No | List of comma- or space-separated Value:OrgIdOrName:Role mappings. Value can be `*` meaning "All users". Role is optional and can have the following values: `Viewer`, `Editor` or `Admin`. For more information on org to role mapping, refer to [Org roles mapping examples]({{< relref "#org-roles-mapping-examples" >}}). | |
| `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. This will allow you to set organization roles for your users from within Grafana manually. | `false` |
| `groups_attribute_path` | No | [JMESPath](http://jmespath.org/examples.html) expression to use for user group lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no groups are found, the expression will be evaluated using the user information obtained from the UserInfo endpoint. The result of the evaluation should be a string array of groups. | |

View File

@ -78,7 +78,7 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F
PluginSettings: cfg.PluginSettings,
}),
namespacer: request.GetNamespaceMapper(cfg),
SocialService: socialimpl.ProvideService(cfg, features, &usagestats.UsageStatsMock{}, supportbundlestest.NewFakeBundleService(), remotecache.NewFakeCacheStorage(), &ssosettingstests.MockService{}),
SocialService: socialimpl.ProvideService(cfg, features, &usagestats.UsageStatsMock{}, supportbundlestest.NewFakeBundleService(), remotecache.NewFakeCacheStorage(), nil, &ssosettingstests.MockService{}),
}
m := web.New()

View File

@ -78,9 +78,9 @@ type keySetJWKS struct {
jose.JSONWebKeySet
}
func NewAzureADProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles, cache remotecache.CacheStorage) *SocialAzureAD {
func NewAzureADProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper *OrgRoleMapper, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles, cache remotecache.CacheStorage) *SocialAzureAD {
provider := &SocialAzureAD{
SocialBase: newSocialBase(social.AzureADProviderName, info, features, cfg),
SocialBase: newSocialBase(social.AzureADProviderName, orgRoleMapper, info, features, cfg),
cache: cache,
allowedOrganizations: util.SplitString(info.Extra[allowedOrganizationsKey]),
forceUseGraphAPI: MustBool(info.Extra[forceUseGraphAPIKey], ExtraAzureADSettingKeys[forceUseGraphAPIKey].DefaultValue.(bool)),
@ -179,7 +179,7 @@ func (s *SocialAzureAD) Reload(ctx context.Context, settings ssoModels.SSOSettin
s.reloadMutex.Lock()
defer s.reloadMutex.Unlock()
s.updateInfo(social.AzureADProviderName, newInfo)
s.updateInfo(ctx, social.AzureADProviderName, newInfo)
if newInfo.UseRefreshToken {
appendUniqueScope(s.Config, social.OfflineAccessScope)

View File

@ -711,7 +711,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := NewAzureADProvider(tt.fields.providerCfg, tt.fields.cfg, &ssosettingstests.MockService{}, featuremgmt.WithFeatures(), cache)
s := NewAzureADProvider(tt.fields.providerCfg, tt.fields.cfg, nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures(), cache)
if tt.fields.usGovURL {
s.SocialBase.Endpoint.AuthURL = usGovAuthURL
@ -884,7 +884,7 @@ func TestSocialAzureAD_SkipOrgRole(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := NewAzureADProvider(tt.fields.providerCfg, tt.fields.cfg, &ssosettingstests.MockService{}, featuremgmt.WithFeatures(), cache)
s := NewAzureADProvider(tt.fields.providerCfg, tt.fields.cfg, nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures(), cache)
s.SocialBase.Endpoint.AuthURL = authURL
@ -982,7 +982,7 @@ func TestSocialAzureAD_InitializeExtraFields(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewAzureADProvider(tc.settings, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures(), nil)
s := NewAzureADProvider(tc.settings, &setting.Cfg{}, nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures(), nil)
require.Equal(t, tc.want.forceUseGraphAPI, s.forceUseGraphAPI)
require.Equal(t, tc.want.allowedOrganizations, s.allowedOrganizations)
@ -1125,7 +1125,7 @@ func TestSocialAzureAD_Validate(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewAzureADProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures(), nil)
s := NewAzureADProvider(&social.OAuthInfo{}, &setting.Cfg{}, nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures(), nil)
if tc.requester == nil {
tc.requester = &user.SignedInUser{IsGrafanaAdmin: false}
@ -1205,7 +1205,7 @@ func TestSocialAzureAD_Reload(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewAzureADProvider(tc.info, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures(), nil)
s := NewAzureADProvider(tc.info, &setting.Cfg{}, nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures(), nil)
err := s.Reload(context.Background(), tc.settings)
if tc.expectError {
@ -1262,7 +1262,7 @@ func TestSocialAzureAD_Reload_ExtraFields(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewAzureADProvider(tc.info, setting.NewCfg(), &ssosettingstests.MockService{}, featuremgmt.WithFeatures(), remotecache.FakeCacheStorage{})
s := NewAzureADProvider(tc.info, setting.NewCfg(), nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures(), remotecache.FakeCacheStorage{})
err := s.Reload(context.Background(), tc.settings)
require.NoError(t, err)

View File

@ -53,9 +53,9 @@ type SocialGenericOAuth struct {
teamIds []string
}
func NewGenericOAuthProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGenericOAuth {
func NewGenericOAuthProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper *OrgRoleMapper, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGenericOAuth {
provider := &SocialGenericOAuth{
SocialBase: newSocialBase(social.GenericOAuthProviderName, info, features, cfg),
SocialBase: newSocialBase(social.GenericOAuthProviderName, orgRoleMapper, info, features, cfg),
teamsUrl: info.TeamsUrl,
emailAttributeName: info.EmailAttributeName,
emailAttributePath: info.EmailAttributePath,
@ -122,7 +122,7 @@ func (s *SocialGenericOAuth) Reload(ctx context.Context, settings ssoModels.SSOS
s.reloadMutex.Lock()
defer s.reloadMutex.Unlock()
s.updateInfo(social.GenericOAuthProviderName, newInfo)
s.updateInfo(ctx, social.GenericOAuthProviderName, newInfo)
s.teamsUrl = newInfo.TeamsUrl
s.emailAttributeName = newInfo.EmailAttributeName
@ -231,6 +231,7 @@ func (s *SocialGenericOAuth) UserInfo(ctx context.Context, client *http.Client,
}
userInfo := &social.BasicUserInfo{}
var externalOrgs []string
for _, data := range toCheck {
s.log.Debug("Processing external user info", "source", data.source, "data", data)
@ -265,6 +266,15 @@ func (s *SocialGenericOAuth) UserInfo(ctx context.Context, client *http.Client,
}
}
if len(externalOrgs) == 0 && !s.info.SkipOrgRoleSync {
var err error
externalOrgs, err = s.extractOrgs(data.rawJSON)
if err != nil {
s.log.Warn("Failed to extract orgs", "err", err)
return nil, err
}
}
if len(userInfo.Groups) == 0 {
groups, err := s.extractGroups(data)
if err != nil {
@ -276,11 +286,14 @@ func (s *SocialGenericOAuth) UserInfo(ctx context.Context, client *http.Client,
}
}
if userInfo.Role == "" && !s.info.SkipOrgRoleSync {
if s.info.RoleAttributeStrict {
return nil, errRoleAttributeStrictViolation.Errorf("idP did not return a role attribute")
if !s.info.SkipOrgRoleSync {
userInfo.OrgRoles = s.orgRoleMapper.MapOrgRoles(s.orgMappingCfg, externalOrgs, userInfo.Role)
if s.info.RoleAttributeStrict && len(userInfo.OrgRoles) == 0 {
// If no roles are found and role_attribute_strict is set, return an error.
// The s.info.RoleAttributeStrict is necessary, because there is a case when len(userInfo.OrgRoles) == 0,
// but strict role mapping is not enabled (when getAllOrgs fails).
return nil, errRoleAttributeStrictViolation.Errorf("could not evaluate any valid roles using IdP provided data")
}
userInfo.Role = s.defaultRole()
}
if s.info.AllowAssignGrafanaAdmin && s.info.SkipOrgRoleSync {

View File

@ -16,6 +16,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"
@ -23,22 +24,20 @@ import (
"github.com/grafana/grafana/pkg/setting"
)
func TestUserInfoSearchesForEmailAndRole(t *testing.T) {
provider := NewGenericOAuthProvider(&social.OAuthInfo{
EmailAttributePath: "email",
}, &setting.Cfg{},
&ssosettingstests.MockService{},
featuremgmt.WithFeatures())
tests := []struct {
func TestUserInfoSearchesForEmailAndOrgRoles(t *testing.T) {
testCases := []struct {
Name string
SkipOrgRoleSync bool
AllowAssignGrafanaAdmin bool
ResponseBody any
OAuth2Extra any
Setup func(*orgtest.FakeOrgService)
RoleAttributePath string
RoleAttributeStrict bool
OrgAttributePath string
OrgMapping []string
ExpectedEmail string
ExpectedRole org.RoleType
ExpectedOrgRoles map[int64]org.RoleType
ExpectedError error
ExpectedGrafanaAdmin *bool
}{
@ -50,7 +49,7 @@ func TestUserInfoSearchesForEmailAndRole(t *testing.T) {
},
RoleAttributePath: "role",
ExpectedEmail: "john.doe@example.com",
ExpectedRole: "Admin",
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleAdmin},
},
{
Name: "Given a valid id_token, no role path, no API response, use id_token",
@ -60,7 +59,7 @@ func TestUserInfoSearchesForEmailAndRole(t *testing.T) {
},
RoleAttributePath: "",
ExpectedEmail: "john.doe@example.com",
ExpectedRole: "Viewer",
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleViewer},
},
{
Name: "Given a valid id_token, an invalid role path, no API response, use id_token",
@ -70,7 +69,7 @@ func TestUserInfoSearchesForEmailAndRole(t *testing.T) {
},
RoleAttributePath: "invalid_path",
ExpectedEmail: "john.doe@example.com",
ExpectedRole: "Viewer",
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleViewer},
},
{
Name: "Given no id_token, a valid role path, a valid API response, use API response",
@ -80,7 +79,7 @@ func TestUserInfoSearchesForEmailAndRole(t *testing.T) {
},
RoleAttributePath: "role",
ExpectedEmail: "john.doe@example.com",
ExpectedRole: "Admin",
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleAdmin},
},
{
Name: "Given no id_token, no role path, a valid API response, use API response",
@ -89,7 +88,7 @@ func TestUserInfoSearchesForEmailAndRole(t *testing.T) {
},
RoleAttributePath: "",
ExpectedEmail: "john.doe@example.com",
ExpectedRole: "Viewer",
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleViewer},
},
{
Name: "Given no id_token, a role path, a valid API response without a role, use API response",
@ -98,13 +97,13 @@ func TestUserInfoSearchesForEmailAndRole(t *testing.T) {
},
RoleAttributePath: "role",
ExpectedEmail: "john.doe@example.com",
ExpectedRole: "Viewer",
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleViewer},
},
{
Name: "Given no id_token, a valid role path, no API response, no data",
RoleAttributePath: "role",
ExpectedEmail: "",
ExpectedRole: "Viewer",
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleViewer},
},
{
Name: "Given a valid id_token, a valid role path, a valid API response, prefer id_token",
@ -118,7 +117,7 @@ func TestUserInfoSearchesForEmailAndRole(t *testing.T) {
},
RoleAttributePath: "role",
ExpectedEmail: "john.doe@example.com",
ExpectedRole: "Admin",
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleAdmin},
},
{
Name: "Given a valid id_token and AssignGrafanaAdmin is unchecked, don't grant Server Admin",
@ -133,8 +132,8 @@ func TestUserInfoSearchesForEmailAndRole(t *testing.T) {
},
RoleAttributePath: "role",
ExpectedEmail: "john.doe@example.com",
ExpectedRole: "Admin",
ExpectedGrafanaAdmin: nil,
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleAdmin},
},
{
Name: "Given a valid id_token and AssignGrafanaAdmin is checked, grant Server Admin",
@ -149,8 +148,8 @@ func TestUserInfoSearchesForEmailAndRole(t *testing.T) {
},
RoleAttributePath: "role",
ExpectedEmail: "john.doe@example.com",
ExpectedRole: "Admin",
ExpectedGrafanaAdmin: trueBoolPtr(),
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleAdmin},
},
{
Name: "Given a valid id_token, an invalid role path, a valid API response, prefer id_token",
@ -164,7 +163,7 @@ func TestUserInfoSearchesForEmailAndRole(t *testing.T) {
},
RoleAttributePath: "invalid_path",
ExpectedEmail: "john.doe@example.com",
ExpectedRole: "Viewer",
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleViewer},
},
{
Name: "Given a valid id_token with no email, a valid role path, a valid API response with no role, merge",
@ -177,7 +176,7 @@ func TestUserInfoSearchesForEmailAndRole(t *testing.T) {
},
RoleAttributePath: "role",
ExpectedEmail: "from_response@example.com",
ExpectedRole: "Admin",
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleAdmin},
},
{
Name: "Given a valid id_token with no role, a valid role path, a valid API response with no email, merge",
@ -190,8 +189,8 @@ func TestUserInfoSearchesForEmailAndRole(t *testing.T) {
},
RoleAttributePath: "role",
ExpectedEmail: "john.doe@example.com",
ExpectedRole: "Viewer",
ExpectedError: nil,
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleViewer},
},
{
Name: "Given a valid id_token, a valid advanced JMESPath role path, derive the role",
@ -202,10 +201,10 @@ func TestUserInfoSearchesForEmailAndRole(t *testing.T) {
},
RoleAttributePath: "contains(info.roles[*], 'dev') && 'Editor'",
ExpectedEmail: "john.doe@example.com",
ExpectedRole: "Editor",
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleEditor},
},
{
Name: "Given a valid id_token without role info, a valid advanced JMESPath role path, a valid API response, derive the correct role using the userinfo API response (JMESPath warning on id_token)",
Name: "Given a valid id_token without role info, a valid advanced JMESPath role path, a valid API response, derive the correct org roles using the userinfo API response (JMESPath warning on id_token)",
OAuth2Extra: map[string]any{
// { "email": "john.doe@example.com" }
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIn0.k5GwPcZvGe2BE_jgwN0ntz0nz4KlYhEd0hRRLApkTJ4",
@ -217,7 +216,22 @@ func TestUserInfoSearchesForEmailAndRole(t *testing.T) {
},
RoleAttributePath: "contains(info.roles[*], 'SRE') && 'Admin'",
ExpectedEmail: "john.doe@example.com",
ExpectedRole: "Admin",
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleAdmin},
},
{
Name: "Given a valid id_token without role info, a valid advanced JMESPath role path, a valid API response, derive the correct org roles using the userinfo API response (JMESPath warning on id_token)",
OAuth2Extra: map[string]any{
// { "email": "john.doe@example.com" }
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIn0.k5GwPcZvGe2BE_jgwN0ntz0nz4KlYhEd0hRRLApkTJ4",
},
ResponseBody: map[string]any{
"info": map[string]any{
"roles": []string{"engineering", "SRE"},
},
},
RoleAttributePath: "contains(info.roles[*], 'SRE') && 'Admin'",
ExpectedEmail: "john.doe@example.com",
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleAdmin},
},
{
Name: "Given a valid id_token, a valid advanced JMESPath role path, a valid API response, prefer ID token",
@ -233,7 +247,7 @@ func TestUserInfoSearchesForEmailAndRole(t *testing.T) {
},
RoleAttributePath: "contains(info.roles[*], 'SRE') && 'Admin' || contains(info.roles[*], 'dev') && 'Editor' || 'Viewer'",
ExpectedEmail: "john.doe@example.com",
ExpectedRole: "Editor",
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleEditor},
},
{
Name: "Given skip org role sync set to true, with a valid id_token, a valid advanced JMESPath role path, a valid API response, no org role should be set",
@ -250,17 +264,213 @@ func TestUserInfoSearchesForEmailAndRole(t *testing.T) {
},
RoleAttributePath: "contains(info.roles[*], 'SRE') && 'Admin' || contains(info.roles[*], 'dev') && 'Editor' || 'Viewer'",
ExpectedEmail: "john.doe@example.com",
ExpectedRole: "",
ExpectedOrgRoles: nil,
},
{
Name: "Given a valid id_token without role info, a valid advanced JMESPath role path, a valid org attribute path, a valid org mapping, a valid API response, derive the correct org roles using the userinfo API response (JMESPath warning on id_token)",
OAuth2Extra: map[string]any{
// { "email": "john.doe@example.com" }
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIn0.k5GwPcZvGe2BE_jgwN0ntz0nz4KlYhEd0hRRLApkTJ4",
},
ResponseBody: map[string]any{
"info": map[string]any{
"roles": []string{"engineering", "SRE"},
},
},
RoleAttributePath: "contains(info.roles[*], 'SRE') && 'Admin'",
ExpectedEmail: "john.doe@example.com",
OrgAttributePath: "info.roles",
OrgMapping: []string{"SRE:2:Viewer", "engineering:3:Editor"},
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleAdmin, 3: org.RoleAdmin},
},
{
Name: "Given a valid id_token, a role attribute path, an org roles path, an org mapping, a valid API response, prefer ID token",
SkipOrgRoleSync: false,
AllowAssignGrafanaAdmin: false,
ResponseBody: map[string]any{"info": map[string]any{"roles": []string{"engineering", "SRE"}}},
OAuth2Extra: map[string]any{
// { "email": "john.doe@example.com",
// "info": { "roles": [ "dev", "engineering" ] }}
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIiwiaW5mbyI6eyJyb2xlcyI6WyJkZXYiLCJlbmdpbmVlcmluZyJdfX0.RmmQfv25eXb4p3wMrJsvXfGQ6EXhGtwRXo6SlCFHRNg"},
RoleAttributePath: "'Viewer'",
OrgAttributePath: "info.roles",
OrgMapping: []string{"dev:org_dev:Viewer", "engineering:org_engineering:Editor"},
ExpectedEmail: "john.doe@example.com",
ExpectedOrgRoles: map[int64]org.RoleType{4: org.RoleViewer, 5: org.RoleEditor},
},
{
Name: "Should not fail when the evaluated role is invalid, role_attribute_strict is set to true and evaluated org roles are not empty",
SkipOrgRoleSync: false,
AllowAssignGrafanaAdmin: false,
ResponseBody: map[string]any{"info": map[string]any{"roles": []string{"engineering", "SRE"}}},
OAuth2Extra: map[string]any{
// { "email": "john.doe@example.com",
// "info": { "roles": [ "dev", "engineering" ] }}
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIiwiaW5mbyI6eyJyb2xlcyI6WyJkZXYiLCJlbmdpbmVlcmluZyJdfX0.RmmQfv25eXb4p3wMrJsvXfGQ6EXhGtwRXo6SlCFHRNg"},
RoleAttributePath: "'Invalid'",
RoleAttributeStrict: true,
OrgAttributePath: "info.roles",
OrgMapping: []string{"dev:org_dev:Viewer", "engineering:org_engineering:Editor"},
ExpectedEmail: "john.doe@example.com",
ExpectedOrgRoles: map[int64]org.RoleType{4: org.RoleViewer, 5: org.RoleEditor},
},
{
Name: "Should not fail when the evaluated role is valid, role_attribute_strict is set to true and evaluated org roles are empty",
SkipOrgRoleSync: false,
AllowAssignGrafanaAdmin: false,
ResponseBody: map[string]any{"info": map[string]any{"roles": []string{"engineering", "SRE"}}},
OAuth2Extra: map[string]any{
// { "email": "john.doe@example.com",
// "info": { "roles": [ "dev", "engineering" ] }}
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIiwiaW5mbyI6eyJyb2xlcyI6WyJkZXYiLCJlbmdpbmVlcmluZyJdfX0.RmmQfv25eXb4p3wMrJsvXfGQ6EXhGtwRXo6SlCFHRNg"},
RoleAttributePath: "'Editor'",
RoleAttributeStrict: true,
OrgAttributePath: "info.roles",
OrgMapping: []string{"notmatching:org_dev:Viewer", "notmatching:org_engineering:Editor"},
ExpectedEmail: "john.doe@example.com",
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleEditor},
},
{
Name: "Should not fail when role_attribute path is empty, role_attribute_strict is set to true and evaluated org roles are not empty",
SkipOrgRoleSync: false,
AllowAssignGrafanaAdmin: false,
ResponseBody: map[string]any{"info": map[string]any{"roles": []string{"engineering", "SRE"}}},
OAuth2Extra: map[string]any{
// { "email": "john.doe@example.com",
// "info": { "roles": [ "dev", "engineering" ] }}
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIiwiaW5mbyI6eyJyb2xlcyI6WyJkZXYiLCJlbmdpbmVlcmluZyJdfX0.RmmQfv25eXb4p3wMrJsvXfGQ6EXhGtwRXo6SlCFHRNg"},
RoleAttributePath: "",
RoleAttributeStrict: true,
OrgAttributePath: "info.roles",
OrgMapping: []string{"dev:org_dev:Viewer", "engineering:org_engineering:Editor"},
ExpectedEmail: "john.doe@example.com",
ExpectedOrgRoles: map[int64]org.RoleType{4: org.RoleViewer, 5: org.RoleEditor},
},
{
Name: "Should return empty when evaluated role is empty/invalid, role_attribute_strict is set to false and evaluated org roles are empty",
SkipOrgRoleSync: false,
AllowAssignGrafanaAdmin: false,
ResponseBody: map[string]any{"info": map[string]any{"roles": []string{"engineering", "SRE"}}},
OAuth2Extra: map[string]any{
// { "email": "john.doe@example.com",
// "info": { "roles": [ "dev", "engineering" ] }}
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIiwiaW5mbyI6eyJyb2xlcyI6WyJkZXYiLCJlbmdpbmVlcmluZyJdfX0.RmmQfv25eXb4p3wMrJsvXfGQ6EXhGtwRXo6SlCFHRNg"},
Setup: func(orgSvc *orgtest.FakeOrgService) {
orgSvc.ExpectedError = assert.AnError
},
RoleAttributePath: "'Invalid'",
RoleAttributeStrict: false,
OrgAttributePath: "info.roles",
OrgMapping: []string{"dev:*:Viewer"},
ExpectedEmail: "john.doe@example.com",
ExpectedOrgRoles: nil,
},
{
Name: "Should fail when role_attribute_path is empty, role_attribute_strict is set to true and org_mapping is empty",
SkipOrgRoleSync: false,
AllowAssignGrafanaAdmin: false,
ResponseBody: map[string]any{"info": map[string]any{"roles": []string{"engineering", "SRE"}}},
OAuth2Extra: map[string]any{
// { "email": "john.doe@example.com",
// "info": { "roles": [ "dev", "engineering" ] }}
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIiwiaW5mbyI6eyJyb2xlcyI6WyJkZXYiLCJlbmdpbmVlcmluZyJdfX0.RmmQfv25eXb4p3wMrJsvXfGQ6EXhGtwRXo6SlCFHRNg"},
RoleAttributePath: "",
RoleAttributeStrict: true,
OrgAttributePath: "info.invalid",
OrgMapping: []string{},
ExpectedEmail: "john.doe@example.com",
ExpectedError: errRoleAttributeStrictViolation,
},
{
Name: "Should fail when role_attribute_path evaluates to invalid role, role_attribute_strict is set to true and org_mapping is empty",
SkipOrgRoleSync: false,
AllowAssignGrafanaAdmin: false,
ResponseBody: map[string]any{"info": map[string]any{"roles": []string{"engineering", "SRE"}}},
OAuth2Extra: map[string]any{
// { "email": "john.doe@example.com",
// "info": { "roles": [ "dev", "engineering" ] }}
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIiwiaW5mbyI6eyJyb2xlcyI6WyJkZXYiLCJlbmdpbmVlcmluZyJdfX0.RmmQfv25eXb4p3wMrJsvXfGQ6EXhGtwRXo6SlCFHRNg"},
RoleAttributePath: "'Invalid'",
RoleAttributeStrict: true,
OrgAttributePath: "info.invalid",
OrgMapping: []string{},
ExpectedEmail: "john.doe@example.com",
ExpectedError: errRoleAttributeStrictViolation,
},
{
Name: "Should fail when role_attribute path is empty, role_attribute_strict is set to true and evaluated org roles are empty",
SkipOrgRoleSync: false,
AllowAssignGrafanaAdmin: false,
ResponseBody: map[string]any{"info": map[string]any{"roles": []string{"engineering", "SRE"}}},
OAuth2Extra: map[string]any{
// { "email": "john.doe@example.com",
// "info": { "roles": [ "dev", "engineering" ] }}
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIiwiaW5mbyI6eyJyb2xlcyI6WyJkZXYiLCJlbmdpbmVlcmluZyJdfX0.RmmQfv25eXb4p3wMrJsvXfGQ6EXhGtwRXo6SlCFHRNg"},
RoleAttributePath: "",
RoleAttributeStrict: true,
OrgAttributePath: "info.invalid",
OrgMapping: []string{"dev:org_dev:Viewer", "engineering:org_engineering:Editor"},
ExpectedEmail: "john.doe@example.com",
ExpectedError: errRoleAttributeStrictViolation,
},
{
Name: "Should get orgs from API when not in token",
ResponseBody: map[string]any{"anotherInfo": map[string]any{"roles": []string{"fromApiOne", "fromApiTwo"}}},
OAuth2Extra: map[string]any{
// { "email": "john.doe@example.com",
// "info": { "roles": [ "dev", "engineering" ] }}
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIiwiaW5mbyI6eyJyb2xlcyI6WyJkZXYiLCJlbmdpbmVlcmluZyJdfX0.RmmQfv25eXb4p3wMrJsvXfGQ6EXhGtwRXo6SlCFHRNg"},
RoleAttributePath: "",
RoleAttributeStrict: true,
OrgAttributePath: "anotherInfo.roles",
OrgMapping: []string{"fromApiOne:org_dev:Viewer", "fromApiTwo:org_engineering:Editor"},
ExpectedEmail: "john.doe@example.com",
ExpectedOrgRoles: map[int64]org.RoleType{4: org.RoleViewer, 5: org.RoleEditor},
},
{
Name: "Give AutoAssignOrgRole in AutoAssignOrgId when OrgMapping returns no OrgRoles",
ResponseBody: map[string]any{},
OAuth2Extra: map[string]any{
// { "email": "john.doe@example.com",
// "info": { "roles": [ "dev", "engineering" ] }}
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIiwiaW5mbyI6eyJyb2xlcyI6WyJkZXYiLCJlbmdpbmVlcmluZyJdfX0.RmmQfv25eXb4p3wMrJsvXfGQ6EXhGtwRXo6SlCFHRNg"},
RoleAttributePath: "",
OrgAttributePath: "info.roles",
OrgMapping: []string{"foo:org_dev:Viewer", "bar:org_engineering:Editor"},
ExpectedEmail: "john.doe@example.com",
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleViewer},
},
}
for _, test := range tests {
provider.info.RoleAttributePath = test.RoleAttributePath
provider.info.AllowAssignGrafanaAdmin = test.AllowAssignGrafanaAdmin
provider.info.SkipOrgRoleSync = test.SkipOrgRoleSync
cfg := &setting.Cfg{
AutoAssignOrg: true,
AutoAssignOrgId: 2,
AutoAssignOrgRole: string(org.RoleViewer),
}
t.Run(test.Name, func(t *testing.T) {
body, err := json.Marshal(test.ResponseBody)
for _, tc := range testCases {
orgSvc := &orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "org_dev"}, {ID: 5, Name: "org_engineering"}}}
if tc.Setup != nil {
tc.Setup(orgSvc)
}
orgRoleMapper := ProvideOrgRoleMapper(cfg, orgSvc)
provider := NewGenericOAuthProvider(&social.OAuthInfo{
EmailAttributePath: "email",
}, cfg,
orgRoleMapper,
&ssosettingstests.MockService{},
featuremgmt.WithFeatures())
provider.info.RoleAttributePath = tc.RoleAttributePath
provider.info.OrgAttributePath = tc.OrgAttributePath
provider.info.OrgMapping = tc.OrgMapping
provider.orgMappingCfg = orgRoleMapper.ParseOrgMappingSettings(context.Background(), tc.OrgMapping, tc.RoleAttributeStrict)
provider.info.AllowAssignGrafanaAdmin = tc.AllowAssignGrafanaAdmin
provider.info.SkipOrgRoleSync = tc.SkipOrgRoleSync
provider.info.RoleAttributeStrict = tc.RoleAttributeStrict
t.Run(tc.Name, func(t *testing.T) {
body, err := json.Marshal(tc.ResponseBody)
require.NoError(t, err)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
@ -276,283 +486,288 @@ func TestUserInfoSearchesForEmailAndRole(t *testing.T) {
Expiry: time.Now(),
}
token := staticToken.WithExtra(test.OAuth2Extra)
token := staticToken.WithExtra(tc.OAuth2Extra)
actualResult, err := provider.UserInfo(context.Background(), ts.Client(), token)
if test.ExpectedError != nil {
require.ErrorIs(t, err, test.ExpectedError)
if tc.ExpectedError != nil {
require.ErrorIs(t, err, tc.ExpectedError)
return
}
require.NoError(t, err)
require.Equal(t, test.ExpectedEmail, actualResult.Email)
require.Equal(t, test.ExpectedEmail, actualResult.Login)
require.Equal(t, test.ExpectedRole, actualResult.Role)
require.Equal(t, test.ExpectedGrafanaAdmin, actualResult.IsGrafanaAdmin)
require.Equal(t, tc.ExpectedEmail, actualResult.Email)
require.Equal(t, tc.ExpectedEmail, actualResult.Login)
require.Equal(t, tc.ExpectedOrgRoles, actualResult.OrgRoles)
require.Equal(t, tc.ExpectedGrafanaAdmin, actualResult.IsGrafanaAdmin)
})
}
}
func TestUserInfoSearchesForLogin(t *testing.T) {
t.Run("Given a generic OAuth provider", func(t *testing.T) {
provider := NewGenericOAuthProvider(&social.OAuthInfo{
Extra: map[string]string{
"login_attribute_path": "login",
testCases := []struct {
Name string
ResponseBody any
OAuth2Extra any
LoginAttributePath string
ExpectedLogin string
}{
{
Name: "Given a valid id_token, a valid login path, no API response, use id_token",
OAuth2Extra: map[string]any{
// { "login": "johndoe", "email": "john.doe@example.com" }
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsb2dpbiI6ImpvaG5kb2UiLCJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIn0.sg4sRJCNpax_76XMgr277fdxhjjtNSWXKIOFv4_GJN8",
},
}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
LoginAttributePath: "role",
ExpectedLogin: "johndoe",
},
{
Name: "Given a valid id_token, no login path, no API response, use id_token",
OAuth2Extra: map[string]any{
// { "login": "johndoe", "email": "john.doe@example.com" }
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsb2dpbiI6ImpvaG5kb2UiLCJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIn0.sg4sRJCNpax_76XMgr277fdxhjjtNSWXKIOFv4_GJN8",
},
LoginAttributePath: "",
ExpectedLogin: "johndoe",
},
{
Name: "Given no id_token, a valid login path, a valid API response, use API response",
ResponseBody: map[string]any{
"user_uid": "johndoe",
"email": "john.doe@example.com",
},
LoginAttributePath: "user_uid",
ExpectedLogin: "johndoe",
},
{
Name: "Given no id_token, no login path, a valid API response, use API response",
ResponseBody: map[string]any{
"login": "johndoe",
},
LoginAttributePath: "",
ExpectedLogin: "johndoe",
},
{
Name: "Given no id_token, a login path, a valid API response without a login, use API response",
ResponseBody: map[string]any{
"username": "john.doe",
},
LoginAttributePath: "login",
ExpectedLogin: "john.doe",
},
{
Name: "Given no id_token, a valid login path, no API response, no data",
LoginAttributePath: "login",
ExpectedLogin: "",
},
}
tests := []struct {
Name string
ResponseBody any
OAuth2Extra any
LoginAttributePath string
ExpectedLogin string
}{
{
Name: "Given a valid id_token, a valid login path, no API response, use id_token",
OAuth2Extra: map[string]any{
// { "login": "johndoe", "email": "john.doe@example.com" }
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsb2dpbiI6ImpvaG5kb2UiLCJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIn0.sg4sRJCNpax_76XMgr277fdxhjjtNSWXKIOFv4_GJN8",
},
LoginAttributePath: "role",
ExpectedLogin: "johndoe",
},
{
Name: "Given a valid id_token, no login path, no API response, use id_token",
OAuth2Extra: map[string]any{
// { "login": "johndoe", "email": "john.doe@example.com" }
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsb2dpbiI6ImpvaG5kb2UiLCJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIn0.sg4sRJCNpax_76XMgr277fdxhjjtNSWXKIOFv4_GJN8",
},
LoginAttributePath: "",
ExpectedLogin: "johndoe",
},
{
Name: "Given no id_token, a valid login path, a valid API response, use API response",
ResponseBody: map[string]any{
"user_uid": "johndoe",
"email": "john.doe@example.com",
},
LoginAttributePath: "user_uid",
ExpectedLogin: "johndoe",
},
{
Name: "Given no id_token, no login path, a valid API response, use API response",
ResponseBody: map[string]any{
"login": "johndoe",
},
LoginAttributePath: "",
ExpectedLogin: "johndoe",
},
{
Name: "Given no id_token, a login path, a valid API response without a login, use API response",
ResponseBody: map[string]any{
"username": "john.doe",
},
LoginAttributePath: "login",
ExpectedLogin: "john.doe",
},
{
Name: "Given no id_token, a valid login path, no API response, no data",
LoginAttributePath: "login",
ExpectedLogin: "",
},
}
provider := NewGenericOAuthProvider(&social.OAuthInfo{
Extra: map[string]string{
"login_attribute_path": "login",
},
}, setting.NewCfg(),
ProvideOrgRoleMapper(setting.NewCfg(), orgtest.NewOrgServiceFake()),
&ssosettingstests.MockService{},
featuremgmt.WithFeatures())
for _, test := range tests {
provider.loginAttributePath = test.LoginAttributePath
t.Run(test.Name, func(t *testing.T) {
body, err := json.Marshal(test.ResponseBody)
for _, tc := range testCases {
provider.loginAttributePath = tc.LoginAttributePath
t.Run(tc.Name, func(t *testing.T) {
body, err := json.Marshal(tc.ResponseBody)
require.NoError(t, err)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
t.Log("Writing fake API response body", "body", tc.ResponseBody)
_, err = w.Write(body)
require.NoError(t, err)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
t.Log("Writing fake API response body", "body", test.ResponseBody)
_, err = w.Write(body)
require.NoError(t, err)
}))
provider.info.ApiUrl = ts.URL
staticToken := oauth2.Token{
AccessToken: "",
TokenType: "",
RefreshToken: "",
Expiry: time.Now(),
}
}))
provider.info.ApiUrl = ts.URL
staticToken := oauth2.Token{
AccessToken: "",
TokenType: "",
RefreshToken: "",
Expiry: time.Now(),
}
token := staticToken.WithExtra(test.OAuth2Extra)
actualResult, err := provider.UserInfo(context.Background(), ts.Client(), token)
require.NoError(t, err)
require.Equal(t, test.ExpectedLogin, actualResult.Login)
})
}
})
token := staticToken.WithExtra(tc.OAuth2Extra)
actualResult, err := provider.UserInfo(context.Background(), ts.Client(), token)
require.NoError(t, err)
require.Equal(t, tc.ExpectedLogin, actualResult.Login)
})
}
}
func TestUserInfoSearchesForName(t *testing.T) {
t.Run("Given a generic OAuth provider", func(t *testing.T) {
provider := NewGenericOAuthProvider(&social.OAuthInfo{
Extra: map[string]string{
"name_attribute_path": "name",
testCases := []struct {
Name string
ResponseBody any
OAuth2Extra any
NameAttributePath string
ExpectedName string
}{
{
Name: "Given a valid id_token, a valid name path, no API response, use id_token",
OAuth2Extra: map[string]any{
// { "name": "John Doe", "login": "johndoe", "email": "john.doe@example.com" }
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsb2dpbiI6ImpvaG5kb2UiLCJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIiwibmFtZSI6IkpvaG4gRG9lIn0.oMsXH0mHxUSYMXh6FonZIWh8LgNIcYbKRLSO1bwnfSI",
},
}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
NameAttributePath: "name",
ExpectedName: "John Doe",
},
{
Name: "Given a valid id_token, no name path, no API response, use id_token",
OAuth2Extra: map[string]any{
// { "name": "John Doe", "login": "johndoe", "email": "john.doe@example.com" }
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsb2dpbiI6ImpvaG5kb2UiLCJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIiwibmFtZSI6IkpvaG4gRG9lIn0.oMsXH0mHxUSYMXh6FonZIWh8LgNIcYbKRLSO1bwnfSI",
},
NameAttributePath: "",
ExpectedName: "John Doe",
},
{
Name: "Given no id_token, a valid name path, a valid API response, use API response",
ResponseBody: map[string]any{
"user_name": "John Doe",
"login": "johndoe",
"email": "john.doe@example.com",
},
NameAttributePath: "user_name",
ExpectedName: "John Doe",
},
{
Name: "Given no id_token, no name path, a valid API response, use API response",
ResponseBody: map[string]any{
"display_name": "John Doe",
"login": "johndoe",
},
NameAttributePath: "",
ExpectedName: "John Doe",
},
{
Name: "Given no id_token, a name path, a valid API response without a name, use API response",
ResponseBody: map[string]any{
"display_name": "John Doe",
"username": "john.doe",
},
NameAttributePath: "name",
ExpectedName: "John Doe",
},
{
Name: "Given no id_token, a valid name path, no API response, no data",
NameAttributePath: "name",
ExpectedName: "",
},
}
tests := []struct {
Name string
ResponseBody any
OAuth2Extra any
NameAttributePath string
ExpectedName string
}{
{
Name: "Given a valid id_token, a valid name path, no API response, use id_token",
OAuth2Extra: map[string]any{
// { "name": "John Doe", "login": "johndoe", "email": "john.doe@example.com" }
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsb2dpbiI6ImpvaG5kb2UiLCJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIiwibmFtZSI6IkpvaG4gRG9lIn0.oMsXH0mHxUSYMXh6FonZIWh8LgNIcYbKRLSO1bwnfSI",
},
NameAttributePath: "name",
ExpectedName: "John Doe",
},
{
Name: "Given a valid id_token, no name path, no API response, use id_token",
OAuth2Extra: map[string]any{
// { "name": "John Doe", "login": "johndoe", "email": "john.doe@example.com" }
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsb2dpbiI6ImpvaG5kb2UiLCJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIiwibmFtZSI6IkpvaG4gRG9lIn0.oMsXH0mHxUSYMXh6FonZIWh8LgNIcYbKRLSO1bwnfSI",
},
NameAttributePath: "",
ExpectedName: "John Doe",
},
{
Name: "Given no id_token, a valid name path, a valid API response, use API response",
ResponseBody: map[string]any{
"user_name": "John Doe",
"login": "johndoe",
"email": "john.doe@example.com",
},
NameAttributePath: "user_name",
ExpectedName: "John Doe",
},
{
Name: "Given no id_token, no name path, a valid API response, use API response",
ResponseBody: map[string]any{
"display_name": "John Doe",
"login": "johndoe",
},
NameAttributePath: "",
ExpectedName: "John Doe",
},
{
Name: "Given no id_token, a name path, a valid API response without a name, use API response",
ResponseBody: map[string]any{
"display_name": "John Doe",
"username": "john.doe",
},
NameAttributePath: "name",
ExpectedName: "John Doe",
},
{
Name: "Given no id_token, a valid name path, no API response, no data",
NameAttributePath: "name",
ExpectedName: "",
},
}
provider := NewGenericOAuthProvider(&social.OAuthInfo{
Extra: map[string]string{
"name_attribute_path": "name",
},
},
setting.NewCfg(),
ProvideOrgRoleMapper(setting.NewCfg(), orgtest.NewOrgServiceFake()),
&ssosettingstests.MockService{},
featuremgmt.WithFeatures())
for _, test := range tests {
provider.nameAttributePath = test.NameAttributePath
t.Run(test.Name, func(t *testing.T) {
body, err := json.Marshal(test.ResponseBody)
for _, tc := range testCases {
provider.nameAttributePath = tc.NameAttributePath
t.Run(tc.Name, func(t *testing.T) {
body, err := json.Marshal(tc.ResponseBody)
require.NoError(t, err)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
t.Log("Writing fake API response body", "body", tc.ResponseBody)
_, err = w.Write(body)
require.NoError(t, err)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
t.Log("Writing fake API response body", "body", test.ResponseBody)
_, err = w.Write(body)
require.NoError(t, err)
}))
provider.info.ApiUrl = ts.URL
staticToken := oauth2.Token{
AccessToken: "",
TokenType: "",
RefreshToken: "",
Expiry: time.Now(),
}
}))
provider.info.ApiUrl = ts.URL
staticToken := oauth2.Token{
AccessToken: "",
TokenType: "",
RefreshToken: "",
Expiry: time.Now(),
}
token := staticToken.WithExtra(test.OAuth2Extra)
actualResult, err := provider.UserInfo(context.Background(), ts.Client(), token)
require.NoError(t, err)
require.Equal(t, test.ExpectedName, actualResult.Name)
})
}
})
token := staticToken.WithExtra(tc.OAuth2Extra)
actualResult, err := provider.UserInfo(context.Background(), ts.Client(), token)
require.NoError(t, err)
require.Equal(t, tc.ExpectedName, actualResult.Name)
})
}
}
func TestUserInfoSearchesForGroup(t *testing.T) {
t.Run("Given a generic OAuth provider", func(t *testing.T) {
tests := []struct {
name string
groupsAttributePath string
responseBody any
expectedResult []string
}{
{
name: "If groups are not set, user groups are nil",
groupsAttributePath: "",
expectedResult: nil,
},
{
name: "If groups are empty, user groups are nil",
groupsAttributePath: "info.groups",
responseBody: map[string]any{
"info": map[string]any{
"groups": []string{},
},
testCases := []struct {
name string
groupsAttributePath string
responseBody any
expectedResult []string
}{
{
name: "If groups are not set, user groups are nil",
groupsAttributePath: "",
expectedResult: nil,
},
{
name: "If groups are empty, user groups are nil",
groupsAttributePath: "info.groups",
responseBody: map[string]any{
"info": map[string]any{
"groups": []string{},
},
expectedResult: nil,
},
{
name: "If groups are set, user groups are set",
groupsAttributePath: "info.groups",
responseBody: map[string]any{
"info": map[string]any{
"groups": []string{"foo", "bar"},
},
expectedResult: nil,
},
{
name: "If groups are set, user groups are set",
groupsAttributePath: "info.groups",
responseBody: map[string]any{
"info": map[string]any{
"groups": []string{"foo", "bar"},
},
expectedResult: []string{"foo", "bar"},
},
}
expectedResult: []string{"foo", "bar"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
body, err := json.Marshal(test.responseBody)
for _, test := range testCases {
t.Run(test.name, func(t *testing.T) {
body, err := json.Marshal(test.responseBody)
require.NoError(t, err)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
t.Log("Writing fake API response body", "body", test.responseBody)
_, err := w.Write(body)
require.NoError(t, err)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
t.Log("Writing fake API response body", "body", test.responseBody)
_, err := w.Write(body)
require.NoError(t, err)
}))
}))
provider := NewGenericOAuthProvider(&social.OAuthInfo{
GroupsAttributePath: test.groupsAttributePath,
ApiUrl: ts.URL,
}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
provider := NewGenericOAuthProvider(&social.OAuthInfo{
GroupsAttributePath: test.groupsAttributePath,
ApiUrl: ts.URL,
}, setting.NewCfg(),
ProvideOrgRoleMapper(setting.NewCfg(), orgtest.NewOrgServiceFake()),
&ssosettingstests.MockService{},
featuremgmt.WithFeatures())
token := &oauth2.Token{
AccessToken: "",
TokenType: "",
RefreshToken: "",
Expiry: time.Now(),
}
token := &oauth2.Token{
AccessToken: "",
TokenType: "",
RefreshToken: "",
Expiry: time.Now(),
}
userInfo, err := provider.UserInfo(context.Background(), ts.Client(), token)
assert.NoError(t, err)
assert.Equal(t, test.expectedResult, userInfo.Groups)
})
}
})
userInfo, err := provider.UserInfo(context.Background(), ts.Client(), token)
assert.NoError(t, err)
assert.Equal(t, test.expectedResult, userInfo.Groups)
})
}
}
func TestPayloadCompression(t *testing.T) {
provider := NewGenericOAuthProvider(&social.OAuthInfo{
EmailAttributePath: "email",
}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
}, &setting.Cfg{}, nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
tests := []struct {
Name string
@ -707,7 +922,7 @@ func TestSocialGenericOAuth_InitializeExtraFields(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewGenericOAuthProvider(tc.settings, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
s := NewGenericOAuthProvider(tc.settings, &setting.Cfg{}, nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
require.Equal(t, tc.want.nameAttributePath, s.nameAttributePath)
require.Equal(t, tc.want.loginAttributePath, s.loginAttributePath)
@ -870,7 +1085,7 @@ func TestSocialGenericOAuth_Validate(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewGenericOAuthProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
s := NewGenericOAuthProvider(&social.OAuthInfo{}, &setting.Cfg{}, nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
if tc.requester == nil {
tc.requester = &user.SignedInUser{IsGrafanaAdmin: false}
@ -950,7 +1165,7 @@ func TestSocialGenericOAuth_Reload(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewGenericOAuthProvider(tc.info, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
s := NewGenericOAuthProvider(tc.info, &setting.Cfg{}, nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
err := s.Reload(context.Background(), tc.settings)
if tc.expectError {
@ -1048,7 +1263,7 @@ func TestGenericOAuth_Reload_ExtraFields(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewGenericOAuthProvider(tc.info, setting.NewCfg(), &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
s := NewGenericOAuthProvider(tc.info, setting.NewCfg(), nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
err := s.Reload(context.Background(), tc.settings)
require.NoError(t, err)

View File

@ -61,12 +61,12 @@ var (
"User is not a member of one of the required organizations. Please contact identity provider administrator."))
)
func NewGitHubProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGithub {
func NewGitHubProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper *OrgRoleMapper, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGithub {
teamIdsSplitted := util.SplitString(info.Extra[teamIdsKey])
teamIds := mustInts(teamIdsSplitted)
provider := &SocialGithub{
SocialBase: newSocialBase(social.GitHubProviderName, info, features, cfg),
SocialBase: newSocialBase(social.GitHubProviderName, orgRoleMapper, info, features, cfg),
teamIds: teamIds,
allowedOrganizations: util.SplitString(info.Extra[allowedOrganizationsKey]),
}
@ -127,7 +127,7 @@ func (s *SocialGithub) Reload(ctx context.Context, settings ssoModels.SSOSetting
s.reloadMutex.Lock()
defer s.reloadMutex.Unlock()
s.updateInfo(social.GitHubProviderName, newInfo)
s.updateInfo(ctx, social.GitHubProviderName, newInfo)
s.teamIds = teamIds
s.allowedOrganizations = util.SplitString(newInfo.Extra[allowedOrganizationsKey])

View File

@ -286,7 +286,7 @@ func TestSocialGitHub_UserInfo(t *testing.T) {
Extra: tt.oAuthExtraInfo,
}, &setting.Cfg{
AutoAssignOrgRole: tt.autoAssignOrgRole,
}, &ssosettingstests.MockService{},
}, nil, &ssosettingstests.MockService{},
featuremgmt.WithFeatures())
token := &oauth2.Token{
@ -368,7 +368,7 @@ func TestSocialGitHub_InitializeExtraFields(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewGitHubProvider(tc.settings, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
s := NewGitHubProvider(tc.settings, &setting.Cfg{}, nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
require.Equal(t, tc.want.teamIds, s.teamIds)
require.Equal(t, tc.want.allowedOrganizations, s.allowedOrganizations)
@ -495,7 +495,7 @@ func TestSocialGitHub_Validate(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewGitHubProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
s := NewGitHubProvider(&social.OAuthInfo{}, &setting.Cfg{}, nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
if tc.requester == nil {
tc.requester = &user.SignedInUser{IsGrafanaAdmin: false}
@ -576,7 +576,7 @@ func TestSocialGitHub_Reload(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewGitHubProvider(tc.info, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
s := NewGitHubProvider(tc.info, &setting.Cfg{}, nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
err := s.Reload(context.Background(), tc.settings)
if tc.expectError {
@ -635,7 +635,7 @@ func TestGitHub_Reload_ExtraFields(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewGitHubProvider(tc.info, setting.NewCfg(), &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
s := NewGitHubProvider(tc.info, setting.NewCfg(), nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
err := s.Reload(context.Background(), tc.settings)
require.NoError(t, err)

View File

@ -54,9 +54,9 @@ type userData struct {
IsGrafanaAdmin *bool `json:"-"`
}
func NewGitLabProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGitlab {
func NewGitLabProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper *OrgRoleMapper, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGitlab {
provider := &SocialGitlab{
SocialBase: newSocialBase(social.GitlabProviderName, info, features, cfg),
SocialBase: newSocialBase(social.GitlabProviderName, orgRoleMapper, info, features, cfg),
}
if features.IsEnabledGlobally(featuremgmt.FlagSsoSettingsApi) {
@ -92,7 +92,7 @@ func (s *SocialGitlab) Reload(ctx context.Context, settings ssoModels.SSOSetting
s.reloadMutex.Lock()
defer s.reloadMutex.Unlock()
s.updateInfo(social.GitlabProviderName, newInfo)
s.updateInfo(ctx, social.GitlabProviderName, newInfo)
return nil
}

View File

@ -45,7 +45,7 @@ const (
func TestSocialGitlab_UserInfo(t *testing.T) {
var nilPointer *bool
provider := NewGitLabProvider(&social.OAuthInfo{SkipOrgRoleSync: false}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
provider := NewGitLabProvider(&social.OAuthInfo{SkipOrgRoleSync: false}, &setting.Cfg{}, nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
type conf struct {
AllowAssignGrafanaAdmin bool
@ -363,7 +363,7 @@ func TestSocialGitlab_extractFromToken(t *testing.T) {
},
&setting.Cfg{
AutoAssignOrgRole: "",
}, &ssosettingstests.MockService{},
}, nil, &ssosettingstests.MockService{},
featuremgmt.WithFeatures())
// Test case: successful extraction
@ -454,7 +454,7 @@ func TestSocialGitlab_GetGroupsNextPage(t *testing.T) {
defer mockServer.Close()
// Create a SocialGitlab instance with the mock server URL
s := NewGitLabProvider(&social.OAuthInfo{ApiUrl: mockServer.URL}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
s := NewGitLabProvider(&social.OAuthInfo{ApiUrl: mockServer.URL}, &setting.Cfg{}, nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
// Call getGroups and verify that it returns all groups
expectedGroups := []string{"admins", "editors", "viewers", "serveradmins"}
@ -576,7 +576,7 @@ func TestSocialGitlab_Validate(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewGitLabProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
s := NewGitLabProvider(&social.OAuthInfo{}, &setting.Cfg{}, nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
if tc.requester == nil {
tc.requester = &user.SignedInUser{IsGrafanaAdmin: false}
@ -657,7 +657,7 @@ func TestSocialGitlab_Reload(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewGitLabProvider(tc.info, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
s := NewGitLabProvider(tc.info, &setting.Cfg{}, nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
err := s.Reload(context.Background(), tc.settings)
if tc.expectError {

View File

@ -48,9 +48,9 @@ type googleUserData struct {
rawJSON []byte `json:"-"`
}
func NewGoogleProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGoogle {
func NewGoogleProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper *OrgRoleMapper, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGoogle {
provider := &SocialGoogle{
SocialBase: newSocialBase(social.GoogleProviderName, info, features, cfg),
SocialBase: newSocialBase(social.GoogleProviderName, orgRoleMapper, info, features, cfg),
validateHD: MustBool(info.Extra[validateHDKey], true),
}
@ -95,7 +95,7 @@ func (s *SocialGoogle) Reload(ctx context.Context, settings ssoModels.SSOSetting
s.reloadMutex.Lock()
defer s.reloadMutex.Unlock()
s.updateInfo(social.GoogleProviderName, newInfo)
s.updateInfo(ctx, social.GoogleProviderName, newInfo)
s.validateHD = MustBool(newInfo.Extra[validateHDKey], true)
return nil

View File

@ -202,6 +202,7 @@ func TestSocialGoogle_retrieveGroups(t *testing.T) {
&setting.Cfg{
AutoAssignOrgRole: "",
},
nil,
&ssosettingstests.MockService{},
featuremgmt.WithFeatures())
@ -654,6 +655,7 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
SkipOrgRoleSync: tt.fields.skipOrgRoleSync,
},
&setting.Cfg{},
nil,
&ssosettingstests.MockService{},
featuremgmt.WithFeatures())
@ -795,7 +797,7 @@ func TestSocialGoogle_Validate(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewGoogleProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
s := NewGoogleProvider(&social.OAuthInfo{}, &setting.Cfg{}, nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
if tc.requester == nil {
tc.requester = &user.SignedInUser{IsGrafanaAdmin: false}
@ -876,7 +878,7 @@ func TestSocialGoogle_Reload(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewGoogleProvider(tc.info, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
s := NewGoogleProvider(tc.info, &setting.Cfg{}, nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
err := s.Reload(context.Background(), tc.settings)
if tc.expectError {
@ -929,7 +931,7 @@ func TestIsHDAllowed(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
info := &social.OAuthInfo{}
info.AllowedDomains = tc.allowedDomains
s := NewGoogleProvider(info, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
s := NewGoogleProvider(info, &setting.Cfg{}, nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
s.validateHD = tc.validateHD
err := s.isHDAllowed(tc.email)

View File

@ -37,14 +37,14 @@ type OrgRecord struct {
Login string `json:"login"`
}
func NewGrafanaComProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGrafanaCom {
func NewGrafanaComProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper *OrgRoleMapper, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGrafanaCom {
// Override necessary settings
info.AuthUrl = cfg.GrafanaComURL + "/oauth2/authorize"
info.TokenUrl = cfg.GrafanaComURL + "/api/oauth2/token"
info.AuthStyle = "inheader"
provider := &SocialGrafanaCom{
SocialBase: newSocialBase(social.GrafanaComProviderName, info, features, cfg),
SocialBase: newSocialBase(social.GrafanaComProviderName, orgRoleMapper, info, features, cfg),
url: cfg.GrafanaComURL,
allowedOrganizations: util.SplitString(info.Extra[allowedOrganizationsKey]),
}
@ -87,7 +87,7 @@ func (s *SocialGrafanaCom) Reload(ctx context.Context, settings ssoModels.SSOSet
s.reloadMutex.Lock()
defer s.reloadMutex.Unlock()
s.updateInfo(social.GrafanaComProviderName, newInfo)
s.updateInfo(ctx, social.GrafanaComProviderName, newInfo)
s.url = s.cfg.GrafanaComURL
s.allowedOrganizations = util.SplitString(newInfo.Extra[allowedOrganizationsKey])

View File

@ -32,7 +32,7 @@ const (
)
func TestSocialGrafanaCom_UserInfo(t *testing.T) {
provider := NewGrafanaComProvider(social.NewOAuthInfo(), &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
provider := NewGrafanaComProvider(social.NewOAuthInfo(), &setting.Cfg{}, nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
type conf struct {
skipOrgRoleSync bool
@ -130,7 +130,7 @@ func TestSocialGrafanaCom_InitializeExtraFields(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewGrafanaComProvider(tc.settings, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
s := NewGrafanaComProvider(tc.settings, &setting.Cfg{}, nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
require.Equal(t, tc.want.allowedOrganizations, s.allowedOrganizations)
})
@ -199,7 +199,7 @@ func TestSocialGrafanaCom_Validate(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewGrafanaComProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
s := NewGrafanaComProvider(&social.OAuthInfo{}, &setting.Cfg{}, nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
if tc.requester == nil {
tc.requester = &user.SignedInUser{IsGrafanaAdmin: false}
@ -299,7 +299,7 @@ func TestSocialGrafanaCom_Reload(t *testing.T) {
cfg := &setting.Cfg{
GrafanaComURL: GrafanaComURL,
}
s := NewGrafanaComProvider(tc.info, cfg, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
s := NewGrafanaComProvider(tc.info, cfg, nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
err := s.Reload(context.Background(), tc.settings)
if tc.expectError {
@ -360,7 +360,7 @@ func TestSocialGrafanaCom_Reload_ExtraFields(t *testing.T) {
cfg := &setting.Cfg{
GrafanaComURL: GrafanaComURL,
}
s := NewGrafanaComProvider(tc.info, cfg, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
s := NewGrafanaComProvider(tc.info, cfg, nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
err := s.Reload(context.Background(), tc.settings)
require.NoError(t, err)

View File

@ -46,9 +46,9 @@ type OktaClaims struct {
Name string `json:"name"`
}
func NewOktaProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialOkta {
func NewOktaProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper *OrgRoleMapper, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialOkta {
provider := &SocialOkta{
SocialBase: newSocialBase(social.OktaProviderName, info, features, cfg),
SocialBase: newSocialBase(social.OktaProviderName, orgRoleMapper, info, features, cfg),
}
if info.UseRefreshToken {
@ -88,7 +88,7 @@ func (s *SocialOkta) Reload(ctx context.Context, settings ssoModels.SSOSettings)
s.reloadMutex.Lock()
defer s.reloadMutex.Unlock()
s.updateInfo(social.OktaProviderName, newInfo)
s.updateInfo(ctx, social.OktaProviderName, newInfo)
if newInfo.UseRefreshToken {
appendUniqueScope(s.Config, social.OfflineAccessScope)
}

View File

@ -113,6 +113,7 @@ func TestSocialOkta_UserInfo(t *testing.T) {
&setting.Cfg{
AutoAssignOrgRole: tt.autoAssignOrgRole,
},
nil,
&ssosettingstests.MockService{},
featuremgmt.WithFeatures())
@ -281,7 +282,7 @@ func TestSocialOkta_Validate(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewOktaProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
s := NewOktaProvider(&social.OAuthInfo{}, &setting.Cfg{}, nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
if tc.requester == nil {
tc.requester = &user.SignedInUser{IsGrafanaAdmin: false}
@ -361,7 +362,7 @@ func TestSocialOkta_Reload(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewOktaProvider(tc.info, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
s := NewOktaProvider(tc.info, &setting.Cfg{}, nil, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
err := s.Reload(context.Background(), tc.settings)
if tc.expectError {

View File

@ -29,32 +29,39 @@ import (
type SocialBase struct {
*oauth2.Config
info *social.OAuthInfo
cfg *setting.Cfg
reloadMutex sync.RWMutex
log log.Logger
features featuremgmt.FeatureToggles
info *social.OAuthInfo
cfg *setting.Cfg
reloadMutex sync.RWMutex
log log.Logger
features featuremgmt.FeatureToggles
orgRoleMapper *OrgRoleMapper
orgMappingCfg *MappingConfiguration
}
func newSocialBase(name string,
orgRoleMapper *OrgRoleMapper,
info *social.OAuthInfo,
features featuremgmt.FeatureToggles,
cfg *setting.Cfg,
) *SocialBase {
logger := log.New("oauth." + name)
return &SocialBase{
Config: createOAuthConfig(info, cfg, name),
info: info,
log: logger,
features: features,
cfg: cfg,
Config: createOAuthConfig(info, cfg, name),
info: info,
log: logger,
features: features,
cfg: cfg,
orgRoleMapper: orgRoleMapper,
orgMappingCfg: orgRoleMapper.ParseOrgMappingSettings(context.Background(), info.OrgMapping, info.RoleAttributeStrict),
}
}
func (s *SocialBase) updateInfo(name string, info *social.OAuthInfo) {
func (s *SocialBase) updateInfo(ctx context.Context, name string, info *social.OAuthInfo) {
s.Config = createOAuthConfig(info, s.cfg, name)
s.info = info
s.orgMappingCfg = s.orgRoleMapper.ParseOrgMappingSettings(ctx, info.OrgMapping, info.RoleAttributeStrict)
}
type groupStruct struct {
@ -171,6 +178,14 @@ func (s *SocialBase) searchRole(rawJSON []byte, groups []string) (org.RoleType,
return "", false
}
func (s *SocialBase) extractOrgs(rawJSON []byte) ([]string, error) {
if s.info.OrgAttributePath == "" {
return []string{}, nil
}
return util.SearchJSONForStringSliceAttr(s.info.OrgAttributePath, rawJSON)
}
// 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() org.RoleType {

View File

@ -74,6 +74,8 @@ type OAuthInfo struct {
Name string `mapstructure:"name" toml:"name"`
RoleAttributePath string `mapstructure:"role_attribute_path" toml:"role_attribute_path"`
RoleAttributeStrict bool `mapstructure:"role_attribute_strict" toml:"role_attribute_strict"`
OrgAttributePath string `mapstructure:"org_attribute_path"`
OrgMapping []string `mapstructure:"org_mapping"`
Scopes []string `mapstructure:"scopes" toml:"scopes"`
SignoutRedirectUrl string `mapstructure:"signout_redirect_url" toml:"signout_redirect_url"`
SkipOrgRoleSync bool `mapstructure:"skip_org_role_sync" toml:"skip_org_role_sync"`
@ -104,11 +106,12 @@ type BasicUserInfo struct {
Email string
Login string
Role org.RoleType
OrgRoles map[int64]org.RoleType
IsGrafanaAdmin *bool // nil will avoid overriding user's set server admin setting
Groups []string
}
func (b *BasicUserInfo) String() string {
return fmt.Sprintf("Id: %s, Name: %s, Email: %s, Login: %s, Role: %s, Groups: %v",
b.Id, b.Name, b.Email, b.Login, b.Role, b.Groups)
return fmt.Sprintf("Id: %s, Name: %s, Email: %s, Login: %s, Role: %s, Groups: %v, OrgRoles: %v",
b.Id, b.Name, b.Email, b.Login, b.Role, b.Groups, b.OrgRoles)
}

View File

@ -42,6 +42,7 @@ func ProvideService(cfg *setting.Cfg,
usageStats usagestats.Service,
bundleRegistry supportbundles.Service,
cache remotecache.CacheStorage,
orgRoleMapper *connectors.OrgRoleMapper,
ssoSettings ssosettings.Service,
) *SocialService {
ss := &SocialService{
@ -70,7 +71,7 @@ func ProvideService(cfg *setting.Cfg,
continue
}
conn, err := createOAuthConnector(ssoSetting.Provider, info, cfg, ssoSettings, features, cache)
conn, err := createOAuthConnector(ssoSetting.Provider, info, cfg, orgRoleMapper, ssoSettings, features, cache)
if err != nil {
ss.log.Error("Failed to create OAuth provider", "error", err, "provider", ssoSetting.Provider)
continue
@ -98,7 +99,7 @@ func ProvideService(cfg *setting.Cfg,
name = social.GrafanaComProviderName
}
conn, _ := createOAuthConnector(name, info, cfg, ssoSettings, features, cache)
conn, _ := createOAuthConnector(name, info, cfg, orgRoleMapper, ssoSettings, features, cache)
ss.socialMap[name] = conn
}
@ -229,22 +230,22 @@ func (ss *SocialService) getUsageStats(ctx context.Context) (map[string]any, err
return m, nil
}
func createOAuthConnector(name string, info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles, cache remotecache.CacheStorage) (social.SocialConnector, error) {
func createOAuthConnector(name string, info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper *connectors.OrgRoleMapper, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles, cache remotecache.CacheStorage) (social.SocialConnector, error) {
switch name {
case social.AzureADProviderName:
return connectors.NewAzureADProvider(info, cfg, ssoSettings, features, cache), nil
return connectors.NewAzureADProvider(info, cfg, orgRoleMapper, ssoSettings, features, cache), nil
case social.GenericOAuthProviderName:
return connectors.NewGenericOAuthProvider(info, cfg, ssoSettings, features), nil
return connectors.NewGenericOAuthProvider(info, cfg, orgRoleMapper, ssoSettings, features), nil
case social.GitHubProviderName:
return connectors.NewGitHubProvider(info, cfg, ssoSettings, features), nil
return connectors.NewGitHubProvider(info, cfg, orgRoleMapper, ssoSettings, features), nil
case social.GitlabProviderName:
return connectors.NewGitLabProvider(info, cfg, ssoSettings, features), nil
return connectors.NewGitLabProvider(info, cfg, orgRoleMapper, ssoSettings, features), nil
case social.GoogleProviderName:
return connectors.NewGoogleProvider(info, cfg, ssoSettings, features), nil
return connectors.NewGoogleProvider(info, cfg, orgRoleMapper, ssoSettings, features), nil
case social.GrafanaComProviderName:
return connectors.NewGrafanaComProvider(info, cfg, ssoSettings, features), nil
return connectors.NewGrafanaComProvider(info, cfg, orgRoleMapper, ssoSettings, features), nil
case social.OktaProviderName:
return connectors.NewOktaProvider(info, cfg, ssoSettings, features), nil
return connectors.NewOktaProvider(info, cfg, orgRoleMapper, ssoSettings, features), nil
default:
return nil, fmt.Errorf("unknown oauth provider: %s", name)
}

View File

@ -92,7 +92,7 @@ func TestSocialService_ProvideService(t *testing.T) {
tc.setup(t, env)
}
socialService := ProvideService(cfg, env.features, &usagestats.UsageStatsMock{}, supportbundlestest.NewFakeBundleService(), remotecache.NewFakeStore(t), ssoSettingsSvc)
socialService := ProvideService(cfg, env.features, &usagestats.UsageStatsMock{}, supportbundlestest.NewFakeBundleService(), remotecache.NewFakeStore(t), nil, ssoSettingsSvc)
require.Equal(t, tc.expectedSocialMapLength, len(socialService.socialMap))
genericOAuthInfo := socialService.GetOAuthInfoProvider("generic_oauth")
@ -192,7 +192,7 @@ func TestSocialService_ProvideService_GrafanaComGrafanaNet(t *testing.T) {
cfg := setting.NewCfg()
cfg.Raw = iniFile
socialService := ProvideService(cfg, featuremgmt.WithFeatures(), &usagestats.UsageStatsMock{}, supportbundlestest.NewFakeBundleService(), remotecache.NewFakeStore(t), ssoSettingsSvc)
socialService := ProvideService(cfg, featuremgmt.WithFeatures(), &usagestats.UsageStatsMock{}, supportbundlestest.NewFakeBundleService(), remotecache.NewFakeStore(t), nil, ssoSettingsSvc)
require.EqualValues(t, tc.expectedGrafanaComOAuthInfo, socialService.GetOAuthInfoProvider("grafana_com"))
})
}

View File

@ -31,6 +31,7 @@ import (
"github.com/grafana/grafana/pkg/infra/usagestats/statscollector"
"github.com/grafana/grafana/pkg/infra/usagestats/validator"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/login/social/connectors"
"github.com/grafana/grafana/pkg/login/social/socialimpl"
"github.com/grafana/grafana/pkg/middleware/csrf"
"github.com/grafana/grafana/pkg/middleware/loggermw"
@ -381,6 +382,7 @@ var wireBasicSet = wire.NewSet(
wire.Bind(new(auth.IDService), new(*idimpl.Service)),
cloudmigrationimpl.ProvideService,
userimpl.ProvideVerifier,
connectors.ProvideOrgRoleMapper,
wire.Bind(new(user.Verifier), new(*userimpl.Verifier)),
// Kubernetes API server
grafanaapiserver.WireSet,

View File

@ -166,9 +166,15 @@ func (c *OAuth) Authenticate(ctx context.Context, r *authn.Request) (*authn.Iden
return nil, errOAuthEmailNotAllowed.Errorf("provided email is not allowed")
}
orgRoles, isGrafanaAdmin, _ := getRoles(c.cfg, func() (org.RoleType, *bool, error) {
return userInfo.Role, userInfo.IsGrafanaAdmin, nil
})
// This is required to implement OrgRole mapping for OAuth providers step by step
switch c.providerName {
case social.GenericOAuthProviderName:
// Do nothing, GenericOAuthProvider already supports OrgRole mapping
default:
userInfo.OrgRoles, userInfo.IsGrafanaAdmin, _ = getRoles(c.cfg, func() (org.RoleType, *bool, error) {
return userInfo.Role, userInfo.IsGrafanaAdmin, nil
})
}
lookupParams := login.UserLookupParams{}
allowInsecureEmailLookup := c.settingsProviderSvc.KeyValue("auth", "oauth_allow_insecure_email_lookup").MustBool(false)
@ -180,12 +186,12 @@ func (c *OAuth) Authenticate(ctx context.Context, r *authn.Request) (*authn.Iden
Login: userInfo.Login,
Name: userInfo.Name,
Email: userInfo.Email,
IsGrafanaAdmin: isGrafanaAdmin,
IsGrafanaAdmin: userInfo.IsGrafanaAdmin,
AuthenticatedBy: c.moduleName,
AuthID: userInfo.Id,
Groups: userInfo.Groups,
OAuthToken: token,
OrgRoles: orgRoles,
OrgRoles: userInfo.OrgRoles,
ClientParams: authn.ClientParams{
SyncUser: true,
SyncTeams: true,
@ -193,7 +199,7 @@ func (c *OAuth) Authenticate(ctx context.Context, r *authn.Request) (*authn.Iden
SyncPermissions: true,
AllowSignUp: connector.IsSignupAllowed(),
// skip org role flag is checked and handled in the connector. For now we can skip the hook if no roles are passed
SyncOrgRoles: len(orgRoles) > 0,
SyncOrgRoles: len(userInfo.OrgRoles) > 0,
LookUpParams: lookupParams,
},
}, nil

View File

@ -59,6 +59,11 @@ func (f *FakeOrgService) GetByID(ctx context.Context, query *org.GetOrgByIDQuery
}
func (f *FakeOrgService) GetByName(ctx context.Context, query *org.GetOrgByNameQuery) (*org.Org, error) {
for _, expectedOrg := range f.ExpectedOrgs {
if expectedOrg != nil && expectedOrg.Name == query.Name {
return &org.Org{ID: expectedOrg.ID, Name: expectedOrg.Name}, nil
}
}
return f.ExpectedOrg, f.ExpectedError
}

View File

@ -102,6 +102,8 @@ func (s *OAuthStrategy) loadSettingsForProvider(provider string) map[string]any
"auto_login": section.Key("auto_login").MustBool(false),
"allowed_groups": section.Key("allowed_groups").Value(),
"signout_redirect_url": section.Key("signout_redirect_url").Value(),
"org_mapping": section.Key("org_mapping").Value(),
"org_attribute_path": section.Key("org_attribute_path").Value(),
}
extraKeys := extraKeysByProvider[provider]

View File

@ -52,6 +52,8 @@ var (
empty_scopes =
hosted_domain = test_hosted_domain
signout_redirect_url = test_signout_redirect_url
org_attribute_path = groups
org_mapping = Group1:*:Editor
`
expectedOAuthInfo = map[string]any{
@ -92,6 +94,8 @@ var (
"login_attribute_path": "login",
"name_attribute_path": "name",
"team_ids": "first, second",
"org_attribute_path": "groups",
"org_mapping": "Group1:*:Editor",
}
)