diff --git a/pkg/services/ssosettings/api/api.go b/pkg/services/ssosettings/api/api.go index b01a47b3a4b..33866df7547 100644 --- a/pkg/services/ssosettings/api/api.go +++ b/pkg/services/ssosettings/api/api.go @@ -2,7 +2,10 @@ package api import ( "context" + "encoding/json" "errors" + "fmt" + "hash/fnv" "net/http" "github.com/grafana/grafana/pkg/api/response" @@ -40,6 +43,22 @@ func ProvideApi( return api } +// generateFNVETag computes a FNV hash-based ETag for the SSOSettings struct +func generateFNVETag(SSOSettings *models.SSOSettings) (string, error) { + hasher := fnv.New64() + data, err := json.Marshal(SSOSettings) + if err != nil { + return "", err + } + + _, err = hasher.Write(data) + if err != nil { + return "", err + } + + return fmt.Sprintf("%x", hasher.Sum(nil)), nil +} + // RegisterAPIEndpoints Registers Endpoints on Grafana Router func (api *Api) RegisterAPIEndpoints() { api.RouteRegister.Group("/api/v1/sso-settings", func(router routing.RouteRegister) { @@ -91,18 +110,39 @@ func (api *Api) getAuthorizedList(ctx context.Context, identity identity.Request return authorizedProviders, nil } +// swagger:route GET /v1/sso-settings/{key} sso_settings getProviderSettings +// +// # Get an SSO Settings entry by Key +// +// You need to have a permission with action `settings:read` with scope `settings:auth.:*`. +// +// Responses: +// 200: okResponse +// 400: badRequestError +// 401: unauthorisedError +// 403: forbiddenError +// 404: notFoundError func (api *Api) getProviderSettings(c *contextmodel.ReqContext) response.Response { key, ok := web.Params(c.Req)[":key"] if !ok { return response.Error(http.StatusBadRequest, "Missing key", nil) } - settings, err := api.SSOSettingsService.GetForProvider(c.Req.Context(), key) + provider, err := api.SSOSettingsService.GetForProviderWithRedactedSecrets(c.Req.Context(), key) + if err != nil { - return response.Error(http.StatusNotFound, "The provider was not found", err) + if errors.Is(err, ssosettings.ErrNotFound) { + return response.Error(http.StatusNotFound, "The provider was not found", err) + } + return response.Error(http.StatusInternalServerError, "Failed to get provider settings", err) } - return response.JSON(http.StatusOK, settings) + etag, err := generateFNVETag(provider) + if err != nil { + return response.Error(http.StatusInternalServerError, "Failed to get provider settings", err) + } + + return response.JSON(http.StatusOK, provider).SetHeader("ETag", etag) } // swagger:route PUT /v1/sso-settings/{key} sso_settings updateProviderSettings @@ -172,6 +212,13 @@ func (api *Api) removeProviderSettings(c *contextmodel.ReqContext) response.Resp return response.JSON(http.StatusNoContent, nil) } +// swagger:parameters getProviderSettings +type GetProviderSettingsWrapper struct { + // in:path + // required:true + Provider string `json:"key"` +} + // swagger:parameters updateProviderSettings type UpdateProviderSettingsParams struct { // in:path diff --git a/pkg/services/ssosettings/api/api_test.go b/pkg/services/ssosettings/api/api_test.go index 94fc299d315..9d94967119d 100644 --- a/pkg/services/ssosettings/api/api_test.go +++ b/pkg/services/ssosettings/api/api_test.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "testing" + "time" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -253,6 +254,119 @@ func TestSSOSettingsAPI_Delete(t *testing.T) { } } +func TestSSOSettingsAPI_GetForProvider(t *testing.T) { + type TestCase struct { + desc string + key string + action string + scope string + expectedResult *models.SSOSettings + expectedError error + expectedServiceCall bool + expectedStatusCode int + } + + tests := []TestCase{ + { + desc: "successfully gets SSO settings", + key: "azuread", + action: "settings:read", + scope: "settings:auth.azuread:*", + expectedResult: &models.SSOSettings{ + ID: "1", + Provider: "azuread", + Settings: make(map[string]interface{}), + Created: time.Now(), + Updated: time.Now(), + IsDeleted: false, + Source: models.DB, + }, + expectedError: nil, + expectedServiceCall: true, + expectedStatusCode: http.StatusOK, + }, + { + desc: "fails when action doesn't match", + key: "azuread", + action: "settings:write", + scope: "settings:auth.azuread:*", + expectedResult: nil, + expectedError: nil, + expectedServiceCall: false, + expectedStatusCode: http.StatusForbidden, + }, + { + desc: "fails when scope doesn't match", + key: "azuread", + action: "settings:read", + scope: "settings:auth.azuread:write", + expectedResult: nil, + expectedError: nil, + expectedServiceCall: false, + expectedStatusCode: http.StatusForbidden, + }, + { + desc: "fails when scope contains another provider", + key: "azuread", + action: "settings:read", + scope: "settings:auth.github:*", + expectedResult: nil, + expectedError: nil, + expectedServiceCall: false, + expectedStatusCode: http.StatusForbidden, + }, + { + desc: "fails with not found when key was not found", + key: "nonexistant", + action: "settings:read", + scope: "settings:auth.nonexistant:*", + expectedResult: nil, + expectedError: ssosettings.ErrNotFound, + expectedServiceCall: true, + expectedStatusCode: http.StatusNotFound, + }, + { + desc: "fails with internal server error when service returns an error", + key: "azuread", + action: "settings:read", + scope: "settings:auth.azuread:*", + expectedResult: nil, + expectedError: errors.New("something went wrong"), + expectedServiceCall: true, + expectedStatusCode: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + service := ssosettingstests.NewMockService(t) + if tt.expectedServiceCall { + service.On("GetForProviderWithRedactedSecrets", mock.AnythingOfType("*context.valueCtx"), tt.key).Return(tt.expectedResult, tt.expectedError).Once() + } + server := setupTests(t, service) + + path := fmt.Sprintf("/api/v1/sso-settings/%s", tt.key) + 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) + + if tt.expectedError == nil { + var data models.SSOSettings + require.NoError(t, json.NewDecoder(res.Body).Decode(&data)) + } + + require.NoError(t, res.Body.Close()) + }) + } +} + 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/models/models.go b/pkg/services/ssosettings/models/models.go index 405c57fd34b..3dd364de52a 100644 --- a/pkg/services/ssosettings/models/models.go +++ b/pkg/services/ssosettings/models/models.go @@ -26,6 +26,23 @@ func (s SettingsSource) MarshalJSON() ([]byte, error) { } } +func (s *SettingsSource) UnmarshalJSON(data []byte) error { + var source string + if err := json.Unmarshal(data, &source); err != nil { + return err + } + + switch source { + case "database": + *s = DB + case "system": + *s = System + default: + return fmt.Errorf("unknown source: %s", source) + } + return nil +} + type SSOSettings struct { ID string `xorm:"id pk" json:"id"` Provider string `xorm:"provider" json:"provider"` diff --git a/pkg/services/ssosettings/ssosettings.go b/pkg/services/ssosettings/ssosettings.go index e1c194cd2c6..59aa8a44a1b 100644 --- a/pkg/services/ssosettings/ssosettings.go +++ b/pkg/services/ssosettings/ssosettings.go @@ -23,6 +23,8 @@ type Service interface { List(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 + GetForProviderWithRedactedSecrets(ctx context.Context, provider string) (*models.SSOSettings, error) // Upsert creates or updates the SSO settings for a given provider Upsert(ctx context.Context, settings models.SSOSettings) error // Delete deletes the SSO settings for a given provider (soft delete) diff --git a/pkg/services/ssosettings/ssosettingsimpl/service.go b/pkg/services/ssosettings/ssosettingsimpl/service.go index 5a8c6d2b26b..6e3b50c80a1 100644 --- a/pkg/services/ssosettings/ssosettingsimpl/service.go +++ b/pkg/services/ssosettings/ssosettingsimpl/service.go @@ -84,6 +84,21 @@ func (s *SSOSettingsService) GetForProvider(ctx context.Context, provider string return storeSettings, nil } +func (s *SSOSettingsService) GetForProviderWithRedactedSecrets(ctx context.Context, provider string) (*models.SSOSettings, error) { + storeSettings, err := s.GetForProvider(ctx, provider) + if err != nil { + return nil, err + } + + for k, v := range storeSettings.Settings { + if isSecret(k) && v != "" { + storeSettings.Settings[k] = "*********" + } + } + + return storeSettings, nil +} + func (s *SSOSettingsService) List(ctx context.Context) ([]*models.SSOSettings, error) { result := make([]*models.SSOSettings, 0, len(ssosettings.AllOAuthProviders)) storedSettings, err := s.store.List(ctx) diff --git a/pkg/services/ssosettings/ssosettingsimpl/service_test.go b/pkg/services/ssosettings/ssosettingsimpl/service_test.go index 801d851787d..17fe14ec1aa 100644 --- a/pkg/services/ssosettings/ssosettingsimpl/service_test.go +++ b/pkg/services/ssosettings/ssosettingsimpl/service_test.go @@ -103,6 +103,99 @@ func TestSSOSettingsService_GetForProvider(t *testing.T) { } } +func TestSSOSettingsService_GetForProviderWithRedactedSecrets(t *testing.T) { + testCases := []struct { + name string + setup func(env testEnv) + want *models.SSOSettings + wantErr bool + }{ + { + name: "should return successfully and redact secrets", + setup: func(env testEnv) { + env.store.ExpectedSSOSetting = &models.SSOSettings{ + Provider: "github", + Settings: map[string]any{ + "enabled": true, + "secret": "secret", + "client_secret": "client_secret", + "client_id": "client_id", + }, + Source: models.DB, + } + }, + want: &models.SSOSettings{ + Provider: "github", + Settings: map[string]any{ + "enabled": true, + "secret": "*********", + "client_secret": "*********", + "client_id": "client_id", + }, + }, + wantErr: false, + }, + { + name: "should return error if store returns an error different than not found", + setup: func(env testEnv) { env.store.ExpectedError = fmt.Errorf("error") }, + want: nil, + wantErr: true, + }, + { + name: "should fallback to strategy if store returns not found", + setup: func(env testEnv) { + env.store.ExpectedError = ssosettings.ErrNotFound + env.fallbackStrategy.ExpectedIsMatch = true + env.fallbackStrategy.ExpectedConfig = map[string]any{"enabled": true} + }, + want: &models.SSOSettings{ + Provider: "github", + Settings: map[string]any{"enabled": true}, + Source: models.System, + }, + wantErr: false, + }, + { + name: "should return error if the fallback strategy was not found", + setup: func(env testEnv) { + env.store.ExpectedError = ssosettings.ErrNotFound + env.fallbackStrategy.ExpectedIsMatch = false + }, + want: nil, + wantErr: true, + }, + { + name: "should return error if fallback strategy returns error", + setup: func(env testEnv) { + env.store.ExpectedError = ssosettings.ErrNotFound + env.fallbackStrategy.ExpectedIsMatch = true + env.fallbackStrategy.ExpectedError = fmt.Errorf("error") + }, + 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.GetForProviderWithRedactedSecrets(context.Background(), "github") + + if tc.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, actual) + }) + } +} + func TestSSOSettingsService_List(t *testing.T) { testCases := []struct { name string diff --git a/pkg/services/ssosettings/ssosettingstests/reloadable_mock.go b/pkg/services/ssosettings/ssosettingstests/reloadable_mock.go index f4d8c37ccc8..e4c34dad862 100644 --- a/pkg/services/ssosettings/ssosettingstests/reloadable_mock.go +++ b/pkg/services/ssosettings/ssosettingstests/reloadable_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.37.1. DO NOT EDIT. +// Code generated by mockery v2.38.0. DO NOT EDIT. package ssosettingstests @@ -18,6 +18,10 @@ type MockReloadable struct { func (_m *MockReloadable) Reload(ctx context.Context, settings models.SSOSettings) error { ret := _m.Called(ctx, settings) + if len(ret) == 0 { + panic("no return value specified for Reload") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, models.SSOSettings) error); ok { r0 = rf(ctx, settings) @@ -32,6 +36,10 @@ func (_m *MockReloadable) Reload(ctx context.Context, settings models.SSOSetting func (_m *MockReloadable) Validate(ctx context.Context, settings models.SSOSettings) error { ret := _m.Called(ctx, settings) + if len(ret) == 0 { + panic("no return value specified for Validate") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, models.SSOSettings) error); ok { r0 = rf(ctx, settings) diff --git a/pkg/services/ssosettings/ssosettingstests/service_mock.go b/pkg/services/ssosettings/ssosettingstests/service_mock.go index 04765452a87..e1683b2de99 100644 --- a/pkg/services/ssosettings/ssosettingstests/service_mock.go +++ b/pkg/services/ssosettings/ssosettingstests/service_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.37.1. DO NOT EDIT. +// Code generated by mockery v2.38.0. DO NOT EDIT. package ssosettingstests @@ -20,6 +20,10 @@ type MockService struct { func (_m *MockService) Delete(ctx context.Context, provider string) error { ret := _m.Called(ctx, provider) + if len(ret) == 0 { + panic("no return value specified for Delete") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { r0 = rf(ctx, provider) @@ -34,6 +38,40 @@ func (_m *MockService) Delete(ctx context.Context, provider string) error { func (_m *MockService) GetForProvider(ctx context.Context, provider string) (*models.SSOSettings, error) { ret := _m.Called(ctx, provider) + if len(ret) == 0 { + panic("no return value specified for GetForProvider") + } + + var r0 *models.SSOSettings + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (*models.SSOSettings, error)); ok { + return rf(ctx, provider) + } + if rf, ok := ret.Get(0).(func(context.Context, string) *models.SSOSettings); ok { + r0 = rf(ctx, provider) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.SSOSettings) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, provider) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetForProviderWithRedactedSecrets provides a mock function with given fields: ctx, provider +func (_m *MockService) GetForProviderWithRedactedSecrets(ctx context.Context, provider string) (*models.SSOSettings, error) { + ret := _m.Called(ctx, provider) + + if len(ret) == 0 { + panic("no return value specified for GetForProviderWithRedactedSecrets") + } + var r0 *models.SSOSettings var r1 error if rf, ok := ret.Get(0).(func(context.Context, string) (*models.SSOSettings, error)); ok { @@ -60,6 +98,10 @@ func (_m *MockService) GetForProvider(ctx context.Context, provider string) (*mo func (_m *MockService) List(ctx context.Context) ([]*models.SSOSettings, error) { ret := _m.Called(ctx) + if len(ret) == 0 { + panic("no return value specified for List") + } + var r0 []*models.SSOSettings var r1 error if rf, ok := ret.Get(0).(func(context.Context) ([]*models.SSOSettings, error)); ok { @@ -86,6 +128,10 @@ func (_m *MockService) List(ctx context.Context) ([]*models.SSOSettings, error) func (_m *MockService) Patch(ctx context.Context, provider string, data map[string]interface{}) error { ret := _m.Called(ctx, provider, data) + if len(ret) == 0 { + panic("no return value specified for Patch") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, string, map[string]interface{}) error); ok { r0 = rf(ctx, provider, data) @@ -110,6 +156,10 @@ func (_m *MockService) Reload(ctx context.Context, provider string) { func (_m *MockService) Upsert(ctx context.Context, settings models.SSOSettings) error { ret := _m.Called(ctx, settings) + if len(ret) == 0 { + panic("no return value specified for Upsert") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, models.SSOSettings) error); ok { r0 = rf(ctx, settings) diff --git a/pkg/services/ssosettings/ssosettingstests/store_mock.go b/pkg/services/ssosettings/ssosettingstests/store_mock.go index 55214c18fe8..9b04828519b 100644 --- a/pkg/services/ssosettings/ssosettingstests/store_mock.go +++ b/pkg/services/ssosettings/ssosettingstests/store_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.37.1. DO NOT EDIT. +// Code generated by mockery v2.38.0. DO NOT EDIT. package ssosettingstests @@ -18,6 +18,10 @@ type MockStore struct { func (_m *MockStore) Delete(ctx context.Context, provider string) error { ret := _m.Called(ctx, provider) + if len(ret) == 0 { + panic("no return value specified for Delete") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { r0 = rf(ctx, provider) @@ -32,6 +36,10 @@ func (_m *MockStore) Delete(ctx context.Context, provider string) error { func (_m *MockStore) Get(ctx context.Context, provider string) (*models.SSOSettings, error) { ret := _m.Called(ctx, provider) + if len(ret) == 0 { + panic("no return value specified for Get") + } + var r0 *models.SSOSettings var r1 error if rf, ok := ret.Get(0).(func(context.Context, string) (*models.SSOSettings, error)); ok { @@ -58,6 +66,10 @@ func (_m *MockStore) Get(ctx context.Context, provider string) (*models.SSOSetti func (_m *MockStore) List(ctx context.Context) ([]*models.SSOSettings, error) { ret := _m.Called(ctx) + if len(ret) == 0 { + panic("no return value specified for List") + } + var r0 []*models.SSOSettings var r1 error if rf, ok := ret.Get(0).(func(context.Context) ([]*models.SSOSettings, error)); ok { @@ -84,6 +96,10 @@ func (_m *MockStore) List(ctx context.Context) ([]*models.SSOSettings, error) { func (_m *MockStore) Patch(ctx context.Context, provider string, data map[string]interface{}) error { ret := _m.Called(ctx, provider, data) + if len(ret) == 0 { + panic("no return value specified for Patch") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, string, map[string]interface{}) error); ok { r0 = rf(ctx, provider, data) @@ -98,6 +114,10 @@ func (_m *MockStore) Patch(ctx context.Context, provider string, data map[string func (_m *MockStore) Upsert(ctx context.Context, settings models.SSOSettings) error { ret := _m.Called(ctx, settings) + if len(ret) == 0 { + panic("no return value specified for Upsert") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, models.SSOSettings) error); ok { r0 = rf(ctx, settings) diff --git a/public/api-merged.json b/public/api-merged.json index 5d5a7c73f85..d9e753501c9 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -11351,6 +11351,39 @@ } }, "/v1/sso-settings/{key}": { + "get": { + "description": "You need to have a permission with action `settings:read` with scope `settings:auth.\u003cprovider\u003e:*`.", + "tags": [ + "sso_settings" + ], + "summary": "Get an SSO Settings entry by Key", + "operationId": "getProviderSettings", + "parameters": [ + { + "type": "string", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/okResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + } + } + }, "put": { "description": "Inserts or updates the SSO Settings for a provider.\n\nYou need to have a permission with action `settings:write` and scope `settings:auth.\u003cprovider\u003e:*`.", "tags": [ diff --git a/public/openapi3.json b/public/openapi3.json index b978a762728..cd79d379d85 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -24969,6 +24969,41 @@ "sso_settings" ] }, + "get": { + "description": "You need to have a permission with action `settings:read` with scope `settings:auth.\u003cprovider\u003e:*`.", + "operationId": "getProviderSettings", + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/okResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + } + }, + "summary": "Get an SSO Settings entry by Key", + "tags": [ + "sso_settings" + ] + }, "put": { "description": "Inserts or updates the SSO Settings for a provider.\n\nYou need to have a permission with action `settings:write` and scope `settings:auth.\u003cprovider\u003e:*`.", "operationId": "updateProviderSettings",