SSO: Add LDAP fallback strategy for SSO settings service (#88905)

* add root and client certificate value fields for LDAP

* update error messages for connection error

* add LDAP fallback strategy for SSO settings service

* fix params for sso service provider

* fix params for sso service provider

* sort imports

* sort imports

* replace json.Number with int64 in config map

* remove type assertions
This commit is contained in:
Mihai Doarna 2024-06-11 10:22:53 +03:00 committed by GitHub
parent d4b0ac5973
commit 3d40caf819
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 318 additions and 38 deletions

View File

@ -24,6 +24,7 @@ const (
GrafanaNetProviderName = "grafananet"
OktaProviderName = "okta"
SAMLProviderName = "saml"
LDAPProviderName = "ldap"
)
var (

View File

@ -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) {

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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
}

View File

@ -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)
}

View File

@ -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