From b174c1310a7fd156fac07cd8280eb7fa406be661 Mon Sep 17 00:00:00 2001 From: Misi Date: Thu, 4 Jul 2024 16:00:56 +0200 Subject: [PATCH] Auth: Add organization mapping configuration to the UI (#90003) * Add org_mapping and org_attribute_path to the UI * Add validators, allow setting org mapping to only Grafana Admins * comment * Address feedback, improve validation, fix FE test, lint --- pkg/login/social/connectors/azuread_oauth.go | 11 +- pkg/login/social/connectors/generic_oauth.go | 11 +- pkg/login/social/connectors/github_oauth.go | 11 +- pkg/login/social/connectors/gitlab_oauth.go | 11 +- pkg/login/social/connectors/google_oauth.go | 10 +- .../social/connectors/grafana_com_oauth.go | 11 +- pkg/login/social/connectors/okta_oauth.go | 11 +- pkg/login/social/connectors/social_base.go | 8 +- .../validation/oauth_validators.go | 32 +++- .../validation/oauth_validators_test.go | 163 ++++++++++++++++-- .../auth-config/ProviderConfigForm.test.tsx | 13 ++ public/app/features/auth-config/fields.tsx | 51 +++++- public/app/features/auth-config/types.ts | 3 + public/app/features/auth-config/utils/data.ts | 11 +- 14 files changed, 313 insertions(+), 44 deletions(-) diff --git a/pkg/login/social/connectors/azuread_oauth.go b/pkg/login/social/connectors/azuread_oauth.go index cd91c95e922..484fb2fd3b8 100644 --- a/pkg/login/social/connectors/azuread_oauth.go +++ b/pkg/login/social/connectors/azuread_oauth.go @@ -189,13 +189,18 @@ func (s *SocialAzureAD) Reload(ctx context.Context, settings ssoModels.SSOSettin return nil } -func (s *SocialAzureAD) Validate(ctx context.Context, settings ssoModels.SSOSettings, _ ssoModels.SSOSettings, requester identity.Requester) error { - info, err := CreateOAuthInfoFromKeyValues(settings.Settings) +func (s *SocialAzureAD) Validate(ctx context.Context, newSettings ssoModels.SSOSettings, oldSettings ssoModels.SSOSettings, requester identity.Requester) error { + info, err := CreateOAuthInfoFromKeyValues(newSettings.Settings) if err != nil { return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) } - err = validateInfo(info, requester) + oldInfo, err := CreateOAuthInfoFromKeyValues(oldSettings.Settings) + if err != nil { + oldInfo = &social.OAuthInfo{} + } + + err = validateInfo(info, oldInfo, requester) if err != nil { return err } diff --git a/pkg/login/social/connectors/generic_oauth.go b/pkg/login/social/connectors/generic_oauth.go index e9d7d1513eb..84a8e00d3fd 100644 --- a/pkg/login/social/connectors/generic_oauth.go +++ b/pkg/login/social/connectors/generic_oauth.go @@ -75,13 +75,18 @@ func NewGenericOAuthProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMa return provider } -func (s *SocialGenericOAuth) Validate(ctx context.Context, settings ssoModels.SSOSettings, _ ssoModels.SSOSettings, requester identity.Requester) error { - info, err := CreateOAuthInfoFromKeyValues(settings.Settings) +func (s *SocialGenericOAuth) Validate(ctx context.Context, newSettings ssoModels.SSOSettings, oldSettings ssoModels.SSOSettings, requester identity.Requester) error { + info, err := CreateOAuthInfoFromKeyValues(newSettings.Settings) if err != nil { return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) } - err = validateInfo(info, requester) + oldInfo, err := CreateOAuthInfoFromKeyValues(oldSettings.Settings) + if err != nil { + oldInfo = &social.OAuthInfo{} + } + + err = validateInfo(info, oldInfo, requester) if err != nil { return err } diff --git a/pkg/login/social/connectors/github_oauth.go b/pkg/login/social/connectors/github_oauth.go index 80e840e80e4..348e59f1ddd 100644 --- a/pkg/login/social/connectors/github_oauth.go +++ b/pkg/login/social/connectors/github_oauth.go @@ -82,13 +82,18 @@ func NewGitHubProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper * return provider } -func (s *SocialGithub) Validate(ctx context.Context, settings ssoModels.SSOSettings, _ ssoModels.SSOSettings, requester identity.Requester) error { - info, err := CreateOAuthInfoFromKeyValues(settings.Settings) +func (s *SocialGithub) Validate(ctx context.Context, newSettings ssoModels.SSOSettings, oldSettings ssoModels.SSOSettings, requester identity.Requester) error { + info, err := CreateOAuthInfoFromKeyValues(newSettings.Settings) if err != nil { return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) } - err = validateInfo(info, requester) + oldInfo, err := CreateOAuthInfoFromKeyValues(oldSettings.Settings) + if err != nil { + oldInfo = &social.OAuthInfo{} + } + + err = validateInfo(info, oldInfo, requester) if err != nil { return err } diff --git a/pkg/login/social/connectors/gitlab_oauth.go b/pkg/login/social/connectors/gitlab_oauth.go index 357ab458611..d51544dd7c7 100644 --- a/pkg/login/social/connectors/gitlab_oauth.go +++ b/pkg/login/social/connectors/gitlab_oauth.go @@ -64,13 +64,18 @@ func NewGitLabProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper * return provider } -func (s *SocialGitlab) Validate(ctx context.Context, settings ssoModels.SSOSettings, _ ssoModels.SSOSettings, requester identity.Requester) error { - info, err := CreateOAuthInfoFromKeyValues(settings.Settings) +func (s *SocialGitlab) Validate(ctx context.Context, newSettings ssoModels.SSOSettings, oldSettings ssoModels.SSOSettings, requester identity.Requester) error { + info, err := CreateOAuthInfoFromKeyValues(newSettings.Settings) if err != nil { return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) } - err = validateInfo(info, requester) + oldInfo, err := CreateOAuthInfoFromKeyValues(oldSettings.Settings) + if err != nil { + oldInfo = &social.OAuthInfo{} + } + + err = validateInfo(info, oldInfo, requester) if err != nil { return err } diff --git a/pkg/login/social/connectors/google_oauth.go b/pkg/login/social/connectors/google_oauth.go index ac369137ecf..3044548948b 100644 --- a/pkg/login/social/connectors/google_oauth.go +++ b/pkg/login/social/connectors/google_oauth.go @@ -65,13 +65,17 @@ func NewGoogleProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper * return provider } -func (s *SocialGoogle) Validate(ctx context.Context, settings ssoModels.SSOSettings, _ ssoModels.SSOSettings, requester identity.Requester) error { - info, err := CreateOAuthInfoFromKeyValues(settings.Settings) +func (s *SocialGoogle) Validate(ctx context.Context, newSettings ssoModels.SSOSettings, oldSettings ssoModels.SSOSettings, requester identity.Requester) error { + info, err := CreateOAuthInfoFromKeyValues(newSettings.Settings) if err != nil { return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) } + oldInfo, err := CreateOAuthInfoFromKeyValues(oldSettings.Settings) + if err != nil { + oldInfo = &social.OAuthInfo{} + } - err = validateInfo(info, requester) + err = validateInfo(info, oldInfo, requester) if err != nil { return err } diff --git a/pkg/login/social/connectors/grafana_com_oauth.go b/pkg/login/social/connectors/grafana_com_oauth.go index b6d4b32d3fa..1ded4a18f7b 100644 --- a/pkg/login/social/connectors/grafana_com_oauth.go +++ b/pkg/login/social/connectors/grafana_com_oauth.go @@ -54,13 +54,18 @@ func NewGrafanaComProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapp return provider } -func (s *SocialGrafanaCom) Validate(ctx context.Context, settings ssoModels.SSOSettings, _ ssoModels.SSOSettings, requester identity.Requester) error { - info, err := CreateOAuthInfoFromKeyValues(settings.Settings) +func (s *SocialGrafanaCom) Validate(ctx context.Context, newSettings ssoModels.SSOSettings, oldSettings ssoModels.SSOSettings, requester identity.Requester) error { + info, err := CreateOAuthInfoFromKeyValues(newSettings.Settings) if err != nil { return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) } - err = validateInfo(info, requester) + oldInfo, err := CreateOAuthInfoFromKeyValues(oldSettings.Settings) + if err != nil { + oldInfo = &social.OAuthInfo{} + } + + err = validateInfo(info, oldInfo, requester) if err != nil { return err } diff --git a/pkg/login/social/connectors/okta_oauth.go b/pkg/login/social/connectors/okta_oauth.go index 4110ef5fec7..b126c2acd1d 100644 --- a/pkg/login/social/connectors/okta_oauth.go +++ b/pkg/login/social/connectors/okta_oauth.go @@ -61,13 +61,18 @@ func NewOktaProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper *Or return provider } -func (s *SocialOkta) Validate(ctx context.Context, settings ssoModels.SSOSettings, _ ssoModels.SSOSettings, requester identity.Requester) error { - info, err := CreateOAuthInfoFromKeyValues(settings.Settings) +func (s *SocialOkta) Validate(ctx context.Context, newSettings ssoModels.SSOSettings, oldSettings ssoModels.SSOSettings, requester identity.Requester) error { + info, err := CreateOAuthInfoFromKeyValues(newSettings.Settings) if err != nil { return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) } - err = validateInfo(info, requester) + oldInfo, err := CreateOAuthInfoFromKeyValues(oldSettings.Settings) + if err != nil { + oldInfo = &social.OAuthInfo{} + } + + err = validateInfo(info, oldInfo, requester) if err != nil { return err } diff --git a/pkg/login/social/connectors/social_base.go b/pkg/login/social/connectors/social_base.go index 92fe4b6d6e7..46410c985eb 100644 --- a/pkg/login/social/connectors/social_base.go +++ b/pkg/login/social/connectors/social_base.go @@ -259,9 +259,11 @@ func getRoleFromSearch(role string) (org.RoleType, bool) { return org.RoleType(cases.Title(language.Und).String(role)), false } -func validateInfo(info *social.OAuthInfo, requester identity.Requester) error { +func validateInfo(info *social.OAuthInfo, oldInfo *social.OAuthInfo, requester identity.Requester) error { return validation.Validate(info, requester, validation.RequiredValidator(info.ClientId, "Client Id"), - validation.AllowAssignGrafanaAdminValidator, - validation.SkipOrgRoleSyncAllowAssignGrafanaAdminValidator) + validation.AllowAssignGrafanaAdminValidator(info, oldInfo, requester), + validation.SkipOrgRoleSyncAllowAssignGrafanaAdminValidator, + validation.OrgAttributePathValidator(info, oldInfo, requester), + validation.OrgMappingValidator(info, oldInfo, requester)) } diff --git a/pkg/services/ssosettings/validation/oauth_validators.go b/pkg/services/ssosettings/validation/oauth_validators.go index 56fdf53f459..7899bce8706 100644 --- a/pkg/services/ssosettings/validation/oauth_validators.go +++ b/pkg/services/ssosettings/validation/oauth_validators.go @@ -3,6 +3,7 @@ package validation import ( "fmt" "net/url" + "slices" "strings" "github.com/grafana/grafana/pkg/apimachinery/identity" @@ -10,11 +11,34 @@ import ( "github.com/grafana/grafana/pkg/services/ssosettings" ) -func AllowAssignGrafanaAdminValidator(info *social.OAuthInfo, requester identity.Requester) error { - if info.AllowAssignGrafanaAdmin && !requester.GetIsGrafanaAdmin() { - return ssosettings.ErrInvalidOAuthConfig("Allow assign Grafana Admin can only be updated by Grafana Server Admins.") +func AllowAssignGrafanaAdminValidator(info *social.OAuthInfo, oldInfo *social.OAuthInfo, requester identity.Requester) ssosettings.ValidateFunc[social.OAuthInfo] { + return func(info *social.OAuthInfo, requester identity.Requester) error { + hasChanged := info.AllowAssignGrafanaAdmin != oldInfo.AllowAssignGrafanaAdmin + if hasChanged && !requester.GetIsGrafanaAdmin() { + return ssosettings.ErrInvalidOAuthConfig("Allow assign Grafana Admin can only be updated by Grafana Server Admins.") + } + return nil + } +} + +func OrgMappingValidator(info *social.OAuthInfo, oldInfo *social.OAuthInfo, requester identity.Requester) ssosettings.ValidateFunc[social.OAuthInfo] { + return func(info *social.OAuthInfo, requester identity.Requester) error { + hasChanged := !slices.Equal(oldInfo.OrgMapping, info.OrgMapping) + if hasChanged && !requester.GetIsGrafanaAdmin() { + return ssosettings.ErrInvalidOAuthConfig("Organization mapping can only be updated by Grafana Server Admins.") + } + return nil + } +} + +func OrgAttributePathValidator(info *social.OAuthInfo, oldInfo *social.OAuthInfo, requester identity.Requester) ssosettings.ValidateFunc[social.OAuthInfo] { + return func(info *social.OAuthInfo, requester identity.Requester) error { + hasChanged := info.OrgAttributePath != oldInfo.OrgAttributePath + if hasChanged && !requester.GetIsGrafanaAdmin() { + return ssosettings.ErrInvalidOAuthConfig("Organization attribute path can only be updated by Grafana Server Admins.") + } + return nil } - return nil } func SkipOrgRoleSyncAllowAssignGrafanaAdminValidator(info *social.OAuthInfo, requester identity.Requester) error { diff --git a/pkg/services/ssosettings/validation/oauth_validators_test.go b/pkg/services/ssosettings/validation/oauth_validators_test.go index 6cdea2a067d..9bb17f3eb79 100644 --- a/pkg/services/ssosettings/validation/oauth_validators_test.go +++ b/pkg/services/ssosettings/validation/oauth_validators_test.go @@ -12,10 +12,11 @@ import ( ) type testCase struct { - name string - input *social.OAuthInfo - requester identity.Requester - wantErr error + name string + input *social.OAuthInfo + oldSettings *social.OAuthInfo + requester identity.Requester + wantErr error } func TestUrlValidator(t *testing.T) { @@ -81,20 +82,39 @@ func TestRequiredValidator(t *testing.T) { func TestAllowAssignGrafanaAdminValidator(t *testing.T) { tc := []testCase{ { - name: "passes when user is grafana admin and allow assign grafana admin is true", + name: "passes when user is Grafana Admin and Allow assign Grafana Admin was changed", input: &social.OAuthInfo{ AllowAssignGrafanaAdmin: true, }, + oldSettings: &social.OAuthInfo{ + AllowAssignGrafanaAdmin: false, + }, requester: &user.SignedInUser{ IsGrafanaAdmin: true, }, wantErr: nil, }, { - name: "fails when user is not grafana admin and allow assign grafana admin is true", + name: "passess when user is not Grafana Admin and Allow assign Grafana Admin was not changed", input: &social.OAuthInfo{ AllowAssignGrafanaAdmin: true, }, + oldSettings: &social.OAuthInfo{ + AllowAssignGrafanaAdmin: true, + }, + requester: &user.SignedInUser{ + IsGrafanaAdmin: false, + }, + wantErr: nil, + }, + { + name: "fails when user is not Grafana Admin and Allow assign Grafana Admin was changed", + input: &social.OAuthInfo{ + AllowAssignGrafanaAdmin: true, + }, + oldSettings: &social.OAuthInfo{ + AllowAssignGrafanaAdmin: false, + }, requester: &user.SignedInUser{ IsGrafanaAdmin: false, }, @@ -104,7 +124,7 @@ func TestAllowAssignGrafanaAdminValidator(t *testing.T) { for _, tt := range tc { t.Run(tt.name, func(t *testing.T) { - err := AllowAssignGrafanaAdminValidator(tt.input, tt.requester) + err := AllowAssignGrafanaAdminValidator(tt.input, tt.oldSettings, tt.requester)(tt.input, tt.requester) if tt.wantErr != nil { require.ErrorIs(t, err, tt.wantErr) return @@ -117,7 +137,7 @@ func TestAllowAssignGrafanaAdminValidator(t *testing.T) { func TestSkipOrgRoleSyncAllowAssignGrafanaAdminValidator(t *testing.T) { tc := []testCase{ { - name: "passes when allow assign grafana admin is set, but skip org role sync is not set", + name: "passes when allow assign Grafana Admin is set, but skip org role sync is not set", input: &social.OAuthInfo{ AllowAssignGrafanaAdmin: true, SkipOrgRoleSync: false, @@ -125,7 +145,7 @@ func TestSkipOrgRoleSyncAllowAssignGrafanaAdminValidator(t *testing.T) { wantErr: nil, }, { - name: "passes when allow assign grafana admin is not set, but skip org role sync is set", + name: "passes when allow assign Grafana Admin is not set, but skip org role sync is set", input: &social.OAuthInfo{ AllowAssignGrafanaAdmin: false, SkipOrgRoleSync: true, @@ -133,7 +153,7 @@ func TestSkipOrgRoleSyncAllowAssignGrafanaAdminValidator(t *testing.T) { wantErr: nil, }, { - name: "fails when both allow assign grafana admin and skip org role sync is set", + name: "fails when both allow assign Grafana Admin and skip org role sync is set", input: &social.OAuthInfo{ AllowAssignGrafanaAdmin: true, SkipOrgRoleSync: true, @@ -153,3 +173,126 @@ func TestSkipOrgRoleSyncAllowAssignGrafanaAdminValidator(t *testing.T) { }) } } + +func TestOrgMappingValidator(t *testing.T) { + tc := []testCase{ + { + name: "passes when user is Grafana Admin and Org mapping was changed", + input: &social.OAuthInfo{ + OrgMapping: []string{"group1:1:Viewer"}, + }, + oldSettings: &social.OAuthInfo{ + OrgMapping: []string{"group1:2:Viewer"}, + }, + requester: &user.SignedInUser{ + IsGrafanaAdmin: true, + }, + wantErr: nil, + }, + { + name: "passes when user is not Grafana Admin and Org mapping was not changed", + input: &social.OAuthInfo{ + OrgMapping: []string{"group1:1:Viewer"}, + }, + oldSettings: &social.OAuthInfo{ + OrgMapping: []string{"group1:1:Viewer"}, + }, + requester: &user.SignedInUser{ + IsGrafanaAdmin: false, + }, + wantErr: nil, + }, + { + name: "fails when user is not Grafana Admin and Org mapping was changed", + input: &social.OAuthInfo{ + OrgMapping: []string{"group1:1:Viewer"}, + }, + oldSettings: &social.OAuthInfo{ + OrgMapping: []string{}, + }, + requester: &user.SignedInUser{ + IsGrafanaAdmin: false, + }, + wantErr: ssosettings.ErrInvalidOAuthConfig("Organization mapping can only be updated by Grafana Server Admins."), + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + err := OrgMappingValidator(tt.input, tt.oldSettings, tt.requester)(tt.input, tt.requester) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +func TestOrgAttributePathValidator(t *testing.T) { + tc := []testCase{ + { + name: "passes when user is Grafana Admin and Org attribute path was changed", + input: &social.OAuthInfo{ + OrgAttributePath: "path", + }, + oldSettings: &social.OAuthInfo{ + OrgAttributePath: "old-path", + }, + requester: &user.SignedInUser{ + IsGrafanaAdmin: true, + }, + wantErr: nil, + }, + { + name: "passes when user is Grafana Admin and Org attribute path was not changed", + input: &social.OAuthInfo{ + OrgAttributePath: "path", + }, + oldSettings: &social.OAuthInfo{ + OrgAttributePath: "path", + }, + requester: &user.SignedInUser{ + IsGrafanaAdmin: false, + }, + wantErr: nil, + }, + { + name: "fails when user is not Grafana Admin and Org attribute path casing was changed", + input: &social.OAuthInfo{ + OrgAttributePath: "path", + }, + oldSettings: &social.OAuthInfo{ + OrgAttributePath: "Path", + }, + requester: &user.SignedInUser{ + IsGrafanaAdmin: false, + }, + wantErr: ssosettings.ErrInvalidOAuthConfig("Organization attribute path can only be updated by Grafana Server Admins."), + }, + { + name: "fails when user is not Grafana Admin and Org attribute path was changed", + input: &social.OAuthInfo{ + OrgAttributePath: "path", + }, + oldSettings: &social.OAuthInfo{ + OrgAttributePath: "old-path", + }, + requester: &user.SignedInUser{ + IsGrafanaAdmin: false, + }, + wantErr: ssosettings.ErrInvalidOAuthConfig("Organization attribute path can only be updated by Grafana Server Admins."), + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + err := OrgAttributePathValidator(tt.input, tt.oldSettings, tt.requester)(tt.input, tt.requester) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + return + } + require.NoError(t, err) + }) + } +} diff --git a/public/app/features/auth-config/ProviderConfigForm.test.tsx b/public/app/features/auth-config/ProviderConfigForm.test.tsx index ad1b87ec39a..2ae4728227a 100644 --- a/public/app/features/auth-config/ProviderConfigForm.test.tsx +++ b/public/app/features/auth-config/ProviderConfigForm.test.tsx @@ -57,9 +57,18 @@ const testConfig: SSOProvider = { allowedDomains: '', allowedGroups: '', scopes: '', + orgMapping: '', }, }; +jest.mock('app/core/core', () => { + return { + contextSrv: { + isGrafanaAdmin: true, + }, + }; +}); + const emptyConfig = { ...testConfig, settings: { ...testConfig.settings, enabled: false, clientId: '', clientSecret: '' }, @@ -120,6 +129,8 @@ describe('ProviderConfigForm', () => { await user.click(screen.getByText('User mapping')); await user.type(screen.getByRole('textbox', { name: /Role attribute path/i }), 'new-attribute-path'); await user.click(screen.getByRole('checkbox', { name: /Role attribute strict mode/i })); + await user.type(screen.getByRole('combobox', { name: /Organization mapping/i }), 'Group A:1:Editor{enter}'); + await user.type(screen.getByRole('combobox', { name: /Organization mapping/i }), 'Group B:2:Admin{enter}'); await user.click(screen.getByText('Extra security measures')); await user.type(screen.getByRole('combobox', { name: /Allowed domains/i }), 'grafana.com{enter}'); @@ -143,6 +154,7 @@ describe('ProviderConfigForm', () => { clientSecret: 'test-client-secret', enabled: true, name: 'GitHub', + orgMapping: '["Group A:1:Editor","Group B:2:Admin"]', roleAttributePath: 'new-attribute-path', roleAttributeStrict: true, scopes: 'user:email', @@ -203,6 +215,7 @@ describe('ProviderConfigForm', () => { tlsClientKey: '', usePkce: false, useRefreshToken: false, + orgMapping: '', }, }, { showErrorAlert: false } diff --git a/public/app/features/auth-config/fields.tsx b/public/app/features/auth-config/fields.tsx index 6b34468b22b..171f3df0243 100644 --- a/public/app/features/auth-config/fields.tsx +++ b/public/app/features/auth-config/fields.tsx @@ -38,7 +38,7 @@ export const sectionFields: Section = { { name: 'User mapping', id: 'user', - fields: ['roleAttributePath', 'roleAttributeStrict', 'allowAssignGrafanaAdmin', 'skipOrgRoleSync'], + fields: ['roleAttributePath', 'roleAttributeStrict', 'orgMapping', 'allowAssignGrafanaAdmin', 'skipOrgRoleSync'], }, { name: 'Extra security measures', @@ -86,6 +86,8 @@ export const sectionFields: Section = { 'idTokenAttributeName', 'roleAttributePath', 'roleAttributeStrict', + 'orgMapping', + 'orgAttributePath', 'allowAssignGrafanaAdmin', 'skipOrgRoleSync', ], @@ -121,7 +123,7 @@ export const sectionFields: Section = { { name: 'User mapping', id: 'user', - fields: ['roleAttributePath', 'roleAttributeStrict', 'allowAssignGrafanaAdmin', 'skipOrgRoleSync'], + fields: ['roleAttributePath', 'roleAttributeStrict', 'orgMapping', 'allowAssignGrafanaAdmin', 'skipOrgRoleSync'], }, { name: 'Extra security measures', @@ -149,7 +151,7 @@ export const sectionFields: Section = { { name: 'User mapping', id: 'user', - fields: ['roleAttributePath', 'roleAttributeStrict', 'allowAssignGrafanaAdmin', 'skipOrgRoleSync'], + fields: ['roleAttributePath', 'roleAttributeStrict', 'orgMapping', 'allowAssignGrafanaAdmin', 'skipOrgRoleSync'], }, { name: 'Extra security measures', @@ -176,7 +178,7 @@ export const sectionFields: Section = { { name: 'User mapping', id: 'user', - fields: ['roleAttributePath', 'roleAttributeStrict', 'allowAssignGrafanaAdmin', 'skipOrgRoleSync'], + fields: ['roleAttributePath', 'roleAttributeStrict', 'orgMapping', 'allowAssignGrafanaAdmin', 'skipOrgRoleSync'], }, { name: 'Extra security measures', @@ -213,7 +215,14 @@ export const sectionFields: Section = { { name: 'User mapping', id: 'user', - fields: ['roleAttributePath', 'roleAttributeStrict', 'allowAssignGrafanaAdmin', 'skipOrgRoleSync'], + fields: [ + 'roleAttributePath', + 'roleAttributeStrict', + 'orgMapping', + 'orgAttributePath', + 'allowAssignGrafanaAdmin', + 'skipOrgRoleSync', + ], }, { name: 'Extra security measures', @@ -448,6 +457,22 @@ export function fieldMap(provider: string): Record { description: 'Prevent synchronizing users’ organization roles from your IdP.', type: 'switch', }, + orgMapping: { + label: 'Organization mapping', + description: orgMappingDescription(provider), + type: 'select', + hidden: !contextSrv.isGrafanaAdmin, + multi: true, + allowCustomValue: true, + options: [], + placeholder: 'Enter mappings (my-team:1:Viewer...) and press Enter to add', + }, + orgAttributePath: { + label: 'Organization attribute path', + description: 'JMESPath expression to use for organization lookup.', + type: 'text', + hidden: !['generic_oauth', 'okta'].includes(provider), + }, defineAllowedGroups: { label: 'Define allowed groups', type: 'switch', @@ -602,3 +627,19 @@ export function fieldMap(provider: string): Record { function isNumeric(value: string) { return /^-?\d+$/.test(value); } + +function orgMappingDescription(provider: string): string { + switch (provider) { + case 'azuread': + return 'List of "::" mappings.'; + case 'github': + return 'List of "::" mappings.'; + case 'gitlab': + return 'List of "::'; + case 'google': + return 'List of "::'; + default: + // Generic OAuth, Okta + return 'List of "::" mappings.'; + } +} diff --git a/public/app/features/auth-config/types.ts b/public/app/features/auth-config/types.ts index b139efda4fb..19c827fd356 100644 --- a/public/app/features/auth-config/types.ts +++ b/public/app/features/auth-config/types.ts @@ -36,6 +36,7 @@ export type SSOProviderSettingsBase = { roleAttributeStrict?: boolean; signoutRedirectUrl?: string; skipOrgRoleSync?: boolean; + orgAttributePath?: string; teamIdsAttributePath?: string; teamsUrl?: string; tlsClientCa?: string; @@ -70,6 +71,7 @@ export type SSOProvider = { allowedDomains?: string; allowedGroups?: string; scopes?: string; + orgMapping?: string; }; }; @@ -80,6 +82,7 @@ export type SSOProviderDTO = Partial & { allowedDomains?: Array>; allowedGroups?: Array>; scopes?: Array>; + orgMapping?: Array>; }; export interface AuthConfigState { diff --git a/public/app/features/auth-config/utils/data.ts b/public/app/features/auth-config/utils/data.ts index fa7d425939c..9b610a3cc82 100644 --- a/public/app/features/auth-config/utils/data.ts +++ b/public/app/features/auth-config/utils/data.ts @@ -51,6 +51,11 @@ const strToValue = (val: string | string[]): SelectableValue[] => { if (Array.isArray(val)) { return val.map((v) => ({ label: v, value: v })); } + // Stored as JSON Array + if (val.startsWith('[') && val.endsWith(']')) { + return JSON.parse(val).map((v: string) => ({ label: v, value: v })); + } + return val.split(/[\s,]/).map((s) => ({ label: s, value: s })); }; @@ -70,7 +75,11 @@ export function dataToDTO(data?: SSOProvider): SSOProviderDTO { } const valuesToString = (values: Array>) => { - return values.map(({ value }) => value).join(','); + if (values.length <= 1) { + return values.map(({ value }) => value).join(','); + } + // Store as JSON array if there are multiple values + return JSON.stringify(values.map(({ value }) => value)); }; const getFieldsForProvider = (provider: string) => {