LDAP Refactoring to support syncronizing more than one user at a time. (#16705)

* Feature: add cron setting for the ldap settings

* Move ldap configuration read to special function

* Introduce cron setting (no docs for it yet, pending approval)

* Chore: duplicate ldap module as a service

* Feature: implement active sync

This is very early preliminary implementation of active sync.
There is only one thing that's going right for this code - it works.

Aside from that, there is no tests, error handling, docs, transactions,
it's very much duplicative and etc.

But this is the overall direction with architecture I'm going for

* Chore: introduce login service

* Chore: gradually switch to ldap service

* Chore: use new approach for auth_proxy

* Chore: use new approach along with refactoring

* Chore: use new ldap interface for auth_proxy

* Chore: improve auth_proxy and subsequently ldap

* Chore: more of the refactoring bits

* Chore: address comments from code review

* Chore: more refactoring stuff

* Chore: make linter happy

* Chore: add cron dep for grafana enterprise

* Chore: initialize config package var

* Chore: disable gosec for now

* Chore: update dependencies

* Chore: remove unused module

* Chore: address review comments

* Chore: make linter happy
This commit is contained in:
Oleg Gaidarenko
2019-04-26 15:47:16 +03:00
committed by GitHub
parent a8326e3e93
commit 62b85a886e
33 changed files with 2210 additions and 878 deletions

View File

@@ -0,0 +1,5 @@
package ldap
var (
hookDial func(*Auth) error
)

559
pkg/services/ldap/ldap.go Normal file
View File

@@ -0,0 +1,559 @@
package ldap
import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io/ioutil"
"strings"
"github.com/davecgh/go-spew/spew"
LDAP "gopkg.in/ldap.v3"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/log"
models "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
)
// IConnection is interface for LDAP connection manipulation
type IConnection interface {
Bind(username, password string) error
UnauthenticatedBind(username string) error
Search(*LDAP.SearchRequest) (*LDAP.SearchResult, error)
StartTLS(*tls.Config) error
Close()
}
// IAuth is interface for LDAP authorization
type IAuth interface {
Login(query *models.LoginUserQuery) error
SyncUser(query *models.LoginUserQuery) error
GetGrafanaUserFor(
ctx *models.ReqContext,
user *UserInfo,
) (*models.User, error)
Users() ([]*UserInfo, error)
}
// Auth is basic struct of LDAP authorization
type Auth struct {
server *ServerConfig
conn IConnection
requireSecondBind bool
log log.Logger
}
var (
// ErrInvalidCredentials is returned if username and password do not match
ErrInvalidCredentials = errors.New("Invalid Username or Password")
)
var dial = func(network, addr string) (IConnection, error) {
return LDAP.Dial(network, addr)
}
// New creates the new LDAP auth
func New(server *ServerConfig) IAuth {
return &Auth{
server: server,
log: log.New("ldap"),
}
}
// Dial dials in the LDAP
func (auth *Auth) Dial() error {
if hookDial != nil {
return hookDial(auth)
}
var err error
var certPool *x509.CertPool
if auth.server.RootCACert != "" {
certPool = x509.NewCertPool()
for _, caCertFile := range strings.Split(auth.server.RootCACert, " ") {
pem, err := ioutil.ReadFile(caCertFile)
if err != nil {
return err
}
if !certPool.AppendCertsFromPEM(pem) {
return errors.New("Failed to append CA certificate " + caCertFile)
}
}
}
var clientCert tls.Certificate
if auth.server.ClientCert != "" && auth.server.ClientKey != "" {
clientCert, err = tls.LoadX509KeyPair(auth.server.ClientCert, auth.server.ClientKey)
if err != nil {
return err
}
}
for _, host := range strings.Split(auth.server.Host, " ") {
address := fmt.Sprintf("%s:%d", host, auth.server.Port)
if auth.server.UseSSL {
tlsCfg := &tls.Config{
InsecureSkipVerify: auth.server.SkipVerifySSL,
ServerName: host,
RootCAs: certPool,
}
if len(clientCert.Certificate) > 0 {
tlsCfg.Certificates = append(tlsCfg.Certificates, clientCert)
}
if auth.server.StartTLS {
auth.conn, err = dial("tcp", address)
if err == nil {
if err = auth.conn.StartTLS(tlsCfg); err == nil {
return nil
}
}
} else {
auth.conn, err = LDAP.DialTLS("tcp", address, tlsCfg)
}
} else {
auth.conn, err = dial("tcp", address)
}
if err == nil {
return nil
}
}
return err
}
// Login logs in the user
func (auth *Auth) Login(query *models.LoginUserQuery) error {
// connect to ldap server
if err := auth.Dial(); err != nil {
return err
}
defer auth.conn.Close()
// perform initial authentication
if err := auth.initialBind(query.Username, query.Password); err != nil {
return err
}
// find user entry & attributes
user, err := auth.searchForUser(query.Username)
if err != nil {
return err
}
auth.log.Debug("Ldap User found", "info", spew.Sdump(user))
// check if a second user bind is needed
if auth.requireSecondBind {
err = auth.secondBind(user, query.Password)
if err != nil {
return err
}
}
grafanaUser, err := auth.GetGrafanaUserFor(query.ReqContext, user)
if err != nil {
return err
}
query.User = grafanaUser
return nil
}
// SyncUser syncs user with Grafana
func (auth *Auth) SyncUser(query *models.LoginUserQuery) error {
// connect to ldap server
err := auth.Dial()
if err != nil {
return err
}
defer auth.conn.Close()
err = auth.serverBind()
if err != nil {
return err
}
// find user entry & attributes
user, err := auth.searchForUser(query.Username)
if err != nil {
auth.log.Error("Failed searching for user in ldap", "error", err)
return err
}
auth.log.Debug("Ldap User found", "info", spew.Sdump(user))
grafanaUser, err := auth.GetGrafanaUserFor(query.ReqContext, user)
if err != nil {
return err
}
query.User = grafanaUser
return nil
}
func (auth *Auth) GetGrafanaUserFor(
ctx *models.ReqContext,
user *UserInfo,
) (*models.User, error) {
extUser := &models.ExternalUserInfo{
AuthModule: "ldap",
AuthId: user.DN,
Name: fmt.Sprintf("%s %s", user.FirstName, user.LastName),
Login: user.Username,
Email: user.Email,
Groups: user.MemberOf,
OrgRoles: map[int64]models.RoleType{},
}
for _, group := range auth.server.Groups {
// only use the first match for each org
if extUser.OrgRoles[group.OrgId] != "" {
continue
}
if user.isMemberOf(group.GroupDN) {
extUser.OrgRoles[group.OrgId] = group.OrgRole
if extUser.IsGrafanaAdmin == nil || !*extUser.IsGrafanaAdmin {
extUser.IsGrafanaAdmin = group.IsGrafanaAdmin
}
}
}
// validate that the user has access
// if there are no ldap group mappings access is true
// otherwise a single group must match
if len(auth.server.Groups) > 0 && len(extUser.OrgRoles) < 1 {
auth.log.Info(
"Ldap Auth: user does not belong in any of the specified ldap groups",
"username", user.Username,
"groups", user.MemberOf,
)
return nil, ErrInvalidCredentials
}
// add/update user in grafana
upsertUserCmd := &models.UpsertUserCommand{
ReqContext: ctx,
ExternalUser: extUser,
SignupAllowed: setting.LdapAllowSignup,
}
err := bus.Dispatch(upsertUserCmd)
if err != nil {
return nil, err
}
return upsertUserCmd.Result, nil
}
func (auth *Auth) serverBind() error {
bindFn := func() error {
return auth.conn.Bind(auth.server.BindDN, auth.server.BindPassword)
}
if auth.server.BindPassword == "" {
bindFn = func() error {
return auth.conn.UnauthenticatedBind(auth.server.BindDN)
}
}
// bind_dn and bind_password to bind
if err := bindFn(); err != nil {
auth.log.Info("LDAP initial bind failed, %v", err)
if ldapErr, ok := err.(*LDAP.Error); ok {
if ldapErr.ResultCode == 49 {
return ErrInvalidCredentials
}
}
return err
}
return nil
}
func (auth *Auth) secondBind(user *UserInfo, userPassword string) error {
if err := auth.conn.Bind(user.DN, userPassword); err != nil {
auth.log.Info("Second bind failed", "error", err)
if ldapErr, ok := err.(*LDAP.Error); ok {
if ldapErr.ResultCode == 49 {
return ErrInvalidCredentials
}
}
return err
}
return nil
}
func (auth *Auth) initialBind(username, userPassword string) error {
if auth.server.BindPassword != "" || auth.server.BindDN == "" {
userPassword = auth.server.BindPassword
auth.requireSecondBind = true
}
bindPath := auth.server.BindDN
if strings.Contains(bindPath, "%s") {
bindPath = fmt.Sprintf(auth.server.BindDN, username)
}
bindFn := func() error {
return auth.conn.Bind(bindPath, userPassword)
}
if userPassword == "" {
bindFn = func() error {
return auth.conn.UnauthenticatedBind(bindPath)
}
}
if err := bindFn(); err != nil {
auth.log.Info("Initial bind failed", "error", err)
if ldapErr, ok := err.(*LDAP.Error); ok {
if ldapErr.ResultCode == 49 {
return ErrInvalidCredentials
}
}
return err
}
return nil
}
func (auth *Auth) searchForUser(username string) (*UserInfo, error) {
var searchResult *LDAP.SearchResult
var err error
for _, searchBase := range auth.server.SearchBaseDNs {
attributes := make([]string, 0)
inputs := auth.server.Attr
attributes = appendIfNotEmpty(attributes,
inputs.Username,
inputs.Surname,
inputs.Email,
inputs.Name,
inputs.MemberOf)
searchReq := LDAP.SearchRequest{
BaseDN: searchBase,
Scope: LDAP.ScopeWholeSubtree,
DerefAliases: LDAP.NeverDerefAliases,
Attributes: attributes,
Filter: strings.Replace(
auth.server.SearchFilter,
"%s", LDAP.EscapeFilter(username),
-1,
),
}
auth.log.Debug("Ldap Search For User Request", "info", spew.Sdump(searchReq))
searchResult, err = auth.conn.Search(&searchReq)
if err != nil {
return nil, err
}
if len(searchResult.Entries) > 0 {
break
}
}
if len(searchResult.Entries) == 0 {
return nil, ErrInvalidCredentials
}
if len(searchResult.Entries) > 1 {
return nil, errors.New("Ldap search matched more than one entry, please review your filter setting")
}
var memberOf []string
if auth.server.GroupSearchFilter == "" {
memberOf = getLdapAttrArray(auth.server.Attr.MemberOf, searchResult)
} else {
// If we are using a POSIX LDAP schema it won't support memberOf, so we manually search the groups
var groupSearchResult *LDAP.SearchResult
for _, groupSearchBase := range auth.server.GroupSearchBaseDNs {
var filter_replace string
if auth.server.GroupSearchFilterUserAttribute == "" {
filter_replace = getLdapAttr(auth.server.Attr.Username, searchResult)
} else {
filter_replace = getLdapAttr(auth.server.GroupSearchFilterUserAttribute, searchResult)
}
filter := strings.Replace(
auth.server.GroupSearchFilter, "%s",
LDAP.EscapeFilter(filter_replace),
-1,
)
auth.log.Info("Searching for user's groups", "filter", filter)
// support old way of reading settings
groupIdAttribute := auth.server.Attr.MemberOf
// but prefer dn attribute if default settings are used
if groupIdAttribute == "" || groupIdAttribute == "memberOf" {
groupIdAttribute = "dn"
}
groupSearchReq := LDAP.SearchRequest{
BaseDN: groupSearchBase,
Scope: LDAP.ScopeWholeSubtree,
DerefAliases: LDAP.NeverDerefAliases,
Attributes: []string{groupIdAttribute},
Filter: filter,
}
groupSearchResult, err = auth.conn.Search(&groupSearchReq)
if err != nil {
return nil, err
}
if len(groupSearchResult.Entries) > 0 {
for i := range groupSearchResult.Entries {
memberOf = append(memberOf, getLdapAttrN(groupIdAttribute, groupSearchResult, i))
}
break
}
}
}
return &UserInfo{
DN: searchResult.Entries[0].DN,
LastName: getLdapAttr(auth.server.Attr.Surname, searchResult),
FirstName: getLdapAttr(auth.server.Attr.Name, searchResult),
Username: getLdapAttr(auth.server.Attr.Username, searchResult),
Email: getLdapAttr(auth.server.Attr.Email, searchResult),
MemberOf: memberOf,
}, nil
}
func (ldap *Auth) Users() ([]*UserInfo, error) {
var result *LDAP.SearchResult
var err error
server := ldap.server
if err := ldap.Dial(); err != nil {
return nil, err
}
defer ldap.conn.Close()
for _, base := range server.SearchBaseDNs {
attributes := make([]string, 0)
inputs := server.Attr
attributes = appendIfNotEmpty(
attributes,
inputs.Username,
inputs.Surname,
inputs.Email,
inputs.Name,
inputs.MemberOf,
)
req := LDAP.SearchRequest{
BaseDN: base,
Scope: LDAP.ScopeWholeSubtree,
DerefAliases: LDAP.NeverDerefAliases,
Attributes: attributes,
// Doing a star here to get all the users in one go
Filter: strings.Replace(server.SearchFilter, "%s", "*", -1),
}
result, err = ldap.conn.Search(&req)
if err != nil {
return nil, err
}
if len(result.Entries) > 0 {
break
}
}
return ldap.serializeUsers(result), nil
}
func (ldap *Auth) serializeUsers(users *LDAP.SearchResult) []*UserInfo {
var serialized []*UserInfo
for index := range users.Entries {
serialize := &UserInfo{
DN: getLdapAttrN(
"dn",
users,
index,
),
LastName: getLdapAttrN(
ldap.server.Attr.Surname,
users,
index,
),
FirstName: getLdapAttrN(
ldap.server.Attr.Name,
users,
index,
),
Username: getLdapAttrN(
ldap.server.Attr.Username,
users,
index,
),
Email: getLdapAttrN(
ldap.server.Attr.Email,
users,
index,
),
MemberOf: getLdapAttrArrayN(
ldap.server.Attr.MemberOf,
users,
index,
),
}
serialized = append(serialized, serialize)
}
return serialized
}
func appendIfNotEmpty(slice []string, values ...string) []string {
for _, v := range values {
if v != "" {
slice = append(slice, v)
}
}
return slice
}
func getLdapAttr(name string, result *LDAP.SearchResult) string {
return getLdapAttrN(name, result, 0)
}
func getLdapAttrN(name string, result *LDAP.SearchResult, n int) string {
if strings.ToLower(name) == "dn" {
return result.Entries[n].DN
}
for _, attr := range result.Entries[n].Attributes {
if attr.Name == name {
if len(attr.Values) > 0 {
return attr.Values[0]
}
}
}
return ""
}
func getLdapAttrArray(name string, result *LDAP.SearchResult) []string {
return getLdapAttrArrayN(name, result, 0)
}
func getLdapAttrArrayN(name string, result *LDAP.SearchResult, n int) []string {
for _, attr := range result.Entries[n].Attributes {
if attr.Name == name {
return attr.Values
}
}
return []string{}
}

