diff --git a/pkg/services/ssosettings/api/api.go b/pkg/services/ssosettings/api/api.go index 31e68294592..e025c2786ea 100644 --- a/pkg/services/ssosettings/api/api.go +++ b/pkg/services/ssosettings/api/api.go @@ -44,9 +44,9 @@ func ProvideApi( } // generateFNVETag computes a FNV hash-based ETag for the SSOSettings struct -func generateFNVETag(SSOSettings *models.SSOSettings) (string, error) { +func generateFNVETag(input any) (string, error) { hasher := fnv.New64() - data, err := json.Marshal(SSOSettings) + data, err := json.Marshal(input) if err != nil { return "", err } @@ -76,17 +76,28 @@ func (api *Api) RegisterAPIEndpoints() { }) } +// swagger:route GET /v1/sso-settings sso_settings listAllProvidersSettings +// +// # List all SSO Settings entries +// +// You need to have a permission with action `settings:read` with scope `settings:auth.:*`. +// +// Responses: +// 200: listSSOSettingsResponse +// 400: badRequestError +// 401: unauthorisedError +// 403: forbiddenError func (api *Api) listAllProvidersSettings(c *contextmodel.ReqContext) response.Response { providers, err := api.getAuthorizedList(c.Req.Context(), c.SignedInUser) if err != nil { - return response.Error(http.StatusInternalServerError, "Failed to get providers", err) + return response.Error(http.StatusInternalServerError, "Failed to list all providers settings", err) } return response.JSON(http.StatusOK, providers) } func (api *Api) getAuthorizedList(ctx context.Context, identity identity.Requester) ([]*models.SSOSettings, error) { - allProviders, err := api.SSOSettingsService.List(ctx) + allProviders, err := api.SSOSettingsService.ListWithRedactedSecrets(ctx) if err != nil { return nil, err } @@ -212,8 +223,12 @@ func (api *Api) removeProviderSettings(c *contextmodel.ReqContext) response.Resp return response.Empty(http.StatusNoContent) } +// swagger:parameters listAllProvidersSettings +type ListAllProvidersSettingsParams struct { +} + // swagger:parameters getProviderSettings -type GetProviderSettingsWrapper struct { +type GetProviderSettingsParams struct { // in:path // required:true Provider string `json:"key"` @@ -236,6 +251,12 @@ type RemoveProviderSettingsParams struct { Provider string `json:"key"` } +// swagger:response listSSOSettingsResponse +type ListSSOSettingsResponse struct { + // in: body + Body []models.SSOSettings `json:"body"` +} + // swagger:response getSSOSettingsResponse type GetSSOSettingsResponse struct { // in: body diff --git a/pkg/services/ssosettings/api/api_test.go b/pkg/services/ssosettings/api/api_test.go index 9d94967119d..96c94f0e5b4 100644 --- a/pkg/services/ssosettings/api/api_test.go +++ b/pkg/services/ssosettings/api/api_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "testing" "time" @@ -367,6 +368,174 @@ func TestSSOSettingsAPI_GetForProvider(t *testing.T) { } } +func TestSSOSettingsAPI_List(t *testing.T) { + type TestCase struct { + desc string + action string + scope string + expectedResult []*models.SSOSettings + errFromService error + wantErr bool + expectedErrMessage string + expectedServiceCall bool + expectedStatusCode int + } + + tests := []TestCase{ + { + desc: "successfully lists SSO settings", + action: "settings:read", + scope: "settings:auth.azuread:*", + expectedResult: []*models.SSOSettings{ + { + ID: "1", + Provider: "azuread", + Settings: make(map[string]interface{}), + Source: models.DB, + }, + }, + expectedServiceCall: true, + expectedStatusCode: http.StatusOK, + }, + { + desc: "returns empty list when the user has the action but the scope doesn't match any of the providerss scope", + action: "settings:read", + scope: "settings:auth.saml:write", + expectedResult: []*models.SSOSettings{}, + expectedServiceCall: true, + expectedStatusCode: http.StatusOK, + }, + { + desc: "successfully lists SSO settings when scope contains wildcard", + action: "settings:read", + scope: "settings:*", + expectedResult: []*models.SSOSettings{ + { + ID: "1", + Provider: "azuread", + Settings: make(map[string]interface{}), + Source: models.DB, + }, + { + ID: "2", + Provider: "github", + Settings: make(map[string]interface{}), + Source: models.DB, + }, + { + ID: "3", + Provider: "okta", + Settings: make(map[string]interface{}), + Source: models.System, + }, + }, + expectedServiceCall: true, + expectedStatusCode: http.StatusOK, + }, + { + desc: "fails when action doesn't match", + action: "madeupaction:read", + scope: "madeupscope:*", + wantErr: true, + expectedErrMessage: "You'll need additional permissions to perform this action. Permissions needed: settings:read", + expectedResult: nil, + expectedServiceCall: false, + expectedStatusCode: http.StatusForbidden, + }, + { + desc: "fails with internal server error when service returns an error", + action: "settings:read", + scope: "settings:auth.azuread:*", + errFromService: errors.New("something went wrong"), + expectedResult: nil, + wantErr: true, + expectedErrMessage: "Failed to list all providers settings", + expectedServiceCall: true, + expectedStatusCode: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + service := ssosettingstests.NewMockService(t) + + serviceResult := []*models.SSOSettings{ + { + ID: "1", + Provider: "azuread", + Settings: make(map[string]interface{}), + Created: time.Now(), + Updated: time.Now(), + IsDeleted: false, + Source: models.DB, + }, + { + ID: "2", + Provider: "github", + Settings: make(map[string]interface{}), + Created: time.Now(), + Updated: time.Now(), + IsDeleted: false, + Source: models.DB, + }, + { + ID: "3", + Provider: "okta", + Settings: make(map[string]interface{}), + Created: time.Now(), + Updated: time.Now(), + IsDeleted: false, + Source: models.System, + }, + } + if tt.expectedServiceCall { + service.On("ListWithRedactedSecrets", mock.AnythingOfType("*context.valueCtx")).Return(serviceResult, tt.errFromService).Once() + } + server := setupTests(t, service) + + path := "/api/v1/sso-settings" + req := server.NewRequest(http.MethodGet, path, nil) + webtest.RequestWithSignedInUser(req, &user.SignedInUser{ + OrgRole: org.RoleEditor, + OrgID: 1, + Permissions: getPermissionsForActionAndScope(tt.action, tt.scope), + }) + res, err := server.SendJSON(req) + require.NoError(t, err) + + require.Equal(t, tt.expectedStatusCode, res.StatusCode) + + bodyBytes, err := io.ReadAll(res.Body) + if err != nil { + t.Fatalf("Failed to read response body: %v", err) + } + + if tt.wantErr { + var accessErrorResponse struct { + AccessErrorID string `json:"accessErrorId"` + Message string `json:"message"` + Title string `json:"title"` + } + err = json.Unmarshal(bodyBytes, &accessErrorResponse) + if err != nil { + t.Fatalf("Failed to unmarshal response body into accessErrorResponse: %v", err) + } + + require.Equal(t, tt.expectedErrMessage, accessErrorResponse.Message) + return + } + + var actual []*models.SSOSettings + err = json.Unmarshal(bodyBytes, &actual) + require.NoError(t, err) + + require.ElementsMatch(t, tt.expectedResult, actual) + err = res.Body.Close() + require.NoError(t, err) + }) + } +} + func getPermissionsForActionAndScope(action, scope string) map[int64]map[string][]string { return map[int64]map[string][]string{ 1: accesscontrol.GroupScopesByAction([]accesscontrol.Permission{{ diff --git a/pkg/services/ssosettings/ssosettings.go b/pkg/services/ssosettings/ssosettings.go index ff1ffc99c95..3a144fc7e5a 100644 --- a/pkg/services/ssosettings/ssosettings.go +++ b/pkg/services/ssosettings/ssosettings.go @@ -21,6 +21,8 @@ var ( type Service interface { // List returns all SSO settings from DB and config files List(ctx context.Context) ([]*models.SSOSettings, error) + // ListWithRedactedSecrets returns all SSO settings from DB and config files with secret values redacted + ListWithRedactedSecrets(ctx context.Context) ([]*models.SSOSettings, error) // GetForProvider returns the SSO settings for a given provider (DB or config file) GetForProvider(ctx context.Context, provider string) (*models.SSOSettings, error) // GetForProviderWithRedactedSecrets returns the SSO settings for a given provider (DB or config file) with secret values redacted diff --git a/pkg/services/ssosettings/ssosettingsimpl/service.go b/pkg/services/ssosettings/ssosettingsimpl/service.go index bc856a99f62..0a02a781757 100644 --- a/pkg/services/ssosettings/ssosettingsimpl/service.go +++ b/pkg/services/ssosettings/ssosettingsimpl/service.go @@ -94,8 +94,8 @@ func (s *SSOSettingsService) GetForProviderWithRedactedSecrets(ctx context.Conte } for k, v := range storeSettings.Settings { - if isSecret(k) && v != "" { - storeSettings.Settings[k] = setting.RedactedPassword + if strVal, ok := v.(string); ok { + storeSettings.Settings[k] = setting.RedactedValue(k, strVal) } } @@ -130,6 +130,23 @@ func (s *SSOSettingsService) List(ctx context.Context) ([]*models.SSOSettings, e return result, nil } +func (s *SSOSettingsService) ListWithRedactedSecrets(ctx context.Context) ([]*models.SSOSettings, error) { + storeSettings, err := s.List(ctx) + if err != nil { + return nil, err + } + + for _, storeSetting := range storeSettings { + for k, v := range storeSetting.Settings { + if strVal, ok := v.(string); ok { + storeSetting.Settings[k] = setting.RedactedValue(k, strVal) + } + } + } + + return storeSettings, nil +} + func (s *SSOSettingsService) Upsert(ctx context.Context, settings models.SSOSettings) error { if !isProviderConfigurable(settings.Provider) { return ssosettings.ErrInvalidProvider.Errorf("provider %s is not configurable", settings.Provider) diff --git a/pkg/services/ssosettings/ssosettingsimpl/service_test.go b/pkg/services/ssosettings/ssosettingsimpl/service_test.go index 98d6292fdf3..ec95ffae1cb 100644 --- a/pkg/services/ssosettings/ssosettingsimpl/service_test.go +++ b/pkg/services/ssosettings/ssosettingsimpl/service_test.go @@ -414,6 +414,190 @@ func TestSSOSettingsService_List(t *testing.T) { }, wantErr: false, }, + { + name: "should return error if any of the fallback strategies was not found", + setup: func(env testEnv) { + env.store.ExpectedSSOSettings = []*models.SSOSettings{} + env.fallbackStrategy.ExpectedIsMatch = false + }, + want: nil, + wantErr: true, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + env := setupTestEnv(t) + if tc.setup != nil { + tc.setup(env) + } + + actual, err := env.service.List(context.Background()) + + if tc.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.ElementsMatch(t, tc.want, actual) + }) + } +} + +func TestSSOSettingsService_ListWithRedactedSecrets(t *testing.T) { + testCases := []struct { + name string + setup func(env testEnv) + want []*models.SSOSettings + wantErr bool + }{ + { + name: "should return successfully", + setup: func(env testEnv) { + env.store.ExpectedSSOSettings = []*models.SSOSettings{ + { + Provider: "github", + Settings: map[string]any{ + "enabled": true, + "client_secret": base64.RawStdEncoding.EncodeToString([]byte("client_secret")), + "client_id": "client_id", + }, + Source: models.DB, + }, + { + Provider: "okta", + Settings: map[string]any{ + "enabled": false, + "other_secret": base64.RawStdEncoding.EncodeToString([]byte("other_secret")), + "client_id": "client_id", + }, + Source: models.DB, + }, + } + env.secrets.On("Decrypt", mock.Anything, []byte("client_secret"), mock.Anything).Return([]byte("decrypted-client-secret"), nil).Once() + env.secrets.On("Decrypt", mock.Anything, []byte("other_secret"), mock.Anything).Return([]byte("decrypted-other-secret"), nil).Once() + + env.fallbackStrategy.ExpectedIsMatch = true + env.fallbackStrategy.ExpectedConfigs = map[string]map[string]any{ + "github": { + "enabled": true, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", + }, + "okta": { + "enabled": true, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", + }, + "gitlab": { + "enabled": true, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", + }, + "generic_oauth": { + "enabled": true, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", + }, + "google": { + "enabled": true, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", + }, + "azuread": { + "enabled": true, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", + }, + "grafana_com": { + "enabled": true, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", + }, + } + }, + want: []*models.SSOSettings{ + { + Provider: "github", + Settings: map[string]any{ + "enabled": true, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", + }, + Source: models.DB, + }, + { + Provider: "okta", + Settings: map[string]any{ + "enabled": false, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", + "other_secret": "*********", + }, + Source: models.DB, + }, + { + Provider: "gitlab", + Settings: map[string]any{ + "enabled": true, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", + }, + Source: models.System, + }, + { + Provider: "generic_oauth", + Settings: map[string]any{ + "enabled": true, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", + }, + Source: models.System, + }, + { + Provider: "google", + Settings: map[string]any{ + "enabled": true, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", + }, + Source: models.System, + }, + { + Provider: "azuread", + Settings: map[string]any{ + "enabled": true, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", + }, + Source: models.System, + }, + { + Provider: "grafana_com", + Settings: map[string]any{ + "enabled": true, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", + }, + Source: models.System, + }, + }, + wantErr: false, + }, { name: "should return error if store returns an error", setup: func(env testEnv) { env.store.ExpectedError = fmt.Errorf("error") }, @@ -427,63 +611,119 @@ func TestSSOSettingsService_List(t *testing.T) { env.fallbackStrategy.ExpectedIsMatch = true env.fallbackStrategy.ExpectedConfigs = map[string]map[string]any{ "github": { - "enabled": false, + "enabled": false, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", }, "okta": { - "enabled": false, + "enabled": false, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", }, "gitlab": { - "enabled": false, + "enabled": false, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", }, "generic_oauth": { - "enabled": false, + "enabled": false, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", }, "google": { - "enabled": false, + "enabled": false, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", }, "azuread": { - "enabled": false, + "enabled": false, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", }, "grafana_com": { - "enabled": false, + "enabled": false, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", }, } }, want: []*models.SSOSettings{ { Provider: "github", - Settings: map[string]any{"enabled": false}, - Source: models.System, + Settings: map[string]any{ + "enabled": false, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", + }, + Source: models.System, }, { Provider: "okta", - Settings: map[string]any{"enabled": false}, - Source: models.System, + Settings: map[string]any{ + "enabled": false, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", + }, + Source: models.System, }, { Provider: "gitlab", - Settings: map[string]any{"enabled": false}, - Source: models.System, + Settings: map[string]any{ + "enabled": false, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", + }, + Source: models.System, }, { Provider: "generic_oauth", - Settings: map[string]any{"enabled": false}, - Source: models.System, + Settings: map[string]any{ + "enabled": false, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", + }, + Source: models.System, }, { Provider: "google", - Settings: map[string]any{"enabled": false}, - Source: models.System, + Settings: map[string]any{ + "enabled": false, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", + }, + Source: models.System, }, { Provider: "azuread", - Settings: map[string]any{"enabled": false}, - Source: models.System, + Settings: map[string]any{ + "enabled": false, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", + }, + Source: models.System, }, { Provider: "grafana_com", - Settings: map[string]any{"enabled": false}, - Source: models.System, + Settings: map[string]any{ + "enabled": false, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", + }, + Source: models.System, }, }, wantErr: false, @@ -505,7 +745,7 @@ func TestSSOSettingsService_List(t *testing.T) { tc.setup(env) } - actual, err := env.service.List(context.Background()) + actual, err := env.service.ListWithRedactedSecrets(context.Background()) if tc.wantErr { require.Error(t, err) diff --git a/pkg/services/ssosettings/ssosettingstests/service_mock.go b/pkg/services/ssosettings/ssosettingstests/service_mock.go index e1683b2de99..d950697bb39 100644 --- a/pkg/services/ssosettings/ssosettingstests/service_mock.go +++ b/pkg/services/ssosettings/ssosettingstests/service_mock.go @@ -124,6 +124,36 @@ func (_m *MockService) List(ctx context.Context) ([]*models.SSOSettings, error) return r0, r1 } +// ListWithRedactedSecrets provides a mock function with given fields: ctx +func (_m *MockService) ListWithRedactedSecrets(ctx context.Context) ([]*models.SSOSettings, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for ListWithRedactedSecrets") + } + + var r0 []*models.SSOSettings + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) ([]*models.SSOSettings, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) []*models.SSOSettings); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.SSOSettings) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Patch provides a mock function with given fields: ctx, provider, data func (_m *MockService) Patch(ctx context.Context, provider string, data map[string]interface{}) error { ret := _m.Called(ctx, provider, data) diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index c972504d2a3..b650691c5d5 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -602,6 +602,7 @@ func RedactedValue(key, value string) string { "ACCOUNT_KEY", "ENCRYPTION_KEY", "VAULT_TOKEN", + "CLIENT_SECRET", } { if match, err := regexp.MatchString(pattern, uppercased); match && err == nil { return RedactedPassword diff --git a/public/api-merged.json b/public/api-merged.json index ad2ae396d5a..2120a49820f 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -11383,6 +11383,30 @@ } } }, + "/v1/sso-settings": { + "get": { + "description": "You need to have a permission with action `settings:read` with scope `settings:auth.\u003cprovider\u003e:*`.", + "tags": [ + "sso_settings" + ], + "summary": "List all SSO Settings entries", + "operationId": "listAllProvidersSettings", + "responses": { + "200": { + "$ref": "#/responses/listSSOSettingsResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + } + } + } + }, "/v1/sso-settings/{key}": { "get": { "description": "You need to have a permission with action `settings:read` with scope `settings:auth.\u003cprovider\u003e:*`.", @@ -23124,6 +23148,15 @@ } } }, + "listSSOSettingsResponse": { + "description": "(empty)", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/SSOSettings" + } + } + }, "listSortOptionsResponse": { "description": "(empty)", "schema": { diff --git a/public/openapi3.json b/public/openapi3.json index d7a2ea385dd..32fc629e6fc 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -1433,6 +1433,19 @@ }, "description": "(empty)" }, + "listSSOSettingsResponse": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/SSOSettings" + }, + "type": "array" + } + } + }, + "description": "(empty)" + }, "listSortOptionsResponse": { "content": { "application/json": { @@ -25053,6 +25066,30 @@ ] } }, + "/v1/sso-settings": { + "get": { + "description": "You need to have a permission with action `settings:read` with scope `settings:auth.\u003cprovider\u003e:*`.", + "operationId": "listAllProvidersSettings", + "responses": { + "200": { + "$ref": "#/components/responses/listSSOSettingsResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + } + }, + "summary": "List all SSO Settings entries", + "tags": [ + "sso_settings" + ] + } + }, "/v1/sso-settings/{key}": { "delete": { "description": "Removes the SSO Settings for a provider.\n\nYou need to have a permission with action `settings:write` and scope `settings:auth.\u003cprovider\u003e:*`.",