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. `