View File

@@ -0,0 +1,86 @@
package ldap
import (
"testing"
. "github.com/smartystreets/goconvey/convey"
"gopkg.in/ldap.v3"
"github.com/grafana/grafana/pkg/log"
)
func TestLdapLogin(t *testing.T) {
Convey("Login using ldap", t, func() {
AuthScenario("When login with invalid credentials", func(scenario *scenarioContext) {
conn := &mockLdapConn{}
entry := ldap.Entry{}
result := ldap.SearchResult{Entries: []*ldap.Entry{&entry}}
conn.setSearchResult(&result)
conn.bindProvider = func(username, password string) error {
return &ldap.Error{
ResultCode: 49,
}
}
auth := &Auth{
server: &ServerConfig{
Attr: AttributeMap{
Username: "username",
Name: "name",
MemberOf: "memberof",
},
SearchBaseDNs: []string{"BaseDNHere"},
},
conn: conn,
log: log.New("test-logger"),
}
err := auth.Login(scenario.loginUserQuery)
Convey("it should return invalid credentials error", func() {
So(err, ShouldEqual, ErrInvalidCredentials)
})
})
AuthScenario("When login with valid credentials", func(scenario *scenarioContext) {
conn := &mockLdapConn{}
entry := ldap.Entry{
DN: "dn", Attributes: []*ldap.EntryAttribute{
{Name: "username", Values: []string{"markelog"}},
{Name: "surname", Values: []string{"Gaidarenko"}},
{Name: "email", Values: []string{"markelog@gmail.com"}},
{Name: "name", Values: []string{"Oleg"}},
{Name: "memberof", Values: []string{"admins"}},
},
}
result := ldap.SearchResult{Entries: []*ldap.Entry{&entry}}
conn.setSearchResult(&result)
conn.bindProvider = func(username, password string) error {
return nil
}
auth := &Auth{
server: &ServerConfig{
Attr: AttributeMap{
Username: "username",
Name: "name",
MemberOf: "memberof",
},
SearchBaseDNs: []string{"BaseDNHere"},
},
conn: conn,
log: log.New("test-logger"),
}
err := auth.Login(scenario.loginUserQuery)
Convey("it should not return error", func() {
So(err, ShouldBeNil)
})
Convey("it should get user", func() {
So(scenario.loginUserQuery.User.Login, ShouldEqual, "markelog")
})
})
})
}

