diff --git a/pkg/login/social/social.go b/pkg/login/social/social.go index 8924f7ac39f..889622c4494 100644 --- a/pkg/login/social/social.go +++ b/pkg/login/social/social.go @@ -24,6 +24,7 @@ const ( GrafanaNetProviderName = "grafananet" OktaProviderName = "okta" SAMLProviderName = "saml" + LDAPProviderName = "ldap" ) var ( diff --git a/pkg/login/social/socialimpl/service_test.go b/pkg/login/social/socialimpl/service_test.go index 2dce86712e5..6ac7811f3c8 100644 --- a/pkg/login/social/socialimpl/service_test.go +++ b/pkg/login/social/socialimpl/service_test.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/login/social/connectors" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/featuremgmt" + ldap "github.com/grafana/grafana/pkg/services/ldap/service" "github.com/grafana/grafana/pkg/services/licensing" secretsfake "github.com/grafana/grafana/pkg/services/secrets/fakes" "github.com/grafana/grafana/pkg/services/ssosettings/ssosettingsimpl" @@ -81,6 +82,7 @@ func TestSocialService_ProvideService(t *testing.T) { nil, &setting.OSSImpl{Cfg: cfg}, &licensing.OSSLicensingService{}, + ldap.ProvideService(cfg), ) for _, tc := range testCases { @@ -182,7 +184,19 @@ func TestSocialService_ProvideService_GrafanaComGrafanaNet(t *testing.T) { accessControl := acimpl.ProvideAccessControl(featuremgmt.WithFeatures()) sqlStore := db.InitTestDB(t) - ssoSettingsSvc := ssosettingsimpl.ProvideService(cfg, sqlStore, accessControl, routing.NewRouteRegister(), featuremgmt.WithFeatures(), secrets, &usagestats.UsageStatsMock{}, nil, nil, &licensing.OSSLicensingService{}) + ssoSettingsSvc := ssosettingsimpl.ProvideService( + cfg, + sqlStore, + accessControl, + routing.NewRouteRegister(), + featuremgmt.WithFeatures(), + secrets, + &usagestats.UsageStatsMock{}, + nil, + nil, + &licensing.OSSLicensingService{}, + ldap.ProvideService(cfg), + ) for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { diff --git a/pkg/services/ldap/settings.go b/pkg/services/ldap/settings.go index db905557af6..459f6dadcf2 100644 --- a/pkg/services/ldap/settings.go +++ b/pkg/services/ldap/settings.go @@ -17,7 +17,7 @@ const defaultTimeout = 10 // Config holds list of connections to LDAP type Config struct { - Servers []*ServerConfig `toml:"servers"` + Servers []*ServerConfig `toml:"servers" json:"servers"` } // ServerConfig holds connection data to LDAP @@ -25,54 +25,54 @@ type ServerConfig struct { Host string `toml:"host"` Port int `toml:"port"` - UseSSL bool `toml:"use_ssl"` - StartTLS bool `toml:"start_tls"` - SkipVerifySSL bool `toml:"ssl_skip_verify"` - MinTLSVersion string `toml:"min_tls_version"` - minTLSVersion uint16 `toml:"-"` - TLSCiphers []string `toml:"tls_ciphers"` - tlsCiphers []uint16 `toml:"-"` + UseSSL bool `toml:"use_ssl" json:"use_ssl,omitempty"` + StartTLS bool `toml:"start_tls" json:"start_tls,omitempty"` + SkipVerifySSL bool `toml:"ssl_skip_verify" json:"ssl_skip_verify,omitempty"` + MinTLSVersion string `toml:"min_tls_version" json:"min_tls_version,omitempty"` + minTLSVersion uint16 `toml:"-" json:"-"` + TLSCiphers []string `toml:"tls_ciphers" json:"tls_ciphers,omitempty"` + tlsCiphers []uint16 `toml:"-" json:"-"` - RootCACert string `toml:"root_ca_cert"` - RootCACertValue []string - ClientCert string `toml:"client_cert"` - ClientCertValue string - ClientKey string `toml:"client_key"` - ClientKeyValue string - BindDN string `toml:"bind_dn"` - BindPassword string `toml:"bind_password"` - Timeout int `toml:"timeout"` - Attr AttributeMap `toml:"attributes"` + RootCACert string `toml:"root_ca_cert" json:"root_ca_cert,omitempty"` + RootCACertValue []string `json:"root_ca_cert_value,omitempty"` + ClientCert string `toml:"client_cert" json:"client_cert,omitempty"` + ClientCertValue string `json:"client_cert_value,omitempty"` + ClientKey string `toml:"client_key" json:"client_key,omitempty"` + ClientKeyValue string `json:"client_key_value,omitempty"` + BindDN string `toml:"bind_dn" json:"bind_dn,omitempty"` + BindPassword string `toml:"bind_password" json:"bind_password,omitempty"` + Timeout int `toml:"timeout" json:"timeout,omitempty"` + Attr AttributeMap `toml:"attributes" json:"attributes,omitempty"` - SearchFilter string `toml:"search_filter"` - SearchBaseDNs []string `toml:"search_base_dns"` + SearchFilter string `toml:"search_filter" json:"search_filter,omitempty"` + SearchBaseDNs []string `toml:"search_base_dns" json:"search_base_dns,omitempty"` - GroupSearchFilter string `toml:"group_search_filter"` - GroupSearchFilterUserAttribute string `toml:"group_search_filter_user_attribute"` - GroupSearchBaseDNs []string `toml:"group_search_base_dns"` + GroupSearchFilter string `toml:"group_search_filter" json:"group_search_filter,omitempty"` + GroupSearchFilterUserAttribute string `toml:"group_search_filter_user_attribute" json:"group_search_filter_user_attribute,omitempty"` + GroupSearchBaseDNs []string `toml:"group_search_base_dns" json:"group_search_base_dns,omitempty"` - Groups []*GroupToOrgRole `toml:"group_mappings"` + Groups []*GroupToOrgRole `toml:"group_mappings" json:"group_mappings,omitempty"` } // AttributeMap is a struct representation for LDAP "attributes" setting type AttributeMap struct { - Username string `toml:"username"` - Name string `toml:"name"` - Surname string `toml:"surname"` - Email string `toml:"email"` - MemberOf string `toml:"member_of"` + Username string `toml:"username" json:"username,omitempty"` + Name string `toml:"name" json:"name,omitempty"` + Surname string `toml:"surname" json:"surname,omitempty"` + Email string `toml:"email" json:"email,omitempty"` + MemberOf string `toml:"member_of" json:"member_of,omitempty"` } // GroupToOrgRole is a struct representation of LDAP // config "group_mappings" setting type GroupToOrgRole struct { - GroupDN string `toml:"group_dn"` - OrgId int64 `toml:"org_id"` + GroupDN string `toml:"group_dn" json:"group_dn"` + OrgId int64 `toml:"org_id" json:"org_id"` // This pointer specifies if setting was set (for backwards compatibility) - IsGrafanaAdmin *bool `toml:"grafana_admin"` + IsGrafanaAdmin *bool `toml:"grafana_admin" json:"grafana_admin,omitempty"` - OrgRole org.RoleType `toml:"org_role"` + OrgRole org.RoleType `toml:"org_role" json:"org_role,omitempty"` } // logger for all LDAP stuff diff --git a/pkg/services/ssosettings/ssosettingsimpl/service.go b/pkg/services/ssosettings/ssosettingsimpl/service.go index 9ada70288a9..7d8dd5341a7 100644 --- a/pkg/services/ssosettings/ssosettingsimpl/service.go +++ b/pkg/services/ssosettings/ssosettingsimpl/service.go @@ -18,6 +18,7 @@ import ( ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/ldap/service" "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/ssosettings" @@ -47,9 +48,10 @@ type Service struct { func ProvideService(cfg *setting.Cfg, sqlStore db.DB, ac ac.AccessControl, routeRegister routing.RouteRegister, features featuremgmt.FeatureToggles, secrets secrets.Service, usageStats usagestats.Service, registerer prometheus.Registerer, - settingsProvider setting.Provider, licensing licensing.Licensing) *Service { + settingsProvider setting.Provider, licensing licensing.Licensing, ldap service.LDAP) *Service { fbStrategies := []ssosettings.FallbackStrategy{ strategies.NewOAuthStrategy(cfg), + strategies.NewLDAPStrategy(cfg, ldap), } configurableProviders := make(map[string]bool) diff --git a/pkg/services/ssosettings/ssosettingsimpl/service_test.go b/pkg/services/ssosettings/ssosettingsimpl/service_test.go index 97376888580..4b2997c2ae1 100644 --- a/pkg/services/ssosettings/ssosettingsimpl/service_test.go +++ b/pkg/services/ssosettings/ssosettingsimpl/service_test.go @@ -22,6 +22,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/ldap/service" "github.com/grafana/grafana/pkg/services/licensing/licensingtest" secretsFakes "github.com/grafana/grafana/pkg/services/secrets/fakes" "github.com/grafana/grafana/pkg/services/ssosettings" @@ -1555,7 +1556,7 @@ func Test_ProviderService(t *testing.T) { "azuread", "okta", }, - strategiesLength: 1, + strategiesLength: 2, }, { name: "should return all fallback strategies and it should return all OAuth providers but not SAML because the licensing feature is enabled but the configurable provider is not setup", @@ -1569,7 +1570,7 @@ func Test_ProviderService(t *testing.T) { "azuread", "okta", }, - strategiesLength: 2, + strategiesLength: 3, }, { name: "should return all fallback strategies and it should return all OAuth providers and SAML because the licensing feature is enabled and the provider is setup", @@ -1585,7 +1586,7 @@ func Test_ProviderService(t *testing.T) { "okta", "saml", }, - strategiesLength: 2, + strategiesLength: 3, }, } for _, tc := range tests { @@ -1647,6 +1648,7 @@ func setupTestEnv(t *testing.T, isLicensingEnabled, keepFallbackStratergies, sam prometheus.NewRegistry(), &setting.OSSImpl{Cfg: cfg}, licensing, + service.ProvideService(cfg), ) // overriding values for exposed fields diff --git a/pkg/services/ssosettings/strategies/ldap_strategy.go b/pkg/services/ssosettings/strategies/ldap_strategy.go new file mode 100644 index 00000000000..afbde023a13 --- /dev/null +++ b/pkg/services/ssosettings/strategies/ldap_strategy.go @@ -0,0 +1,136 @@ +package strategies + +import ( + "bytes" + "context" + "encoding/json" + + "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/services/ldap/service" + "github.com/grafana/grafana/pkg/services/ssosettings" + "github.com/grafana/grafana/pkg/setting" +) + +type LDAPStrategy struct { + cfg *setting.Cfg + ldap service.LDAP +} + +var _ ssosettings.FallbackStrategy = (*LDAPStrategy)(nil) + +func NewLDAPStrategy(cfg *setting.Cfg, ldap service.LDAP) *LDAPStrategy { + return &LDAPStrategy{ + cfg: cfg, + ldap: ldap, + } +} + +func (s *LDAPStrategy) IsMatch(provider string) bool { + return provider == social.LDAPProviderName +} + +func (s *LDAPStrategy) GetProviderConfig(_ context.Context, _ string) (map[string]any, error) { + config, err := s.getLDAPConfig() + if err != nil { + return nil, err + } + + section := s.cfg.Raw.Section("auth.ldap") + + result := map[string]any{ + "enabled": section.Key("enabled").MustBool(false), + "config": config, + "allow_sign_up": section.Key("allow_sign_up").MustBool(false), + "skip_org_role_sync": section.Key("skip_org_role_sync").MustBool(false), + "sync_cron": section.Key("sync_cron").Value(), + "active_sync_enabled": section.Key("active_sync_enabled").MustBool(false), + } + + return result, nil +} + +func (s *LDAPStrategy) getLDAPConfig() (map[string]any, error) { + var configMap map[string]any + + config := s.ldap.Config() + configJson, err := json.Marshal(config) + if err != nil { + return nil, err + } + + d := json.NewDecoder(bytes.NewReader(configJson)) + d.UseNumber() + err = d.Decode(&configMap) + if err != nil { + return nil, err + } + + // json decodes numbers as json.Number type + // this iterates over all items in the map and returns a new map + // with all json.Number replaced by int64 + result, err := replaceNumbersInMap(configMap) + if err != nil { + return nil, err + } + + return result, nil +} + +func replaceNumbersInMap(m map[string]any) (map[string]any, error) { + var err error + + result := make(map[string]any) + for k, v := range m { + switch v := v.(type) { + case json.Number: + result[k], err = v.Int64() + if err != nil { + return nil, err + } + case []any: + result[k], err = replaceNumbersInSlice(v) + if err != nil { + return nil, err + } + case map[string]any: + result[k], err = replaceNumbersInMap(v) + if err != nil { + return nil, err + } + default: + result[k] = v + } + } + + return result, nil +} + +func replaceNumbersInSlice(s []any) ([]any, error) { + result := make([]any, 0) + for _, v := range s { + switch v := v.(type) { + case json.Number: + number, err := v.Int64() + if err != nil { + return nil, err + } + result = append(result, number) + case []any: + inner, err := replaceNumbersInSlice(v) + if err != nil { + return nil, err + } + result = append(result, inner) + case map[string]any: + inner, err := replaceNumbersInMap(v) + if err != nil { + return nil, err + } + result = append(result, inner) + default: + result = append(result, v) + } + } + + return result, nil +} diff --git a/pkg/services/ssosettings/strategies/ldap_strategy_test.go b/pkg/services/ssosettings/strategies/ldap_strategy_test.go new file mode 100644 index 00000000000..726411b44a0 --- /dev/null +++ b/pkg/services/ssosettings/strategies/ldap_strategy_test.go @@ -0,0 +1,87 @@ +package strategies + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/ini.v1" + + "github.com/grafana/grafana/pkg/services/ldap/service" + "github.com/grafana/grafana/pkg/setting" +) + +const ( + ldapConfig = `[auth.ldap] +enabled = true +config_file = ./testdata/ldap.toml +allow_sign_up = true +skip_org_role_sync = false +sync_cron = "0 1 * * *" +active_sync_enabled = true` +) + +var ( + expectedLdapConfig = map[string]interface{}{ + "enabled": true, + "allow_sign_up": true, + "skip_org_role_sync": false, + "config": map[string]interface{}{ + "servers": []interface{}{ + map[string]interface{}{ + "Host": "127.0.0.1", + "Port": int64(3389), + "attributes": map[string]interface{}{ + "email": "mail", + "member_of": "memberOf", + "name": "displayName", + "surname": "sn", + "username": "cn", + }, + "bind_dn": "cn=ldapservice,ou=users,dc=ldap,dc=goauthentik,dc=io", + "bind_password": "grafana", + "group_mappings": []interface{}{ + map[string]interface{}{ + "group_dn": "cn=admin,ou=groups,dc=ldap,dc=goauthentik,dc=io", + "org_id": int64(1), + "org_role": "Admin", + }, + map[string]interface{}{ + "group_dn": "cn=editor,ou=groups,dc=ldap,dc=goauthentik,dc=io", + "org_id": int64(1), + "org_role": "Editor", + }, + map[string]interface{}{"group_dn": "cn=viewer,ou=groups,dc=ldap,dc=goauthentik,dc=io", + "org_id": int64(1), + "org_role": "Viewer", + }, + }, + "search_base_dns": []interface{}{ + "DC=ldap,DC=goauthentik,DC=io", + }, + "search_filter": "(cn=%s)", "ssl_skip_verify": true, + "timeout": int64(10), + }, + }, + }, + "active_sync_enabled": true, + "sync_cron": "0 1 * * *", + } +) + +func TestGetLDAPConfig(t *testing.T) { + iniFile, err := ini.Load([]byte(ldapConfig)) + require.NoError(t, err) + + cfg, err := setting.NewCfgFromINIFile(iniFile) + require.NoError(t, err) + + ldap := service.ProvideService(cfg) + + strategy := NewLDAPStrategy(cfg, ldap) + + result, err := strategy.GetProviderConfig(context.Background(), "ldap") + require.NoError(t, err) + + require.Equal(t, expectedLdapConfig, result) +} diff --git a/pkg/services/ssosettings/strategies/testdata/ldap.toml b/pkg/services/ssosettings/strategies/testdata/ldap.toml new file mode 100644 index 00000000000..9c4f09baa53 --- /dev/null +++ b/pkg/services/ssosettings/strategies/testdata/ldap.toml @@ -0,0 +1,38 @@ +[[servers]] +host = "127.0.0.1" +port = 3389 +use_ssl = false +start_tls = false +ssl_skip_verify = true + +bind_dn = "cn=ldapservice,ou=users,dc=ldap,dc=goauthentik,dc=io" +bind_password = 'grafana' +timeout = 10 + +search_filter = "(cn=%s)" +search_base_dns = ["DC=ldap,DC=goauthentik,DC=io"] + +# Specify names of the ldap attributes your ldap uses +[servers.attributes] +name = "displayName" +surname = "sn" +username = "cn" +member_of = "memberOf" +email = "mail" + +# Map ldap groups to grafana org roles +[[servers.group_mappings]] +group_dn = "cn=admin,ou=groups,dc=ldap,dc=goauthentik,dc=io" +org_role = "Admin" +org_id = 1 + + +[[servers.group_mappings]] +group_dn = "cn=editor,ou=groups,dc=ldap,dc=goauthentik,dc=io" +org_role = "Editor" +org_id = 1 + +[[servers.group_mappings]] +group_dn = "cn=viewer,ou=groups,dc=ldap,dc=goauthentik,dc=io" +org_role = "Viewer" +org_id = 1