From 073ef930071ddabb8f349a76e75278aabcd145ce Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Tue, 2 Jul 2024 09:50:35 +0300 Subject: [PATCH 01/19] Authn: Set requester in middleware (#89929) identify in context --- pkg/services/contexthandler/contexthandler.go | 10 +++++----- pkg/services/contexthandler/contexthandler_test.go | 11 ++++++++--- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index c6bf3137864..fe8f810872e 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -112,16 +112,16 @@ func (h *ContextHandler) Middleware(next http.Handler) http.Handler { reqContext.Logger = reqContext.Logger.New("traceID", traceID) } - identity, err := h.authnService.Authenticate(ctx, &authn.Request{HTTPRequest: reqContext.Req, Resp: reqContext.Resp}) + id, err := h.authnService.Authenticate(ctx, &authn.Request{HTTPRequest: reqContext.Req, Resp: reqContext.Resp}) if err != nil { // Hack: set all errors on LookupTokenErr, so we can check it in auth middlewares reqContext.LookupTokenErr = err } else { - reqContext.SignedInUser = identity.SignedInUser() - reqContext.UserToken = identity.SessionToken + reqContext.SignedInUser = id.SignedInUser() + reqContext.UserToken = id.SessionToken reqContext.IsSignedIn = !reqContext.SignedInUser.IsAnonymous reqContext.AllowAnonymous = reqContext.SignedInUser.IsAnonymous - reqContext.IsRenderCall = identity.IsAuthenticatedBy(login.RenderModule) + reqContext.IsRenderCall = id.IsAuthenticatedBy(login.RenderModule) } reqContext.Logger = reqContext.Logger.New("userId", reqContext.UserID, "orgId", reqContext.OrgID, "uname", reqContext.Login) @@ -138,7 +138,7 @@ func (h *ContextHandler) Middleware(next http.Handler) http.Handler { // End the span to make next handlers not wrapped within middleware span span.End() - next.ServeHTTP(w, r) + next.ServeHTTP(w, r.WithContext(identity.WithRequester(ctx, id))) }) } diff --git a/pkg/services/contexthandler/contexthandler_test.go b/pkg/services/contexthandler/contexthandler_test.go index 87a95be3cfe..0dcdf896d61 100644 --- a/pkg/services/contexthandler/contexthandler_test.go +++ b/pkg/services/contexthandler/contexthandler_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authn/authntest" @@ -44,20 +45,24 @@ func TestContextHandler(t *testing.T) { }) t.Run("should set identity on successful authentication", func(t *testing.T) { - identity := &authn.Identity{ID: authn.NewNamespaceID(authn.NamespaceUser, 1), OrgID: 1} + id := &authn.Identity{ID: authn.NewNamespaceID(authn.NamespaceUser, 1), OrgID: 1} handler := contexthandler.ProvideService( setting.NewCfg(), tracing.InitializeTracerForTest(), featuremgmt.WithFeatures(), - &authntest.FakeService{ExpectedIdentity: identity}, + &authntest.FakeService{ExpectedIdentity: id}, ) server := webtest.NewServer(t, routing.NewRouteRegister()) server.Mux.Use(handler.Middleware) server.Mux.Get("/api/handler", func(c *contextmodel.ReqContext) { require.True(t, c.IsSignedIn) - require.EqualValues(t, identity.SignedInUser(), c.SignedInUser) + require.EqualValues(t, id.SignedInUser(), c.SignedInUser) require.NoError(t, c.LookupTokenErr) + + requester, err := identity.GetRequester(c.Req.Context()) + require.NoError(t, err) + require.Equal(t, id, requester) }) res, err := server.Send(server.NewGetRequest("/api/handler")) From f1968bbcbba0a578ada244d35d3f10670abf0343 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Tue, 2 Jul 2024 11:14:09 +0200 Subject: [PATCH 02/19] Zanzana: Run OpenFGA HTTP server in standalone mode (#89914) * Zanzana: Listen http to handle fga cli requests. * make configurable * start http server during service run * wait for GRPC server is ready * remove unnecessary logs * fix linter errors * run only in devenv * make address configurable --- pkg/services/authz/zanzana.go | 10 ++++ pkg/services/authz/zanzana/server.go | 84 ++++++++++++++++++++++++++++ pkg/setting/settings_zanzana.go | 6 ++ 3 files changed, 100 insertions(+) diff --git a/pkg/services/authz/zanzana.go b/pkg/services/authz/zanzana.go index 64e7345e0ae..1c83006e7c4 100644 --- a/pkg/services/authz/zanzana.go +++ b/pkg/services/authz/zanzana.go @@ -136,6 +136,16 @@ func (z *Zanzana) start(ctx context.Context) error { } func (z *Zanzana) running(ctx context.Context) error { + if z.cfg.Env == setting.Dev && z.cfg.Zanzana.ListenHTTP { + go func() { + z.logger.Info("Starting OpenFGA HTTP server") + err := zanzana.StartOpenFGAHttpSever(z.cfg, z.handle, z.logger) + if err != nil { + z.logger.Error("failed to start OpenFGA HTTP server", "error", err) + } + }() + } + // Run is blocking so we can just run it here return z.handle.Run(ctx) } diff --git a/pkg/services/authz/zanzana/server.go b/pkg/services/authz/zanzana/server.go index 1b9d1c119c6..787ff6acab9 100644 --- a/pkg/services/authz/zanzana/server.go +++ b/pkg/services/authz/zanzana/server.go @@ -1,10 +1,27 @@ package zanzana import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + openfgav1 "github.com/openfga/api/proto/openfga/v1" + httpmiddleware "github.com/openfga/openfga/pkg/middleware/http" "github.com/openfga/openfga/pkg/server" + serverErrors "github.com/openfga/openfga/pkg/server/errors" "github.com/openfga/openfga/pkg/storage" + "github.com/rs/cors" + "go.uber.org/zap/zapcore" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + healthv1pb "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/status" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/grpcserver" + "github.com/grafana/grafana/pkg/setting" ) func NewServer(store storage.OpenFGADatastore, logger log.Logger) (*server.Server, error) { @@ -24,3 +41,70 @@ func NewServer(store storage.OpenFGADatastore, logger log.Logger) (*server.Serve return srv, nil } + +// StartOpenFGAHttpSever starts HTTP server which allows to use fga cli. +func StartOpenFGAHttpSever(cfg *setting.Cfg, srv grpcserver.Provider, logger log.Logger) error { + dialOpts := []grpc.DialOption{ + grpc.WithTransportCredentials(insecure.NewCredentials()), + } + + addr := srv.GetAddress() + // Wait until GRPC server is initialized + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + maxRetries := 100 + retries := 0 + for addr == "" && retries < maxRetries { + <-ticker.C + addr = srv.GetAddress() + retries++ + } + if addr == "" { + return fmt.Errorf("failed to start HTTP server: GRPC server unavailable") + } + + conn, err := grpc.NewClient(addr, dialOpts...) + if err != nil { + return fmt.Errorf("unable to dial GRPC: %w", err) + } + + muxOpts := []runtime.ServeMuxOption{ + runtime.WithForwardResponseOption(httpmiddleware.HTTPResponseModifier), + runtime.WithErrorHandler(func(c context.Context, + sr *runtime.ServeMux, mm runtime.Marshaler, w http.ResponseWriter, r *http.Request, e error) { + intCode := serverErrors.ConvertToEncodedErrorCode(status.Convert(e)) + httpmiddleware.CustomHTTPErrorHandler(c, w, r, serverErrors.NewEncodedError(intCode, e.Error())) + }), + runtime.WithStreamErrorHandler(func(ctx context.Context, e error) *status.Status { + intCode := serverErrors.ConvertToEncodedErrorCode(status.Convert(e)) + encodedErr := serverErrors.NewEncodedError(intCode, e.Error()) + return status.Convert(encodedErr) + }), + runtime.WithHealthzEndpoint(healthv1pb.NewHealthClient(conn)), + runtime.WithOutgoingHeaderMatcher(func(s string) (string, bool) { return s, true }), + } + mux := runtime.NewServeMux(muxOpts...) + if err := openfgav1.RegisterOpenFGAServiceHandler(context.TODO(), mux, conn); err != nil { + return fmt.Errorf("failed to register gateway handler: %w", err) + } + + httpServer := &http.Server{ + Addr: cfg.Zanzana.HttpAddr, + Handler: cors.New(cors.Options{ + AllowedOrigins: []string{"*"}, + AllowCredentials: true, + AllowedHeaders: []string{"*"}, + AllowedMethods: []string{http.MethodGet, http.MethodPost, + http.MethodHead, http.MethodPatch, http.MethodDelete, http.MethodPut}, + }).Handler(mux), + ReadHeaderTimeout: 30 * time.Second, + } + go func() { + err = httpServer.ListenAndServe() + if err != nil { + logger.Error("failed to start http server", zapcore.Field{Key: "err", Type: zapcore.ErrorType, Interface: err}) + } + }() + logger.Info(fmt.Sprintf("OpenFGA HTTP server listening on '%s'...", httpServer.Addr)) + return nil +} diff --git a/pkg/setting/settings_zanzana.go b/pkg/setting/settings_zanzana.go index f026a9036de..fd692cd32b1 100644 --- a/pkg/setting/settings_zanzana.go +++ b/pkg/setting/settings_zanzana.go @@ -16,6 +16,10 @@ type ZanzanaSettings struct { Addr string // Mode can either be embedded or client Mode ZanzanaMode + // ListenHTTP enables OpenFGA http server which allows to use fga cli + ListenHTTP bool + // OpenFGA http server address which allows to connect with fga cli + HttpAddr string } func (cfg *Cfg) readZanzanaSettings() { @@ -32,6 +36,8 @@ func (cfg *Cfg) readZanzanaSettings() { } s.Addr = sec.Key("address").MustString("") + s.ListenHTTP = sec.Key("listen_http").MustBool(false) + s.HttpAddr = sec.Key("http_addr").MustString("127.0.0.1:8080") cfg.Zanzana = s } From 4306d5235300c48627d2edaea33a16bcc322afbd Mon Sep 17 00:00:00 2001 From: Mihai Doarna Date: Tue, 2 Jul 2024 12:37:13 +0300 Subject: [PATCH 03/19] SSO: Encrypt and decrypt secrets for LDAP settings (#89470) encrypt/decrypt secrets for LDAP --- .../ssosettings/ssosettingsimpl/service.go | 169 ++++++--- .../ssosettingsimpl/service_test.go | 337 +++++++++++++++--- 2 files changed, 413 insertions(+), 93 deletions(-) diff --git a/pkg/services/ssosettings/ssosettingsimpl/service.go b/pkg/services/ssosettings/ssosettingsimpl/service.go index ad80c0dbfbb..5307ac21cfe 100644 --- a/pkg/services/ssosettings/ssosettingsimpl/service.go +++ b/pkg/services/ssosettings/ssosettingsimpl/service.go @@ -62,6 +62,7 @@ func ProvideService(cfg *setting.Cfg, sqlStore db.DB, ac ac.AccessControl, if features.IsEnabledGlobally(featuremgmt.FlagSsoSettingsLDAP) { providersList = append(providersList, social.LDAPProviderName) + configurableProviders[social.LDAPProviderName] = true } if licensing.FeatureEnabled(social.SAMLProviderName) { @@ -320,21 +321,23 @@ func (s *Service) getFallbackStrategyFor(provider string) (ssosettings.FallbackS } func (s *Service) encryptSecrets(ctx context.Context, settings map[string]any) (map[string]any, error) { - result := make(map[string]any) - for k, v := range settings { - if IsSecretField(k) && v != "" { - strValue, ok := v.(string) - if !ok { - return result, fmt.Errorf("failed to encrypt %s setting because it is not a string: %v", k, v) - } + result := deepCopyMap(settings) + configs := getConfigMaps(result) - encryptedSecret, err := s.secrets.Encrypt(ctx, []byte(strValue), secrets.WithoutScope()) - if err != nil { - return result, err + for _, config := range configs { + for k, v := range config { + if IsSecretField(k) && v != "" { + strValue, ok := v.(string) + if !ok { + return result, fmt.Errorf("failed to encrypt %s setting because it is not a string: %v", k, v) + } + + encryptedSecret, err := s.secrets.Encrypt(ctx, []byte(strValue), secrets.WithoutScope()) + if err != nil { + return result, err + } + config[k] = base64.RawStdEncoding.EncodeToString(encryptedSecret) } - result[k] = base64.RawStdEncoding.EncodeToString(encryptedSecret) - } else { - result[k] = v } } @@ -411,29 +414,34 @@ func (s *Service) mergeSSOSettings(dbSettings, systemSettings *models.SSOSetting } func (s *Service) decryptSecrets(ctx context.Context, settings map[string]any) (map[string]any, error) { - for k, v := range settings { - if IsSecretField(k) && v != "" { - strValue, ok := v.(string) - if !ok { - s.logger.Error("Failed to parse secret value, it is not a string", "key", k) - return nil, fmt.Errorf("secret value is not a string") - } + configs := getConfigMaps(settings) - decoded, err := base64.RawStdEncoding.DecodeString(strValue) - if err != nil { - s.logger.Error("Failed to decode secret string", "err", err, "value") - return nil, err - } + for _, config := range configs { + for k, v := range config { + if IsSecretField(k) && v != "" { + strValue, ok := v.(string) + if !ok { + s.logger.Error("Failed to parse secret value, it is not a string", "key", k) + return nil, fmt.Errorf("secret value is not a string") + } - decrypted, err := s.secrets.Decrypt(ctx, decoded) - if err != nil { - s.logger.Error("Failed to decrypt secret", "err", err) - return nil, err - } + decoded, err := base64.RawStdEncoding.DecodeString(strValue) + if err != nil { + s.logger.Error("Failed to decode secret string", "err", err, "value") + return nil, err + } - settings[k] = string(decrypted) + decrypted, err := s.secrets.Decrypt(ctx, decoded) + if err != nil { + s.logger.Error("Failed to decrypt secret", "err", err) + return nil, err + } + + config[k] = string(decrypted) + } } } + return settings, nil } @@ -445,18 +453,39 @@ func (s *Service) isProviderConfigurable(provider string) bool { // removeSecrets removes all the secrets from the map and replaces them with a redacted password // and returns a new map func removeSecrets(settings map[string]any) map[string]any { - result := make(map[string]any) - for k, v := range settings { - val, ok := v.(string) - if ok && val != "" && IsSecretField(k) { - result[k] = setting.RedactedPassword - continue + result := deepCopyMap(settings) + configs := getConfigMaps(result) + + for _, config := range configs { + for k, v := range config { + val, ok := v.(string) + if ok && val != "" && IsSecretField(k) { + config[k] = setting.RedactedPassword + } } - result[k] = v } return result } +// getConfigMaps returns a list of maps that may contain secrets +func getConfigMaps(settings map[string]any) []map[string]any { + // always include the main settings map + result := []map[string]any{settings} + + // for LDAP include settings for each server + if config, ok := settings["config"].(map[string]any); ok { + if servers, ok := config["servers"].([]any); ok { + for _, server := range servers { + if serverSettings, ok := server.(map[string]any); ok { + result = append(result, serverSettings) + } + } + } + } + + return result +} + // mergeSettings merges two maps in a way that the values from the first map are preserved // and the values from the second map are added only if they don't exist in the first map // or if they contain empty URLs. @@ -500,23 +529,25 @@ func isMergingAllowed(fieldName string) bool { // mergeSecrets returns a new map with the current value for secrets that have not been updated func mergeSecrets(settings map[string]any, storedSettings map[string]any) (map[string]any, error) { - settingsWithSecrets := map[string]any{} - for k, v := range settings { - if IsSecretField(k) { - strValue, ok := v.(string) - if !ok { - return nil, fmt.Errorf("secret value is not a string") - } + settingsWithSecrets := deepCopyMap(settings) + newConfigs := getConfigMaps(settingsWithSecrets) + storedConfigs := getConfigMaps(storedSettings) - if isNewSecretValue(strValue) { - settingsWithSecrets[k] = strValue // use the new value - continue + for i, config := range newConfigs { + for k, v := range config { + if IsSecretField(k) { + strValue, ok := v.(string) + if !ok { + return nil, fmt.Errorf("secret value is not a string") + } + + if !isNewSecretValue(strValue) && len(storedConfigs) > i { + config[k] = storedConfigs[i][k] // use the currently stored value + } } - settingsWithSecrets[k] = storedSettings[k] // keep the currently stored value - } else { - settingsWithSecrets[k] = v } } + return settingsWithSecrets, nil } @@ -532,7 +563,7 @@ func overrideMaps(maps ...map[string]any) map[string]any { // IsSecretField returns true if the SSO settings field provided is a secret func IsSecretField(fieldName string) bool { - secretFieldPatterns := []string{"secret", "private", "certificate"} + secretFieldPatterns := []string{"secret", "private", "certificate", "password", "client_key"} for _, v := range secretFieldPatterns { if strings.Contains(strings.ToLower(fieldName), strings.ToLower(v)) { @@ -554,3 +585,37 @@ func isEmptyString(val any) bool { func isNewSecretValue(value string) bool { return value != setting.RedactedPassword } + +func deepCopyMap(settings map[string]any) map[string]any { + newSettings := make(map[string]any) + + for key, value := range settings { + switch v := value.(type) { + case map[string]any: + newSettings[key] = deepCopyMap(v) + case []any: + newSettings[key] = deepCopySlice(v) + default: + newSettings[key] = value + } + } + + return newSettings +} + +func deepCopySlice(s []any) []any { + newSlice := make([]any, len(s)) + + for i, value := range s { + switch v := value.(type) { + case map[string]any: + newSlice[i] = deepCopyMap(v) + case []any: + newSlice[i] = deepCopySlice(v) + default: + newSlice[i] = value + } + } + + return newSlice +} diff --git a/pkg/services/ssosettings/ssosettingsimpl/service_test.go b/pkg/services/ssosettings/ssosettingsimpl/service_test.go index 094105c00b4..7aa812f3f2a 100644 --- a/pkg/services/ssosettings/ssosettingsimpl/service_test.go +++ b/pkg/services/ssosettings/ssosettingsimpl/service_test.go @@ -158,6 +158,62 @@ func TestService_GetForProvider(t *testing.T) { }, wantErr: false, }, + { + name: "should decrypt secrets for LDAP if data is coming from store", + provider: "ldap", + setup: func(env testEnv) { + env.store.ExpectedSSOSetting = &models.SSOSettings{ + Provider: "ldap", + Settings: map[string]any{ + "enabled": true, + "config": map[string]any{ + "servers": []any{ + map[string]any{ + "host": "192.168.0.1", + "bind_password": base64.RawStdEncoding.EncodeToString([]byte("bind_password_1")), + "client_key": base64.RawStdEncoding.EncodeToString([]byte("client_key_1")), + }, + map[string]any{ + "host": "192.168.0.2", + "bind_password": base64.RawStdEncoding.EncodeToString([]byte("bind_password_2")), + "client_key": base64.RawStdEncoding.EncodeToString([]byte("client_key_2")), + }, + }, + }, + }, + Source: models.DB, + } + env.fallbackStrategy.ExpectedIsMatch = true + env.fallbackStrategy.ExpectedConfigs = map[string]map[string]any{} + + env.secrets.On("Decrypt", mock.Anything, []byte("bind_password_1"), mock.Anything).Return([]byte("decrypted-bind-password-1"), nil).Once() + env.secrets.On("Decrypt", mock.Anything, []byte("client_key_1"), mock.Anything).Return([]byte("decrypted-client-key-1"), nil).Once() + env.secrets.On("Decrypt", mock.Anything, []byte("bind_password_2"), mock.Anything).Return([]byte("decrypted-bind-password-2"), nil).Once() + env.secrets.On("Decrypt", mock.Anything, []byte("client_key_2"), mock.Anything).Return([]byte("decrypted-client-key-2"), nil).Once() + }, + want: &models.SSOSettings{ + Provider: "ldap", + Settings: map[string]any{ + "enabled": true, + "config": map[string]any{ + "servers": []any{ + map[string]any{ + "host": "192.168.0.1", + "bind_password": "decrypted-bind-password-1", + "client_key": "decrypted-client-key-1", + }, + map[string]any{ + "host": "192.168.0.2", + "bind_password": "decrypted-bind-password-2", + "client_key": "decrypted-client-key-2", + }, + }, + }, + }, + Source: models.DB, + }, + wantErr: false, + }, { name: "should not decrypt secrets if data is coming from the fallback strategy", provider: "github", @@ -290,7 +346,7 @@ func TestService_GetForProvider(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - env := setupTestEnv(t, true, false, true) + env := setupTestEnv(t, true, false, true, true) if tc.setup != nil { tc.setup(env) } @@ -314,13 +370,15 @@ func TestService_GetForProviderWithRedactedSecrets(t *testing.T) { t.Parallel() testCases := []struct { - name string - setup func(env testEnv) - want *models.SSOSettings - wantErr bool + name string + provider string + setup func(env testEnv) + want *models.SSOSettings + wantErr bool }{ { - name: "should return successfully and redact secrets", + name: "should return successfully and redact secrets", + provider: "github", setup: func(env testEnv) { env.store.ExpectedSSOSetting = &models.SSOSettings{ Provider: "github", @@ -347,13 +405,67 @@ func TestService_GetForProviderWithRedactedSecrets(t *testing.T) { 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 return successfully and redact secrets for LDAP", + provider: "ldap", + setup: func(env testEnv) { + env.store.ExpectedSSOSetting = &models.SSOSettings{ + Provider: "ldap", + Settings: map[string]any{ + "enabled": true, + "config": map[string]any{ + "servers": []any{ + map[string]any{ + "host": "192.168.0.1", + "bind_password": base64.RawStdEncoding.EncodeToString([]byte("bind_password_1")), + "client_key": base64.RawStdEncoding.EncodeToString([]byte("client_key_1")), + }, + map[string]any{ + "host": "192.168.0.2", + "bind_password": base64.RawStdEncoding.EncodeToString([]byte("bind_password_2")), + "client_key": base64.RawStdEncoding.EncodeToString([]byte("client_key_2")), + }, + }, + }, + }, + Source: models.DB, + } + env.secrets.On("Decrypt", mock.Anything, []byte("bind_password_1"), mock.Anything).Return([]byte("decrypted-bind-password-1"), nil).Once() + env.secrets.On("Decrypt", mock.Anything, []byte("client_key_1"), mock.Anything).Return([]byte("decrypted-client-key-1"), nil).Once() + env.secrets.On("Decrypt", mock.Anything, []byte("bind_password_2"), mock.Anything).Return([]byte("decrypted-bind-password-2"), nil).Once() + env.secrets.On("Decrypt", mock.Anything, []byte("client_key_2"), mock.Anything).Return([]byte("decrypted-client-key-2"), nil).Once() + }, + want: &models.SSOSettings{ + Provider: "ldap", + Settings: map[string]any{ + "enabled": true, + "config": map[string]any{ + "servers": []any{ + map[string]any{ + "host": "192.168.0.1", + "bind_password": "*********", + "client_key": "*********", + }, + map[string]any{ + "host": "192.168.0.2", + "bind_password": "*********", + "client_key": "*********", + }, + }, + }, + }, + }, + wantErr: false, }, { - name: "should fallback to strategy if store returns not found", + name: "should return error if store returns an error different than not found", + provider: "github", + setup: func(env testEnv) { env.store.ExpectedError = fmt.Errorf("error") }, + want: nil, + wantErr: true, + }, + { + name: "should fallback to strategy if store returns not found", + provider: "github", setup: func(env testEnv) { env.store.ExpectedError = ssosettings.ErrNotFound env.fallbackStrategy.ExpectedIsMatch = true @@ -371,7 +483,8 @@ func TestService_GetForProviderWithRedactedSecrets(t *testing.T) { wantErr: false, }, { - name: "should return error if the fallback strategy was not found", + name: "should return error if the fallback strategy was not found", + provider: "github", setup: func(env testEnv) { env.store.ExpectedError = ssosettings.ErrNotFound env.fallbackStrategy.ExpectedIsMatch = false @@ -380,7 +493,8 @@ func TestService_GetForProviderWithRedactedSecrets(t *testing.T) { wantErr: true, }, { - name: "should return error if fallback strategy returns error", + name: "should return error if fallback strategy returns error", + provider: "github", setup: func(env testEnv) { env.store.ExpectedError = ssosettings.ErrNotFound env.fallbackStrategy.ExpectedIsMatch = true @@ -399,12 +513,12 @@ func TestService_GetForProviderWithRedactedSecrets(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - env := setupTestEnv(t, false, false, false) + env := setupTestEnv(t, false, false, false, true) if tc.setup != nil { tc.setup(env) } - actual, err := env.service.GetForProviderWithRedactedSecrets(context.Background(), "github") + actual, err := env.service.GetForProviderWithRedactedSecrets(context.Background(), tc.provider) if tc.wantErr { require.Error(t, err) @@ -550,7 +664,7 @@ func TestService_List(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - env := setupTestEnv(t, false, false, false) + env := setupTestEnv(t, false, false, false, false) if tc.setup != nil { tc.setup(env) } @@ -852,7 +966,7 @@ func TestService_ListWithRedactedSecrets(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - env := setupTestEnv(t, false, false, false) + env := setupTestEnv(t, false, false, false, false) if tc.setup != nil { tc.setup(env) } @@ -876,7 +990,7 @@ func TestService_Upsert(t *testing.T) { t.Run("successfully upsert SSO settings", func(t *testing.T) { t.Parallel() - env := setupTestEnv(t, false, false, false) + env := setupTestEnv(t, false, false, false, false) provider := social.AzureADProviderName settings := models.SSOSettings{ @@ -936,10 +1050,80 @@ func TestService_Upsert(t *testing.T) { require.EqualValues(t, settings, env.store.ActualSSOSettings) }) + t.Run("successfully upsert SSO settings for LDAP", func(t *testing.T) { + t.Parallel() + + env := setupTestEnv(t, false, false, false, true) + + provider := social.LDAPProviderName + settings := models.SSOSettings{ + Provider: provider, + Settings: map[string]any{ + "enabled": true, + "config": map[string]any{ + "servers": []any{ + map[string]any{ + "host": "192.168.0.1", + "bind_password": "bind_password_1", + "client_key": "client_key_1", + }, + map[string]any{ + "host": "192.168.0.2", + "bind_password": "bind_password_2", + "client_key": "client_key_2", + }, + }, + }, + }, + } + var wg sync.WaitGroup + wg.Add(1) + + reloadable := ssosettingstests.NewMockReloadable(t) + reloadable.On("Validate", mock.Anything, settings, mock.Anything, mock.Anything).Return(nil) + reloadable.On("Reload", mock.Anything, mock.MatchedBy(func(settings models.SSOSettings) bool { + defer wg.Done() + return settings.Provider == provider && + settings.ID == "someid" && + maps.Equal(settings.Settings["config"].(map[string]any)["servers"].([]any)[0].(map[string]any), map[string]any{ + "host": "192.168.0.1", + "bind_password": "bind_password_1", + "client_key": "client_key_1", + }) && maps.Equal(settings.Settings["config"].(map[string]any)["servers"].([]any)[1].(map[string]any), map[string]any{ + "host": "192.168.0.2", + "bind_password": "bind_password_2", + "client_key": "client_key_2", + }) + })).Return(nil).Once() + env.reloadables[provider] = reloadable + env.secrets.On("Encrypt", mock.Anything, []byte("bind_password_1"), mock.Anything).Return([]byte("encrypted-bind-password-1"), nil).Once() + env.secrets.On("Encrypt", mock.Anything, []byte("bind_password_2"), mock.Anything).Return([]byte("encrypted-bind-password-2"), nil).Once() + env.secrets.On("Encrypt", mock.Anything, []byte("client_key_1"), mock.Anything).Return([]byte("encrypted-client-key-1"), nil).Once() + env.secrets.On("Encrypt", mock.Anything, []byte("client_key_2"), mock.Anything).Return([]byte("encrypted-client-key-2"), nil).Once() + + env.store.UpsertFn = func(ctx context.Context, settings *models.SSOSettings) error { + currentTime := time.Now() + settings.ID = "someid" + settings.Created = currentTime + settings.Updated = currentTime + + env.store.ActualSSOSettings = *settings + return nil + } + + err := env.service.Upsert(context.Background(), &settings, &user.SignedInUser{}) + require.NoError(t, err) + + // Wait for the goroutine first to assert the Reload call + wg.Wait() + + require.EqualValues(t, settings, env.store.ActualSSOSettings) + }) + t.Run("returns error if provider is not configurable", func(t *testing.T) { t.Parallel() - env := setupTestEnv(t, false, false, false) + env := setupTestEnv(t, false, false, false, false) provider := social.GrafanaComProviderName settings := &models.SSOSettings{ @@ -962,7 +1146,7 @@ func TestService_Upsert(t *testing.T) { t.Run("returns error if provider was not found in reloadables", func(t *testing.T) { t.Parallel() - env := setupTestEnv(t, false, false, false) + env := setupTestEnv(t, false, false, false, false) provider := social.AzureADProviderName settings := &models.SSOSettings{ @@ -986,7 +1170,7 @@ func TestService_Upsert(t *testing.T) { t.Run("returns error if validation fails", func(t *testing.T) { t.Parallel() - env := setupTestEnv(t, false, false, false) + env := setupTestEnv(t, false, false, false, false) provider := social.AzureADProviderName settings := models.SSOSettings{ @@ -1010,7 +1194,7 @@ func TestService_Upsert(t *testing.T) { t.Run("returns error if a fallback strategy is not available for the provider", func(t *testing.T) { t.Parallel() - env := setupTestEnv(t, false, false, false) + env := setupTestEnv(t, false, false, false, false) settings := &models.SSOSettings{ Provider: social.AzureADProviderName, @@ -1031,7 +1215,7 @@ func TestService_Upsert(t *testing.T) { t.Run("returns error if a secret does not have the type string", func(t *testing.T) { t.Parallel() - env := setupTestEnv(t, false, false, false) + env := setupTestEnv(t, false, false, false, false) provider := social.OktaProviderName settings := models.SSOSettings{ @@ -1054,7 +1238,7 @@ func TestService_Upsert(t *testing.T) { t.Run("returns error if secrets encryption failed", func(t *testing.T) { t.Parallel() - env := setupTestEnv(t, false, false, false) + env := setupTestEnv(t, false, false, false, false) provider := social.OktaProviderName settings := models.SSOSettings{ @@ -1079,7 +1263,7 @@ func TestService_Upsert(t *testing.T) { t.Run("should not update the current secret if the secret has not been updated", func(t *testing.T) { t.Parallel() - env := setupTestEnv(t, false, false, false) + env := setupTestEnv(t, false, false, false, false) provider := social.AzureADProviderName settings := models.SSOSettings{ @@ -1123,7 +1307,7 @@ func TestService_Upsert(t *testing.T) { t.Run("run validation with all new and current secrets available in settings", func(t *testing.T) { t.Parallel() - env := setupTestEnv(t, false, false, false) + env := setupTestEnv(t, false, false, false, false) provider := social.AzureADProviderName settings := models.SSOSettings{ @@ -1176,7 +1360,7 @@ func TestService_Upsert(t *testing.T) { t.Run("returns error if store failed to upsert settings", func(t *testing.T) { t.Parallel() - env := setupTestEnv(t, false, false, false) + env := setupTestEnv(t, false, false, false, false) provider := social.AzureADProviderName settings := models.SSOSettings{ @@ -1208,7 +1392,7 @@ func TestService_Upsert(t *testing.T) { t.Run("successfully upsert SSO settings if reload fails", func(t *testing.T) { t.Parallel() - env := setupTestEnv(t, false, false, false) + env := setupTestEnv(t, false, false, false, false) provider := social.AzureADProviderName settings := models.SSOSettings{ @@ -1241,7 +1425,7 @@ func TestService_Delete(t *testing.T) { t.Run("successfully delete SSO settings", func(t *testing.T) { t.Parallel() - env := setupTestEnv(t, false, false, false) + env := setupTestEnv(t, false, false, false, false) var wg sync.WaitGroup wg.Add(1) @@ -1279,7 +1463,7 @@ func TestService_Delete(t *testing.T) { t.Run("return error if SSO setting was not found for the specified provider", func(t *testing.T) { t.Parallel() - env := setupTestEnv(t, false, false, false) + env := setupTestEnv(t, false, false, false, false) provider := social.AzureADProviderName reloadable := ssosettingstests.NewMockReloadable(t) @@ -1295,7 +1479,7 @@ func TestService_Delete(t *testing.T) { t.Run("should not delete the SSO settings if the provider is not configurable", func(t *testing.T) { t.Parallel() - env := setupTestEnv(t, false, false, false) + env := setupTestEnv(t, false, false, false, false) env.cfg.SSOSettingsConfigurableProviders = map[string]bool{social.AzureADProviderName: true} provider := social.GrafanaComProviderName @@ -1308,7 +1492,7 @@ func TestService_Delete(t *testing.T) { t.Run("return error when store fails to delete the SSO settings for the specified provider", func(t *testing.T) { t.Parallel() - env := setupTestEnv(t, false, false, false) + env := setupTestEnv(t, false, false, false, false) provider := social.AzureADProviderName env.store.ExpectedError = errors.New("delete sso settings failed") @@ -1321,7 +1505,7 @@ func TestService_Delete(t *testing.T) { t.Run("return successfully when the deletion was successful but reloading the settings fail", func(t *testing.T) { t.Parallel() - env := setupTestEnv(t, false, false, false) + env := setupTestEnv(t, false, false, false, false) provider := social.AzureADProviderName reloadable := ssosettingstests.NewMockReloadable(t) @@ -1337,13 +1521,51 @@ func TestService_Delete(t *testing.T) { }) } +// we might not need this test because it is not testing the public interface +// it was added for convenient testing of the internal deep copy and remove secrets +func TestRemoveSecrets(t *testing.T) { + settings := map[string]any{ + "enabled": true, + "client_secret": "client_secret", + "config": map[string]any{ + "servers": []any{ + map[string]any{ + "host": "192.168.0.1", + "bind_password": "bind_password_1", + "client_key": "client_key_1", + }, + map[string]any{ + "host": "192.168.0.2", + "bind_password": "bind_password_2", + "client_key": "client_key_2", + }, + }, + }, + } + + copiedSettings := deepCopyMap(settings) + copiedSettings["client_secret"] = "client_secret_updated" + copiedSettings["config"].(map[string]any)["servers"].([]any)[0].(map[string]any)["bind_password"] = "bind_password_1_updated" + + require.Equal(t, "client_secret", settings["client_secret"]) + require.Equal(t, "client_secret_updated", copiedSettings["client_secret"]) + require.Equal(t, "bind_password_1", settings["config"].(map[string]any)["servers"].([]any)[0].(map[string]any)["bind_password"]) + require.Equal(t, "bind_password_1_updated", copiedSettings["config"].(map[string]any)["servers"].([]any)[0].(map[string]any)["bind_password"]) + + settingsWithRedactedSecrets := removeSecrets(settings) + require.Equal(t, "client_secret", settings["client_secret"]) + require.Equal(t, "*********", settingsWithRedactedSecrets["client_secret"]) + require.Equal(t, "bind_password_1", settings["config"].(map[string]any)["servers"].([]any)[0].(map[string]any)["bind_password"]) + require.Equal(t, "*********", settingsWithRedactedSecrets["config"].(map[string]any)["servers"].([]any)[0].(map[string]any)["bind_password"]) +} + func TestService_DoReload(t *testing.T) { t.Parallel() t.Run("successfully reload settings", func(t *testing.T) { t.Parallel() - env := setupTestEnv(t, false, false, false) + env := setupTestEnv(t, false, false, false, false) settingsList := []*models.SSOSettings{ { @@ -1383,7 +1605,7 @@ func TestService_DoReload(t *testing.T) { t.Run("successfully reload settings when some providers have empty settings", func(t *testing.T) { t.Parallel() - env := setupTestEnv(t, false, false, false) + env := setupTestEnv(t, false, false, false, false) settingsList := []*models.SSOSettings{ { @@ -1413,7 +1635,7 @@ func TestService_DoReload(t *testing.T) { t.Run("failed fetching the SSO settings", func(t *testing.T) { t.Parallel() - env := setupTestEnv(t, false, false, false) + env := setupTestEnv(t, false, false, false, false) provider := "github" @@ -1459,6 +1681,35 @@ func TestService_decryptSecrets(t *testing.T) { "certificate": "decrypted-certificate", }, }, + { + name: "should decrypt LDAP secrets successfully", + setup: func(env testEnv) { + env.secrets.On("Decrypt", mock.Anything, []byte("client_key"), mock.Anything).Return([]byte("decrypted-client-key"), nil).Once() + env.secrets.On("Decrypt", mock.Anything, []byte("bind_password"), mock.Anything).Return([]byte("decrypted-bind-password"), nil).Once() + }, + settings: map[string]any{ + "enabled": true, + "config": map[string]any{ + "servers": []any{ + map[string]any{ + "client_key": base64.RawStdEncoding.EncodeToString([]byte("client_key")), + "bind_password": base64.RawStdEncoding.EncodeToString([]byte("bind_password")), + }, + }, + }, + }, + want: map[string]any{ + "enabled": true, + "config": map[string]any{ + "servers": []any{ + map[string]any{ + "client_key": "decrypted-client-key", + "bind_password": "decrypted-bind-password", + }, + }, + }, + }, + }, { name: "should not decrypt when a secret is empty", setup: func(env testEnv) { @@ -1514,7 +1765,7 @@ func TestService_decryptSecrets(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - env := setupTestEnv(t, false, false, false) + env := setupTestEnv(t, false, false, false, false) if tc.setup != nil { tc.setup(env) @@ -1593,7 +1844,7 @@ func Test_ProviderService(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - env := setupTestEnv(t, tc.isLicenseEnabled, true, tc.samlEnabled) + env := setupTestEnv(t, tc.isLicenseEnabled, true, tc.samlEnabled, false) require.Equal(t, tc.expectedProvidersList, env.service.providersList) require.Equal(t, tc.strategiesLength, len(env.service.fbStrategies)) @@ -1601,7 +1852,7 @@ func Test_ProviderService(t *testing.T) { } } -func setupTestEnv(t *testing.T, isLicensingEnabled, keepFallbackStratergies, samlEnabled bool) testEnv { +func setupTestEnv(t *testing.T, isLicensingEnabled, keepFallbackStratergies, samlEnabled bool, ldapEnabled bool) testEnv { t.Helper() store := ssosettingstests.NewFakeStore() @@ -1631,10 +1882,14 @@ func setupTestEnv(t *testing.T, isLicensingEnabled, keepFallbackStratergies, sam licensing := licensingtest.NewFakeLicensing() licensing.On("FeatureEnabled", "saml").Return(isLicensingEnabled) - featureManager := featuremgmt.WithManager() + features := make([]any, 0) if samlEnabled { - featureManager = featuremgmt.WithManager(featuremgmt.FlagSsoSettingsSAML) + features = append(features, featuremgmt.FlagSsoSettingsSAML) } + if ldapEnabled { + features = append(features, featuremgmt.FlagSsoSettingsLDAP) + } + featureManager := featuremgmt.WithManager(features...) svc := ProvideService( cfg, From ba64ee44cb418aa45c1fcafb71db56475ec7d91c Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Tue, 2 Jul 2024 11:28:27 +0100 Subject: [PATCH 04/19] Chore: Move SCSS mixins to be where they're used (#89907) move mixins to be where they're used --- public/sass/_grafana.scss | 5 - public/sass/base/_forms.scss | 34 +++++++ public/sass/base/_grid.scss | 121 ++++++++++++++++++++++++ public/sass/components/_buttons.scss | 105 ++++++++++++++++++++ public/sass/components/_gf-form.scss | 7 ++ public/sass/mixins/_buttons.scss | 73 -------------- public/sass/mixins/_forms.scss | 66 ------------- public/sass/mixins/_grid-framework.scss | 48 ---------- public/sass/mixins/_grid.scss | 76 --------------- public/sass/mixins/_hover.scss | 67 ------------- 10 files changed, 267 insertions(+), 335 deletions(-) delete mode 100644 public/sass/mixins/_buttons.scss delete mode 100644 public/sass/mixins/_forms.scss delete mode 100644 public/sass/mixins/_grid-framework.scss delete mode 100644 public/sass/mixins/_grid.scss delete mode 100644 public/sass/mixins/_hover.scss diff --git a/public/sass/_grafana.scss b/public/sass/_grafana.scss index b24b2d4648e..e4108aa327f 100644 --- a/public/sass/_grafana.scss +++ b/public/sass/_grafana.scss @@ -1,11 +1,6 @@ // MIXINS @import 'mixins/mixins'; -@import 'mixins/buttons'; @import 'mixins/breakpoints'; -@import 'mixins/grid'; -@import 'mixins/grid-framework'; -@import 'mixins/hover'; -@import 'mixins/forms'; // BASE @import 'base/reboot'; diff --git a/public/sass/base/_forms.scss b/public/sass/base/_forms.scss index c653ef89e1e..0c5446a1e57 100644 --- a/public/sass/base/_forms.scss +++ b/public/sass/base/_forms.scss @@ -1,3 +1,37 @@ +@use 'sass:color'; + +@mixin form-control-validation($color) { + // Color the label and help text + .text-help, + .form-control-label, + .radio, + .checkbox, + .radio-inline, + .checkbox-inline, + &.radio label, + &.checkbox label, + &.radio-inline label, + &.checkbox-inline label, + .custom-control { + color: $color; + } + + .form-control { + border-color: $color; + } + + // Set validation states also for addons + .input-group-addon { + color: $color; + border-color: $color; + background-color: color.adjust($color, $lightness: 40%); + } + // Optional feedback icon + .form-control-feedback { + color: $color; + } +} + // // Forms // -------------------------------------------------- diff --git a/public/sass/base/_grid.scss b/public/sass/base/_grid.scss index bf825a155e9..6bb8732b4b5 100644 --- a/public/sass/base/_grid.scss +++ b/public/sass/base/_grid.scss @@ -1,3 +1,124 @@ +@use 'sass:math'; + +/// Grid system +// +// Generate semantic grid columns with these mixins. + +@mixin make-container($gutter: $grid-gutter-width) { + margin-left: auto; + margin-right: auto; + padding-left: calc($gutter / 2); + padding-right: calc($gutter / 2); + @if not $enable-flex { + @include clearfix(); + } +} + +// For each breakpoint, define the maximum width of the container in a media query +@mixin make-container-max-widths($max-widths: $container-max-widths, $breakpoints: $grid-breakpoints) { + @each $breakpoint, $container-max-width in $max-widths { + @include media-breakpoint-up($breakpoint, $breakpoints) { + max-width: $container-max-width; + } + } +} + +@mixin make-row($gutter: $grid-gutter-width) { + @if $enable-flex { + display: flex; + flex-wrap: wrap; + } @else { + @include clearfix(); + } + margin-left: calc($gutter / -2); + margin-right: calc($gutter / -2); +} + +@mixin make-col($size, $columns: $grid-columns) { + position: relative; + min-height: 1px; + padding-right: calc($grid-gutter-width / 2); + padding-left: calc($grid-gutter-width / 2); + + @if $enable-flex { + flex: 0 0 math.percentage(calc($size / $columns)); + // Add a `max-width` to ensure content within each column does not blow out + // the width of the column. Applies to IE10+ and Firefox. Chrome and Safari + // do not appear to require this. + max-width: math.percentage(calc($size / $columns)); + } @else { + float: left; + width: math.percentage(calc($size / $columns)); + } +} + +@mixin make-col-offset($size, $columns: $grid-columns) { + margin-left: math.percentage(calc($size / $columns)); +} + +@mixin make-col-push($size, $columns: $grid-columns) { + left: if($size > 0, math.percentage(calc($size / $columns)), auto); +} + +@mixin make-col-pull($size, $columns: $grid-columns) { + right: if($size > 0, math.percentage(calc($size / $columns)), auto); +} + +@mixin make-col-modifier($type, $size, $columns) { + // Work around the lack of dynamic mixin @include support (https://github.com/sass/sass/issues/626) + @if $type == push { + @include make-col-push($size, $columns); + } @else if $type == pull { + @include make-col-pull($size, $columns); + } @else if $type == offset { + @include make-col-offset($size, $columns); + } +} + +@mixin make-grid-columns($columns: $grid-columns, $gutter: $grid-gutter-width, $breakpoints: $grid-breakpoints) { + $breakpoint-counter: 0; + @each $breakpoint in map-keys($breakpoints) { + $breakpoint-counter: ($breakpoint-counter + 1); + @include media-breakpoint-up($breakpoint, $breakpoints) { + @if $enable-flex { + .col-#{$breakpoint} { + position: relative; + flex-basis: 0; + flex-grow: 1; + max-width: 100%; + min-height: 1px; + padding-right: calc($grid-gutter-width / 2); + padding-left: calc($grid-gutter-width / 2); + } + } + + @for $i from 1 through $columns { + .col-#{$breakpoint}-#{$i} { + @include make-col($i, $columns); + } + } + + @each $modifier in (pull, push) { + @for $i from 0 through $columns { + .#{$modifier}-#{$breakpoint}-#{$i} { + @include make-col-modifier($modifier, $i, $columns); + } + } + } + + // `$columns - 1` because offsetting by the width of an entire row isn't possible + @for $i from 0 through ($columns - 1) { + @if $breakpoint-counter != 1 or $i != 0 { + // Avoid emitting useless .col-xs-offset-0 + .offset-#{$breakpoint}-#{$i} { + @include make-col-modifier(offset, $i, $columns); + } + } + } + } + } +} + // Container widths // // Set the container width, and override it for fixed navbars in media queries. diff --git a/public/sass/components/_buttons.scss b/public/sass/components/_buttons.scss index 16d7b941449..fddbf0aae2c 100644 --- a/public/sass/components/_buttons.scss +++ b/public/sass/components/_buttons.scss @@ -1,5 +1,110 @@ @use 'sass:color'; @use 'sass:map'; + +@mixin hover { + @if $enable-hover-media-query { + // See Media Queries Level 4: http://drafts.csswg.org/mediaqueries/#hover + // Currently shimmed by https://github.com/twbs/mq4-hover-shim + @media (hover: hover) { + &:hover { + @content; + } + } + } @else { + &:hover { + @content; + } + } +} + +@mixin hover-focus { + @if $enable-hover-media-query { + &:focus { + @content; + } + @include hover { + @content; + } + } @else { + &:focus, + &:hover { + @content; + } + } +} + +// Button backgrounds +// ------------------ +@mixin buttonBackground($startColor, $endColor, $text-color: #fff, $textShadow: 0px 1px 0 rgba(0, 0, 0, 0.1)) { + // gradientBar will set the background to a pleasing blend of these, to support IE<=9 + @include gradientBar($startColor, $endColor, $text-color, $textShadow); + + // in these cases the gradient won't cover the background, so we override + &:hover, + &:focus, + &:active, + &.active, + &.disabled, + &[disabled] { + color: $text-color; + background-image: none; + background-color: $startColor; + } +} + +// Button sizes +@mixin button-size($padding-y, $padding-x, $font-size, $border-radius) { + padding: $padding-y $padding-x; + font-size: $font-size; + //box-shadow: inset 0 (-$padding-y/3) rgba(0,0,0,0.15); + + @include border-radius($border-radius); +} + +@mixin button-outline-variant($color) { + color: $white; + background-image: none; + background-color: transparent; + border: 1px solid $white; + + @include hover { + color: $white; + background-color: $color; + } + + &:focus, + &.focus { + color: $white; + background-color: $color; + } + + &:active, + &.active, + .open > &.dropdown-toggle { + color: $white; + background-color: $color; + + &:hover, + &:focus, + &.focus { + color: $white; + background-color: color.adjust($color, $lightness: -17%); + border-color: color.adjust($color, $lightness: -25%); + } + } + + &.disabled, + &:disabled { + &:focus, + &.focus { + border-color: color.adjust($color, $lightness: 20%); + } + @include hover { + border-color: color.adjust($color, $lightness: 20%); + } + } +} + // // Buttons // -------------------------------------------------- diff --git a/public/sass/components/_gf-form.scss b/public/sass/components/_gf-form.scss index 761d7e070c7..8027d4080f1 100644 --- a/public/sass/components/_gf-form.scss +++ b/public/sass/components/_gf-form.scss @@ -1,6 +1,13 @@ @use 'sass:list'; $input-border: 1px solid $input-border-color; +@mixin form-control-focus() { + &:focus { + border-color: $input-border-focus; + outline: none; + } +} + .gf-form { display: flex; flex-direction: row; diff --git a/public/sass/mixins/_buttons.scss b/public/sass/mixins/_buttons.scss deleted file mode 100644 index 7fec07c19ed..00000000000 --- a/public/sass/mixins/_buttons.scss +++ /dev/null @@ -1,73 +0,0 @@ -@use 'sass:color'; - -// Button backgrounds -// ------------------ -@mixin buttonBackground($startColor, $endColor, $text-color: #fff, $textShadow: 0px 1px 0 rgba(0, 0, 0, 0.1)) { - // gradientBar will set the background to a pleasing blend of these, to support IE<=9 - @include gradientBar($startColor, $endColor, $text-color, $textShadow); - - // in these cases the gradient won't cover the background, so we override - &:hover, - &:focus, - &:active, - &.active, - &.disabled, - &[disabled] { - color: $text-color; - background-image: none; - background-color: $startColor; - } -} - -// Button sizes -@mixin button-size($padding-y, $padding-x, $font-size, $border-radius) { - padding: $padding-y $padding-x; - font-size: $font-size; - //box-shadow: inset 0 (-$padding-y/3) rgba(0,0,0,0.15); - - @include border-radius($border-radius); -} - -@mixin button-outline-variant($color) { - color: $white; - background-image: none; - background-color: transparent; - border: 1px solid $white; - - @include hover { - color: $white; - background-color: $color; - } - - &:focus, - &.focus { - color: $white; - background-color: $color; - } - - &:active, - &.active, - .open > &.dropdown-toggle { - color: $white; - background-color: $color; - - &:hover, - &:focus, - &.focus { - color: $white; - background-color: color.adjust($color, $lightness: -17%); - border-color: color.adjust($color, $lightness: -25%); - } - } - - &.disabled, - &:disabled { - &:focus, - &.focus { - border-color: color.adjust($color, $lightness: 20%); - } - @include hover { - border-color: color.adjust($color, $lightness: 20%); - } - } -} diff --git a/public/sass/mixins/_forms.scss b/public/sass/mixins/_forms.scss deleted file mode 100644 index 70b34f75d4f..00000000000 --- a/public/sass/mixins/_forms.scss +++ /dev/null @@ -1,66 +0,0 @@ -@use 'sass:color'; - -@mixin form-control-validation($color) { - // Color the label and help text - .text-help, - .form-control-label, - .radio, - .checkbox, - .radio-inline, - .checkbox-inline, - &.radio label, - &.checkbox label, - &.radio-inline label, - &.checkbox-inline label, - .custom-control { - color: $color; - } - - .form-control { - border-color: $color; - } - - // Set validation states also for addons - .input-group-addon { - color: $color; - border-color: $color; - background-color: color.adjust($color, $lightness: 40%); - } - // Optional feedback icon - .form-control-feedback { - color: $color; - } -} - -@mixin form-control-focus() { - &:focus { - border-color: $input-border-focus; - outline: none; - } -} - -// Form control sizing -// -// Relative text size, padding, and border-radii changes for form controls. For -// horizontal sizing, wrap controls in the predefined grid classes. `