View File

@@ -0,0 +1,496 @@
package ldap
import (
"context"
"testing"
. "github.com/smartystreets/goconvey/convey"
"gopkg.in/ldap.v3"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/log"
m "github.com/grafana/grafana/pkg/models"
)
func TestAuth(t *testing.T) {
Convey("initialBind", t, func() {
Convey("Given bind dn and password configured", func() {
conn := &mockLdapConn{}
var actualUsername, actualPassword string
conn.bindProvider = func(username, password string) error {
actualUsername = username
actualPassword = password
return nil
}
Auth := &Auth{
conn: conn,
server: &ServerConfig{
BindDN: "cn=%s,o=users,dc=grafana,dc=org",
BindPassword: "bindpwd",
},
}
err := Auth.initialBind("user", "pwd")
So(err, ShouldBeNil)
So(Auth.requireSecondBind, ShouldBeTrue)
So(actualUsername, ShouldEqual, "cn=user,o=users,dc=grafana,dc=org")
So(actualPassword, ShouldEqual, "bindpwd")
})
Convey("Given bind dn configured", func() {
conn := &mockLdapConn{}
var actualUsername, actualPassword string
conn.bindProvider = func(username, password string) error {
actualUsername = username
actualPassword = password
return nil
}
Auth := &Auth{
conn: conn,
server: &ServerConfig{
BindDN: "cn=%s,o=users,dc=grafana,dc=org",
},
}
err := Auth.initialBind("user", "pwd")
So(err, ShouldBeNil)
So(Auth.requireSecondBind, ShouldBeFalse)
So(actualUsername, ShouldEqual, "cn=user,o=users,dc=grafana,dc=org")
So(actualPassword, ShouldEqual, "pwd")
})
Convey("Given empty bind dn and password", func() {
conn := &mockLdapConn{}
unauthenticatedBindWasCalled := false
var actualUsername string
conn.unauthenticatedBindProvider = func(username string) error {
unauthenticatedBindWasCalled = true
actualUsername = username
return nil
}
Auth := &Auth{
conn: conn,
server: &ServerConfig{},
}
err := Auth.initialBind("user", "pwd")
So(err, ShouldBeNil)
So(Auth.requireSecondBind, ShouldBeTrue)
So(unauthenticatedBindWasCalled, ShouldBeTrue)
So(actualUsername, ShouldBeEmpty)
})
})
Convey("serverBind", t, func() {
Convey("Given bind dn and password configured", func() {
conn := &mockLdapConn{}
var actualUsername, actualPassword string
conn.bindProvider = func(username, password string) error {
actualUsername = username
actualPassword = password
return nil
}
Auth := &Auth{
conn: conn,
server: &ServerConfig{
BindDN: "o=users,dc=grafana,dc=org",
BindPassword: "bindpwd",
},
}
err := Auth.serverBind()
So(err, ShouldBeNil)
So(actualUsername, ShouldEqual, "o=users,dc=grafana,dc=org")
So(actualPassword, ShouldEqual, "bindpwd")
})
Convey("Given bind dn configured", func() {
conn := &mockLdapConn{}
unauthenticatedBindWasCalled := false
var actualUsername string
conn.unauthenticatedBindProvider = func(username string) error {
unauthenticatedBindWasCalled = true
actualUsername = username
return nil
}
Auth := &Auth{
conn: conn,
server: &ServerConfig{
BindDN: "o=users,dc=grafana,dc=org",
},
}
err := Auth.serverBind()
So(err, ShouldBeNil)
So(unauthenticatedBindWasCalled, ShouldBeTrue)
So(actualUsername, ShouldEqual, "o=users,dc=grafana,dc=org")
})
Convey("Given empty bind dn and password", func() {
conn := &mockLdapConn{}
unauthenticatedBindWasCalled := false
var actualUsername string
conn.unauthenticatedBindProvider = func(username string) error {
unauthenticatedBindWasCalled = true
actualUsername = username
return nil
}
Auth := &Auth{
conn: conn,
server: &ServerConfig{},
}
err := Auth.serverBind()
So(err, ShouldBeNil)
So(unauthenticatedBindWasCalled, ShouldBeTrue)
So(actualUsername, ShouldBeEmpty)
})
})
Convey("When translating ldap user to grafana user", t, func() {
var user1 = &m.User{}
bus.AddHandlerCtx("test", func(ctx context.Context, cmd *m.UpsertUserCommand) error {
cmd.Result = user1
cmd.Result.Login = "torkelo"
return nil
})
Convey("Given no ldap group map match", func() {
Auth := New(&ServerConfig{
Groups: []*GroupToOrgRole{{}},
})
_, err := Auth.GetGrafanaUserFor(nil, &UserInfo{})
So(err, ShouldEqual, ErrInvalidCredentials)
})
AuthScenario("Given wildcard group match", func(sc *scenarioContext) {
Auth := New(&ServerConfig{
Groups: []*GroupToOrgRole{
{GroupDN: "*", OrgRole: "Admin"},
},
})
sc.userQueryReturns(user1)
result, err := Auth.GetGrafanaUserFor(nil, &UserInfo{})
So(err, ShouldBeNil)
So(result, ShouldEqual, user1)
})
AuthScenario("Given exact group match", func(sc *scenarioContext) {
Auth := New(&ServerConfig{
Groups: []*GroupToOrgRole{
{GroupDN: "cn=users", OrgRole: "Admin"},
},
})
sc.userQueryReturns(user1)
result, err := Auth.GetGrafanaUserFor(nil, &UserInfo{MemberOf: []string{"cn=users"}})
So(err, ShouldBeNil)
So(result, ShouldEqual, user1)
})
AuthScenario("Given group match with different case", func(sc *scenarioContext) {
Auth := New(&ServerConfig{
Groups: []*GroupToOrgRole{
{GroupDN: "cn=users", OrgRole: "Admin"},
},
})
sc.userQueryReturns(user1)
result, err := Auth.GetGrafanaUserFor(nil, &UserInfo{MemberOf: []string{"CN=users"}})
So(err, ShouldBeNil)
So(result, ShouldEqual, user1)
})
AuthScenario("Given no existing grafana user", func(sc *scenarioContext) {
Auth := New(&ServerConfig{
Groups: []*GroupToOrgRole{
{GroupDN: "cn=admin", OrgRole: "Admin"},
{GroupDN: "cn=editor", OrgRole: "Editor"},
{GroupDN: "*", OrgRole: "Viewer"},
},
})
sc.userQueryReturns(nil)
result, err := Auth.GetGrafanaUserFor(nil, &UserInfo{
DN: "torkelo",
Username: "torkelo",
Email: "my@email.com",
MemberOf: []string{"cn=editor"},
})
So(err, ShouldBeNil)
Convey("Should return new user", func() {
So(result.Login, ShouldEqual, "torkelo")
})
Convey("Should set isGrafanaAdmin to false by default", func() {
So(result.IsAdmin, ShouldBeFalse)
})
})
})
Convey("When syncing ldap groups to grafana org roles", t, func() {
AuthScenario("given no current user orgs", func(sc *scenarioContext) {
Auth := New(&ServerConfig{
Groups: []*GroupToOrgRole{
{GroupDN: "cn=users", OrgRole: "Admin"},
},
})
sc.userOrgsQueryReturns([]*m.UserOrgDTO{})
_, err := Auth.GetGrafanaUserFor(nil, &UserInfo{
MemberOf: []string{"cn=users"},
})
Convey("Should create new org user", func() {
So(err, ShouldBeNil)
So(sc.addOrgUserCmd, ShouldNotBeNil)
So(sc.addOrgUserCmd.Role, ShouldEqual, m.ROLE_ADMIN)
})
})
AuthScenario("given different current org role", func(sc *scenarioContext) {
Auth := New(&ServerConfig{
Groups: []*GroupToOrgRole{
{GroupDN: "cn=users", OrgId: 1, OrgRole: "Admin"},
},
})
sc.userOrgsQueryReturns([]*m.UserOrgDTO{{OrgId: 1, Role: m.ROLE_EDITOR}})
_, err := Auth.GetGrafanaUserFor(nil, &UserInfo{
MemberOf: []string{"cn=users"},
})
Convey("Should update org role", func() {
So(err, ShouldBeNil)
So(sc.updateOrgUserCmd, ShouldNotBeNil)
So(sc.updateOrgUserCmd.Role, ShouldEqual, m.ROLE_ADMIN)
So(sc.setUsingOrgCmd.OrgId, ShouldEqual, 1)
})
})
AuthScenario("given current org role is removed in ldap", func(sc *scenarioContext) {
Auth := New(&ServerConfig{
Groups: []*GroupToOrgRole{
{GroupDN: "cn=users", OrgId: 2, OrgRole: "Admin"},
},
})
sc.userOrgsQueryReturns([]*m.UserOrgDTO{
{OrgId: 1, Role: m.ROLE_EDITOR},
{OrgId: 2, Role: m.ROLE_EDITOR},
})
_, err := Auth.GetGrafanaUserFor(nil, &UserInfo{
MemberOf: []string{"cn=users"},
})
Convey("Should remove org role", func() {
So(err, ShouldBeNil)
So(sc.removeOrgUserCmd, ShouldNotBeNil)
So(sc.setUsingOrgCmd.OrgId, ShouldEqual, 2)
})
})
AuthScenario("given org role is updated in config", func(sc *scenarioContext) {
Auth := New(&ServerConfig{
Groups: []*GroupToOrgRole{
{GroupDN: "cn=admin", OrgId: 1, OrgRole: "Admin"},
{GroupDN: "cn=users", OrgId: 1, OrgRole: "Viewer"},
},
})
sc.userOrgsQueryReturns([]*m.UserOrgDTO{{OrgId: 1, Role: m.ROLE_EDITOR}})
_, err := Auth.GetGrafanaUserFor(nil, &UserInfo{
MemberOf: []string{"cn=users"},
})
Convey("Should update org role", func() {
So(err, ShouldBeNil)
So(sc.removeOrgUserCmd, ShouldBeNil)
So(sc.updateOrgUserCmd, ShouldNotBeNil)
So(sc.setUsingOrgCmd.OrgId, ShouldEqual, 1)
})
})
AuthScenario("given multiple matching ldap groups", func(sc *scenarioContext) {
Auth := New(&ServerConfig{
Groups: []*GroupToOrgRole{
{GroupDN: "cn=admins", OrgId: 1, OrgRole: "Admin"},
{GroupDN: "*", OrgId: 1, OrgRole: "Viewer"},
},
})
sc.userOrgsQueryReturns([]*m.UserOrgDTO{{OrgId: 1, Role: m.ROLE_ADMIN}})
_, err := Auth.GetGrafanaUserFor(nil, &UserInfo{
MemberOf: []string{"cn=admins"},
})
Convey("Should take first match, and ignore subsequent matches", func() {
So(err, ShouldBeNil)
So(sc.updateOrgUserCmd, ShouldBeNil)
So(sc.setUsingOrgCmd.OrgId, ShouldEqual, 1)
})
})
AuthScenario("given multiple matching ldap groups and no existing groups", func(sc *scenarioContext) {
Auth := New(&ServerConfig{
Groups: []*GroupToOrgRole{
{GroupDN: "cn=admins", OrgId: 1, OrgRole: "Admin"},
{GroupDN: "*", OrgId: 1, OrgRole: "Viewer"},
},
})
sc.userOrgsQueryReturns([]*m.UserOrgDTO{})
_, err := Auth.GetGrafanaUserFor(nil, &UserInfo{
MemberOf: []string{"cn=admins"},
})
Convey("Should take first match, and ignore subsequent matches", func() {
So(err, ShouldBeNil)
So(sc.addOrgUserCmd.Role, ShouldEqual, m.ROLE_ADMIN)
So(sc.setUsingOrgCmd.OrgId, ShouldEqual, 1)
})
Convey("Should not update permissions unless specified", func() {
So(err, ShouldBeNil)
So(sc.updateUserPermissionsCmd, ShouldBeNil)
})
})
AuthScenario("given ldap groups with grafana_admin=true", func(sc *scenarioContext) {
trueVal := true
Auth := New(&ServerConfig{
Groups: []*GroupToOrgRole{
{GroupDN: "cn=admins", OrgId: 1, OrgRole: "Admin", IsGrafanaAdmin: &trueVal},
},
})
sc.userOrgsQueryReturns([]*m.UserOrgDTO{})
_, err := Auth.GetGrafanaUserFor(nil, &UserInfo{
MemberOf: []string{"cn=admins"},
})
Convey("Should create user with admin set to true", func() {
So(err, ShouldBeNil)
So(sc.updateUserPermissionsCmd.IsGrafanaAdmin, ShouldBeTrue)
})
})
})
Convey("When calling SyncUser", t, func() {
mockLdapConnection := &mockLdapConn{}
auth := &Auth{
server: &ServerConfig{
Host: "",
RootCACert: "",
Groups: []*GroupToOrgRole{
{GroupDN: "*", OrgRole: "Admin"},
},
Attr: AttributeMap{
Username: "username",
Surname: "surname",
Email: "email",
Name: "name",
MemberOf: "memberof",
},
SearchBaseDNs: []string{"BaseDNHere"},
},
conn: mockLdapConnection,
log: log.New("test-logger"),
}
dialCalled := false
dial = func(network, addr string) (IConnection, error) {
dialCalled = true
return mockLdapConnection, nil
}
entry := ldap.Entry{
DN: "dn", Attributes: []*ldap.EntryAttribute{
{Name: "username", Values: []string{"roelgerrits"}},
{Name: "surname", Values: []string{"Gerrits"}},
{Name: "email", Values: []string{"roel@test.com"}},
{Name: "name", Values: []string{"Roel"}},
{Name: "memberof", Values: []string{"admins"}},
}}
result := ldap.SearchResult{Entries: []*ldap.Entry{&entry}}
mockLdapConnection.setSearchResult(&result)
AuthScenario("When ldapUser found call syncInfo and orgRoles", func(sc *scenarioContext) {
// arrange
query := &m.LoginUserQuery{
Username: "roelgerrits",
}
hookDial = nil
sc.userQueryReturns(&m.User{
Id: 1,
Email: "roel@test.net",
Name: "Roel Gerrits",
Login: "roelgerrits",
})
sc.userOrgsQueryReturns([]*m.UserOrgDTO{})
// act
syncErrResult := auth.SyncUser(query)
// assert
So(dialCalled, ShouldBeTrue)
So(syncErrResult, ShouldBeNil)
// User should be searched in ldap
So(mockLdapConnection.searchCalled, ShouldBeTrue)
// Info should be updated (email differs)
So(sc.updateUserCmd.Email, ShouldEqual, "roel@test.com")
// User should have admin privileges
So(sc.addOrgUserCmd.UserId, ShouldEqual, 1)
So(sc.addOrgUserCmd.Role, ShouldEqual, "Admin")
})
})
Convey("When searching for a user and not all five attributes are mapped", t, func() {
mockLdapConnection := &mockLdapConn{}
entry := ldap.Entry{
DN: "dn", Attributes: []*ldap.EntryAttribute{
{Name: "username", Values: []string{"roelgerrits"}},
{Name: "surname", Values: []string{"Gerrits"}},
{Name: "email", Values: []string{"roel@test.com"}},
{Name: "name", Values: []string{"Roel"}},
{Name: "memberof", Values: []string{"admins"}},
}}
result := ldap.SearchResult{Entries: []*ldap.Entry{&entry}}
mockLdapConnection.setSearchResult(&result)
// Set up attribute map without surname and email
Auth := &Auth{
server: &ServerConfig{
Attr: AttributeMap{
Username: "username",
Name: "name",
MemberOf: "memberof",
},
SearchBaseDNs: []string{"BaseDNHere"},
},
conn: mockLdapConnection,
log: log.New("test-logger"),
}
searchResult, err := Auth.searchForUser("roelgerrits")
So(err, ShouldBeNil)
So(searchResult, ShouldNotBeNil)
// User should be searched in ldap
So(mockLdapConnection.searchCalled, ShouldBeTrue)
// No empty attributes should be added to the search request
So(len(mockLdapConnection.searchAttributes), ShouldEqual, 3)
})
}

View File

@@ -0,0 +1,126 @@
package ldap
import (
"fmt"
"os"
"github.com/BurntSushi/toml"
"github.com/grafana/grafana/pkg/log"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
)
type Config struct {
Servers []*ServerConfig `toml:"servers"`
}
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"`
RootCACert string `toml:"root_ca_cert"`
ClientCert string `toml:"client_cert"`
ClientKey string `toml:"client_key"`
BindDN string `toml:"bind_dn"`
BindPassword string `toml:"bind_password"`
Attr AttributeMap `toml:"attributes"`
SearchFilter string `toml:"search_filter"`
SearchBaseDNs []string `toml:"search_base_dns"`
GroupSearchFilter string `toml:"group_search_filter"`
GroupSearchFilterUserAttribute string `toml:"group_search_filter_user_attribute"`
GroupSearchBaseDNs []string `toml:"group_search_base_dns"`
Groups []*GroupToOrgRole `toml:"group_mappings"`
}
type AttributeMap struct {
Username string `toml:"username"`
Name string `toml:"name"`
Surname string `toml:"surname"`
Email string `toml:"email"`
MemberOf string `toml:"member_of"`
}
type GroupToOrgRole struct {
GroupDN string `toml:"group_dn"`
OrgId int64 `toml:"org_id"`
IsGrafanaAdmin *bool `toml:"grafana_admin"` // This is a pointer to know if it was set or not (for backwards compatibility)
OrgRole m.RoleType `toml:"org_role"`
}
var config *Config
var logger = log.New("ldap")
// IsEnabled checks if ldap is enabled
func IsEnabled() bool {
return setting.LdapEnabled
}
// ReadConfig reads the config if
// ldap is enabled otherwise it will return nil
func ReadConfig() *Config {
if IsEnabled() == false {
return nil
}
// Make it a singleton
if config != nil {
return config
}
config = getConfig(setting.LdapConfigFile)
return config
}
func getConfig(configFile string) *Config {
result := &Config{}
logger.Info("Ldap enabled, reading config file", "file", configFile)
_, err := toml.DecodeFile(configFile, result)
if err != nil {
logger.Crit("Failed to load ldap config file", "error", err)
os.Exit(1)
}
if len(result.Servers) == 0 {
logger.Crit("ldap enabled but no ldap servers defined in config file")
os.Exit(1)
}
// set default org id
for _, server := range result.Servers {
assertNotEmptyCfg(server.SearchFilter, "search_filter")
assertNotEmptyCfg(server.SearchBaseDNs, "search_base_dns")
for _, groupMap := range server.Groups {
if groupMap.OrgId == 0 {
groupMap.OrgId = 1
}
}
}
return result
}
func assertNotEmptyCfg(val interface{}, propName string) {
switch v := val.(type) {
case string:
if v == "" {
logger.Crit("LDAP config file is missing option", "option", propName)
os.Exit(1)
}
case []string:
if len(v) == 0 {
logger.Crit("LDAP config file is missing option", "option", propName)
os.Exit(1)
}
default:
fmt.Println("unknown")
}
}

165
pkg/services/ldap/test.go Normal file
View File

@@ -0,0 +1,165 @@
package ldap
import (
"context"
"crypto/tls"
. "github.com/smartystreets/goconvey/convey"
"gopkg.in/ldap.v3"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/login"
)
type mockLdapConn struct {
result *ldap.SearchResult
searchCalled bool
searchAttributes []string
bindProvider func(username, password string) error
unauthenticatedBindProvider func(username string) error
}
func (c *mockLdapConn) Bind(username, password string) error {
if c.bindProvider != nil {
return c.bindProvider(username, password)
}
return nil
}
func (c *mockLdapConn) UnauthenticatedBind(username string) error {
if c.unauthenticatedBindProvider != nil {
return c.unauthenticatedBindProvider(username)
}
return nil
}
func (c *mockLdapConn) Close() {}
func (c *mockLdapConn) setSearchResult(result *ldap.SearchResult) {
c.result = result
}
func (c *mockLdapConn) Search(sr *ldap.SearchRequest) (*ldap.SearchResult, error) {
c.searchCalled = true
c.searchAttributes = sr.Attributes
return c.result, nil
}
func (c *mockLdapConn) StartTLS(*tls.Config) error {
return nil
}
func AuthScenario(desc string, fn scenarioFunc) {
Convey(desc, func() {
defer bus.ClearBusHandlers()
sc := &scenarioContext{
loginUserQuery: &models.LoginUserQuery{
Username: "user",
Password: "pwd",
IpAddress: "192.168.1.1:56433",
},
}
hookDial = func(auth *Auth) error {
return nil
}
loginService := &login.LoginService{
Bus: bus.GetBus(),
}
bus.AddHandler("test", loginService.UpsertUser)
bus.AddHandlerCtx("test", func(ctx context.Context, cmd *models.SyncTeamsCommand) error {
return nil
})
bus.AddHandlerCtx("test", func(ctx context.Context, cmd *models.UpdateUserPermissionsCommand) error {
sc.updateUserPermissionsCmd = cmd
return nil
})
bus.AddHandler("test", func(cmd *models.GetUserByAuthInfoQuery) error {
sc.getUserByAuthInfoQuery = cmd
sc.getUserByAuthInfoQuery.Result = &models.User{Login: cmd.Login}
return nil
})
bus.AddHandler("test", func(cmd *models.GetUserOrgListQuery) error {
sc.getUserOrgListQuery = cmd
return nil
})
bus.AddHandler("test", func(cmd *models.CreateUserCommand) error {
sc.createUserCmd = cmd
sc.createUserCmd.Result = models.User{Login: cmd.Login}
return nil
})
bus.AddHandler("test", func(cmd *models.AddOrgUserCommand) error {
sc.addOrgUserCmd = cmd
return nil
})
bus.AddHandler("test", func(cmd *models.UpdateOrgUserCommand) error {
sc.updateOrgUserCmd = cmd
return nil
})
bus.AddHandler("test", func(cmd *models.RemoveOrgUserCommand) error {
sc.removeOrgUserCmd = cmd
return nil
})
bus.AddHandler("test", func(cmd *models.UpdateUserCommand) error {
sc.updateUserCmd = cmd
return nil
})
bus.AddHandler("test", func(cmd *models.SetUsingOrgCommand) error {
sc.setUsingOrgCmd = cmd
return nil
})
fn(sc)
})
}
type scenarioContext struct {
loginUserQuery *models.LoginUserQuery
getUserByAuthInfoQuery *models.GetUserByAuthInfoQuery
getUserOrgListQuery *models.GetUserOrgListQuery
createUserCmd *models.CreateUserCommand
addOrgUserCmd *models.AddOrgUserCommand
updateOrgUserCmd *models.UpdateOrgUserCommand
removeOrgUserCmd *models.RemoveOrgUserCommand
updateUserCmd *models.UpdateUserCommand
setUsingOrgCmd *models.SetUsingOrgCommand
updateUserPermissionsCmd *models.UpdateUserPermissionsCommand
}
func (sc *scenarioContext) userQueryReturns(user *models.User) {
bus.AddHandler("test", func(query *models.GetUserByAuthInfoQuery) error {
if user == nil {
return models.ErrUserNotFound
}
query.Result = user
return nil
})
bus.AddHandler("test", func(query *models.SetAuthInfoCommand) error {
return nil
})
}
func (sc *scenarioContext) userOrgsQueryReturns(orgs []*models.UserOrgDTO) {
bus.AddHandler("test", func(query *models.GetUserOrgListQuery) error {
query.Result = orgs
return nil
})
}
type scenarioFunc func(c *scenarioContext)

27
pkg/services/ldap/user.go Normal file
View File

@@ -0,0 +1,27 @@
package ldap
import (
"strings"
)
type UserInfo struct {
DN string
FirstName string
LastName string
Username string
Email string
MemberOf []string
}
func (u *UserInfo) isMemberOf(group string) bool {
if group == "*" {
return true
}
for _, member := range u.MemberOf {
if strings.EqualFold(member, group) {
return true
}
}
return false
}