LDAP: Add API endpoint to debug user mapping from LDAP (#18833)

* Move the ReloadLDAPCfg function to the debug file

Appears to be a better suite place for this.

* LDAP: Return the server information when we find a specific user

We allow you to specify multiple LDAP servers as part of LDAP authentication integration. As part of searching for specific users, we need to understand from which server they come from. Returning the server configuration as part of the search will help us do two things:

- Understand in which server we found the user
- Have access the groups specified as part of the server configuration

* LDAP: Adds the /api/admin/ldap/:username endpoint

This endpoint returns a user found within the configured LDAP server(s). Moreso, it provides the mapping information for the user to help administrators understand how the users would be created within Grafana based on the current configuration.

No changes are executed or saved to the database, this is all an in-memory representation of how the final result would look like.
pull/18874/head
gotjosh 6 years ago committed by GitHub
parent 09032cd766
commit 0cbbb43222
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 17
      pkg/api/admin_ldap.go
  2. 1
      pkg/api/api.go
  3. 183
      pkg/api/ldap_debug.go
  4. 223
      pkg/api/ldap_debug_test.go
  5. 24
      pkg/api/login_test.go
  6. 6
      pkg/login/ldap_login_test.go
  7. 2
      pkg/middleware/auth_proxy/auth_proxy.go
  8. 3
      pkg/middleware/auth_proxy/auth_proxy_test.go
  9. 1
      pkg/models/org.go
  10. 3
      pkg/services/ldap/ldap.go
  11. 17
      pkg/services/multildap/multildap.go
  12. 13
      pkg/services/multildap/multildap_test.go
  13. 4
      pkg/services/multildap/testing.go
  14. 5
      pkg/services/sqlstore/org.go
  15. 24
      pkg/services/sqlstore/org_test.go

@ -1,17 +0,0 @@
package api
import (
"github.com/grafana/grafana/pkg/services/ldap"
)
func (server *HTTPServer) ReloadLDAPCfg() Response {
if !ldap.IsEnabled() {
return Error(400, "LDAP is not enabled", nil)
}
err := ldap.ReloadConfig()
if err != nil {
return Error(500, "Failed to reload ldap config.", err)
}
return Success("LDAP config reloaded")
}

@ -395,6 +395,7 @@ func (hs *HTTPServer) registerRoutes() {
adminRoute.Post("/provisioning/datasources/reload", Wrap(hs.AdminProvisioningReloadDatasources))
adminRoute.Post("/provisioning/notifications/reload", Wrap(hs.AdminProvisioningReloadNotifications))
adminRoute.Post("/ldap/reload", Wrap(hs.ReloadLDAPCfg))
adminRoute.Get("/ldap/:username", Wrap(hs.GetUserFromLDAP))
}, reqGrafanaAdmin)
// rendering

@ -0,0 +1,183 @@
package api
import (
"fmt"
"net/http"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/ldap"
"github.com/grafana/grafana/pkg/services/multildap"
"github.com/grafana/grafana/pkg/util"
)
var (
getLDAPConfig = multildap.GetConfig
newLDAP = multildap.New
errOrganizationNotFound = func(orgId int64) error {
return fmt.Errorf("Unable to find organization with ID '%d'", orgId)
}
)
// LDAPAttribute is a serializer for user attributes mapped from LDAP. Is meant to display both the serialized value and the LDAP key we received it from.
type LDAPAttribute struct {
ConfigAttributeValue string `json:"cfgAttrValue"`
LDAPAttributeValue string `json:"ldapValue"`
}
// RoleDTO is a serializer for mapped roles from LDAP
type RoleDTO struct {
OrgId int64 `json:"orgId"`
OrgName string `json:"orgName"`
OrgRole models.RoleType `json:"orgRole"`
GroupDN string `json:"groupDN"`
}
// TeamDTO is a serializer for mapped Teams from LDAP
type TeamDTO struct {
GroupDN string `json:"groupDN"`
TeamId int64 `json:"teamId"`
TeamName string `json:"teamName"`
}
// LDAPUserDTO is a serializer for users mapped from LDAP
type LDAPUserDTO struct {
Name *LDAPAttribute `json:"name"`
Surname *LDAPAttribute `json:"surname"`
Email *LDAPAttribute `json:"email"`
Username *LDAPAttribute `json:"login"`
IsGrafanaAdmin *bool `json:"isGrafanaAdmin"`
IsDisabled bool `json:"isDisabled"`
OrgRoles []RoleDTO `json:"roles"`
Teams []TeamDTO `json:"teams"`
}
// FetchOrgs fetches the organization(s) information by executing a single query to the database. Then, populating the DTO with the information retrieved.
func (user *LDAPUserDTO) FetchOrgs() error {
orgIds := []int64{}
for _, or := range user.OrgRoles {
orgIds = append(orgIds, or.OrgId)
}
q := &models.SearchOrgsQuery{}
q.Ids = orgIds
if err := bus.Dispatch(q); err != nil {
return err
}
orgNamesById := map[int64]string{}
for _, org := range q.Result {
orgNamesById[org.Id] = org.Name
}
for i, orgDTO := range user.OrgRoles {
orgName := orgNamesById[orgDTO.OrgId]
if orgName != "" {
user.OrgRoles[i].OrgName = orgName
} else {
return errOrganizationNotFound(orgDTO.OrgId)
}
}
return nil
}
// ReloadLDAPCfg reloads the LDAP configuration
func (server *HTTPServer) ReloadLDAPCfg() Response {
if !ldap.IsEnabled() {
return Error(400, "LDAP is not enabled", nil)
}
err := ldap.ReloadConfig()
if err != nil {
return Error(500, "Failed to reload ldap config.", err)
}
return Success("LDAP config reloaded")
}
// GetUserFromLDAP finds an user based on a username in LDAP. This helps illustrate how would the particular user be mapped in Grafana when synced.
func (server *HTTPServer) GetUserFromLDAP(c *models.ReqContext) Response {
ldapConfig, err := getLDAPConfig()
if err != nil {
return Error(400, "Failed to obtain the LDAP configuration. Please ", err)
}
ldap := newLDAP(ldapConfig.Servers)
username := c.Params(":username")
if len(username) == 0 {
return Error(http.StatusBadRequest, "Validation error. You must specify an username", nil)
}
user, serverConfig, err := ldap.User(username)
if user == nil {
return Error(http.StatusNotFound, "No user was found on the LDAP server(s)", err)
}
name, surname := splitName(user.Name)
u := &LDAPUserDTO{
Name: &LDAPAttribute{serverConfig.Attr.Name, name},
Surname: &LDAPAttribute{serverConfig.Attr.Surname, surname},
Email: &LDAPAttribute{serverConfig.Attr.Email, user.Email},
Username: &LDAPAttribute{serverConfig.Attr.Username, user.Login},
IsGrafanaAdmin: user.IsGrafanaAdmin,
IsDisabled: user.IsDisabled,
}
orgRoles := []RoleDTO{}
for _, g := range serverConfig.Groups {
role := &RoleDTO{}
if isMatchToLDAPGroup(user, g) {
role.OrgId = g.OrgID
role.OrgRole = user.OrgRoles[g.OrgID]
role.GroupDN = g.GroupDN
orgRoles = append(orgRoles, *role)
} else {
role.OrgId = g.OrgID
role.GroupDN = g.GroupDN
orgRoles = append(orgRoles, *role)
}
}
u.OrgRoles = orgRoles
err = u.FetchOrgs()
if err != nil {
return Error(http.StatusBadRequest, "Organization not found - Please verify your LDAP configuration", err)
}
return JSON(200, u)
}
// isMatchToLDAPGroup determines if we were able to match an LDAP group to an organization+role.
// Since we allow one role per organization. If it's set, we were able to match it.
func isMatchToLDAPGroup(user *models.ExternalUserInfo, groupConfig *ldap.GroupToOrgRole) bool {
return user.OrgRoles[groupConfig.OrgID] == groupConfig.OrgRole
}
// splitName receives the full name of a user and splits it into two parts: A name and a surname.
func splitName(name string) (string, string) {
names := util.SplitString(name)
switch len(names) {
case 0:
return "", ""
case 1:
return names[0], ""
default:
return names[0], names[1]
}
}

@ -0,0 +1,223 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/ldap"
"github.com/grafana/grafana/pkg/services/multildap"
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type LDAPMock struct {
Results []*models.ExternalUserInfo
}
var userSearchResult *models.ExternalUserInfo
var userSearchConfig ldap.ServerConfig
func (m *LDAPMock) Login(query *models.LoginUserQuery) (*models.ExternalUserInfo, error) {
return &models.ExternalUserInfo{}, nil
}
func (m *LDAPMock) Users(logins []string) ([]*models.ExternalUserInfo, error) {
s := []*models.ExternalUserInfo{}
return s, nil
}
func (m *LDAPMock) User(login string) (*models.ExternalUserInfo, ldap.ServerConfig, error) {
return userSearchResult, userSearchConfig, nil
}
func getUserFromLDAPContext(t *testing.T, requestURL string) *scenarioContext {
t.Helper()
sc := setupScenarioContext(requestURL)
hs := &HTTPServer{Cfg: setting.NewCfg()}
sc.defaultHandler = Wrap(func(c *models.ReqContext) Response {
sc.context = c
return hs.GetUserFromLDAP(c)
})
sc.m.Get("/api/admin/ldap/:username", sc.defaultHandler)
sc.resp = httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, requestURL, nil)
sc.req = req
sc.exec()
return sc
}
func TestGetUserFromLDAPApiEndpoint_UserNotFound(t *testing.T) {
getLDAPConfig = func() (*ldap.Config, error) {
return &ldap.Config{}, nil
}
newLDAP = func(_ []*ldap.ServerConfig) multildap.IMultiLDAP {
return &LDAPMock{}
}
userSearchResult = nil
sc := getUserFromLDAPContext(t, "/api/admin/ldap/user-that-does-not-exist")
require.Equal(t, sc.resp.Code, http.StatusNotFound)
responseString, err := getBody(sc.resp)
assert.Nil(t, err)
assert.Equal(t, "{\"message\":\"No user was found on the LDAP server(s)\"}", responseString)
}
func TestGetUserFromLDAPApiEndpoint_OrgNotfound(t *testing.T) {
isAdmin := true
userSearchResult = &models.ExternalUserInfo{
Name: "John Doe",
Email: "john.doe@example.com",
Login: "johndoe",
OrgRoles: map[int64]models.RoleType{1: models.ROLE_ADMIN, 2: models.ROLE_VIEWER},
IsGrafanaAdmin: &isAdmin,
}
userSearchConfig = ldap.ServerConfig{
Attr: ldap.AttributeMap{
Name: "ldap-name",
Surname: "ldap-surname",
Email: "ldap-email",
Username: "ldap-username",
},
Groups: []*ldap.GroupToOrgRole{
{
GroupDN: "cn=admins,ou=groups,dc=grafana,dc=org",
OrgID: 1,
OrgRole: models.ROLE_ADMIN,
},
{
GroupDN: "cn=admins,ou=groups,dc=grafana2,dc=org",
OrgID: 2,
OrgRole: models.ROLE_VIEWER,
},
},
}
mockOrgSearchResult := []*models.OrgDTO{
{Id: 1, Name: "Main Org."},
}
bus.AddHandler("test", func(query *models.SearchOrgsQuery) error {
query.Result = mockOrgSearchResult
return nil
})
getLDAPConfig = func() (*ldap.Config, error) {
return &ldap.Config{}, nil
}
newLDAP = func(_ []*ldap.ServerConfig) multildap.IMultiLDAP {
return &LDAPMock{}
}
sc := getUserFromLDAPContext(t, "/api/admin/ldap/johndoe")
require.Equal(t, sc.resp.Code, http.StatusBadRequest)
jsonResponse, err := getJSONbody(sc.resp)
assert.Nil(t, err)
expected := `
{
"error": "Unable to find organization with ID '2'",
"message": "Organization not found - Please verify your LDAP configuration"
}
`
var expectedJSON interface{}
_ = json.Unmarshal([]byte(expected), &expectedJSON)
assert.Equal(t, jsonResponse, expectedJSON)
}
func TestGetUserFromLDAPApiEndpoint(t *testing.T) {
isAdmin := true
userSearchResult = &models.ExternalUserInfo{
Name: "John Doe",
Email: "john.doe@example.com",
Login: "johndoe",
OrgRoles: map[int64]models.RoleType{1: models.ROLE_ADMIN},
IsGrafanaAdmin: &isAdmin,
}
userSearchConfig = ldap.ServerConfig{
Attr: ldap.AttributeMap{
Name: "ldap-name",
Surname: "ldap-surname",
Email: "ldap-email",
Username: "ldap-username",
},
Groups: []*ldap.GroupToOrgRole{
{
GroupDN: "cn=admins,ou=groups,dc=grafana,dc=org",
OrgID: 1,
OrgRole: models.ROLE_ADMIN,
},
},
}
mockOrgSearchResult := []*models.OrgDTO{
{Id: 1, Name: "Main Org."},
}
bus.AddHandler("test", func(query *models.SearchOrgsQuery) error {
query.Result = mockOrgSearchResult
return nil
})
getLDAPConfig = func() (*ldap.Config, error) {
return &ldap.Config{}, nil
}
newLDAP = func(_ []*ldap.ServerConfig) multildap.IMultiLDAP {
return &LDAPMock{}
}
sc := getUserFromLDAPContext(t, "/api/admin/ldap/johndoe")
require.Equal(t, sc.resp.Code, http.StatusOK)
jsonResponse, err := getJSONbody(sc.resp)
assert.Nil(t, err)
expected := `
{
"name": {
"cfgAttrValue": "ldap-name", "ldapValue": "John"
},
"surname": {
"cfgAttrValue": "ldap-surname", "ldapValue": "Doe"
},
"email": {
"cfgAttrValue": "ldap-email", "ldapValue": "john.doe@example.com"
},
"login": {
"cfgAttrValue": "ldap-username", "ldapValue": "johndoe"
},
"isGrafanaAdmin": true,
"isDisabled": false,
"roles": [
{ "orgId": 1, "orgRole": "Admin", "orgName": "Main Org.", "groupDN": "cn=admins,ou=groups,dc=grafana,dc=org" }
],
"teams": null
}
`
var expectedJSON interface{}
_ = json.Unmarshal([]byte(expected), &expectedJSON)
assert.Equal(t, jsonResponse, expectedJSON)
}

@ -2,17 +2,19 @@ package api
import (
"encoding/hex"
"encoding/json"
"errors"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
"github.com/stretchr/testify/assert"
"io/ioutil"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
"github.com/stretchr/testify/assert"
)
func mockSetIndexViewData() {
@ -50,6 +52,18 @@ func getBody(resp *httptest.ResponseRecorder) (string, error) {
return string(responseData), nil
}
func getJSONbody(resp *httptest.ResponseRecorder) (interface{}, error) {
var j interface{}
err := json.Unmarshal(resp.Body.Bytes(), &j)
if err != nil {
return nil, err
}
return j, nil
}
func TestLoginErrorCookieApiEndpoint(t *testing.T) {
mockSetIndexViewData()
defer resetSetIndexViewData()

@ -4,12 +4,11 @@ import (
"errors"
"testing"
. "github.com/smartystreets/goconvey/convey"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/ldap"
"github.com/grafana/grafana/pkg/services/multildap"
"github.com/grafana/grafana/pkg/setting"
. "github.com/smartystreets/goconvey/convey"
)
var errTest = errors.New("Test error")
@ -98,9 +97,10 @@ func (auth *mockAuth) Users(logins []string) (
func (auth *mockAuth) User(login string) (
*models.ExternalUserInfo,
ldap.ServerConfig,
error,
) {
return nil, nil
return nil, ldap.ServerConfig{}, nil
}
func (auth *mockAuth) Add(dn string, values map[string][]string) error {

@ -218,7 +218,7 @@ func (auth *AuthProxy) LoginViaLDAP() (int64, *Error) {
return 0, newError("Failed to get LDAP config", nil)
}
extUser, err := newLDAP(config.Servers).User(auth.header)
extUser, _, err := newLDAP(config.Servers).User(auth.header)
if err != nil {
return 0, newError(err.Error(), nil)
}

@ -36,13 +36,14 @@ func (stub *TestMultiLDAP) Login(query *models.LoginUserQuery) (
func (stub *TestMultiLDAP) User(login string) (
*models.ExternalUserInfo,
ldap.ServerConfig,
error,
) {
stub.userCalled = true
result := &models.ExternalUserInfo{
UserId: stub.ID,
}
return result, nil
return result, ldap.ServerConfig{}, nil
}
func prepareMiddleware(t *testing.T, req *http.Request, store *remotecache.RemoteCache) *AuthProxy {

@ -67,6 +67,7 @@ type SearchOrgsQuery struct {
Name string
Limit int
Page int
Ids []int64
Result []*OrgDTO
}

@ -10,10 +10,9 @@ import (
"strings"
"github.com/davecgh/go-spew/spew"
"gopkg.in/ldap.v3"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"gopkg.in/ldap.v3"
)
// IConnection is interface for LDAP connection manipulation

@ -39,7 +39,7 @@ type IMultiLDAP interface {
)
User(login string) (
*models.ExternalUserInfo, error,
*models.ExternalUserInfo, ldap.ServerConfig, error,
)
}
@ -92,14 +92,15 @@ func (multiples *MultiLDAP) Login(query *models.LoginUserQuery) (
return nil, ErrInvalidCredentials
}
// User gets a user by login
// User attempts to find an user by login/username by searching into all of the configured LDAP servers. Then, if the user is found it returns the user alongisde the server it was found.
func (multiples *MultiLDAP) User(login string) (
*models.ExternalUserInfo,
ldap.ServerConfig,
error,
) {
if len(multiples.configs) == 0 {
return nil, ErrNoLDAPServers
return nil, ldap.ServerConfig{}, ErrNoLDAPServers
}
search := []string{login}
@ -107,26 +108,26 @@ func (multiples *MultiLDAP) User(login string) (
server := newLDAP(config)
if err := server.Dial(); err != nil {
return nil, err
return nil, *config, err
}
defer server.Close()
if err := server.Bind(); err != nil {
return nil, err
return nil, *config, err
}
users, err := server.Users(search)
if err != nil {
return nil, err
return nil, *config, err
}
if len(users) != 0 {
return users[0], nil
return users[0], *config, nil
}
}
return nil, ErrDidNotFindUser
return nil, ldap.ServerConfig{}, ErrDidNotFindUser
}
// Users gets users from multiple LDAP servers

@ -4,10 +4,9 @@ import (
"errors"
"testing"
. "github.com/smartystreets/goconvey/convey"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/ldap"
. "github.com/smartystreets/goconvey/convey"
)
func TestMultiLDAP(t *testing.T) {
@ -127,7 +126,7 @@ func TestMultiLDAP(t *testing.T) {
setup()
multi := New([]*ldap.ServerConfig{})
_, err := multi.User("test")
_, _, err := multi.User("test")
So(err, ShouldBeError)
So(err, ShouldEqual, ErrNoLDAPServers)
@ -145,7 +144,7 @@ func TestMultiLDAP(t *testing.T) {
{}, {},
})
_, err := multi.User("test")
_, _, err := multi.User("test")
So(err, ShouldBeError)
So(err, ShouldEqual, expected)
@ -159,7 +158,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
})
_, err := multi.User("test")
_, _, err := multi.User("test")
So(mock.dialCalledTimes, ShouldEqual, 2)
So(mock.usersCalledTimes, ShouldEqual, 2)
@ -179,7 +178,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
})
_, err := multi.User("test")
_, _, err := multi.User("test")
So(mock.dialCalledTimes, ShouldEqual, 1)
So(mock.usersCalledTimes, ShouldEqual, 1)
@ -206,7 +205,7 @@ func TestMultiLDAP(t *testing.T) {
multi := New([]*ldap.ServerConfig{
{}, {},
})
user, err := multi.User("test")
user, _, err := multi.User("test")
So(mock.dialCalledTimes, ShouldEqual, 1)
So(mock.usersCalledTimes, ShouldEqual, 1)

@ -91,10 +91,10 @@ func (mock *MockMultiLDAP) Users(logins []string) (
// User test fn
func (mock *MockMultiLDAP) User(login string) (
*models.ExternalUserInfo, error,
*models.ExternalUserInfo, ldap.ServerConfig, error,
) {
mock.UserCalledTimes = mock.UserCalledTimes + 1
return nil, nil
return nil, ldap.ServerConfig{}, nil
}
func setup() *MockLDAP {

@ -27,6 +27,11 @@ func SearchOrgs(query *m.SearchOrgsQuery) error {
if query.Name != "" {
sess.Where("name=?", query.Name)
}
if len(query.Ids) > 0 {
sess.In("id", query.Ids)
}
sess.Limit(query.Limit, query.Limit*query.Page)
sess.Cols("id", "name")
err := sess.Find(&query.Result)

@ -2,19 +2,39 @@ package sqlstore
import (
"context"
"fmt"
"testing"
"time"
. "github.com/smartystreets/goconvey/convey"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
. "github.com/smartystreets/goconvey/convey"
)
func TestAccountDataAccess(t *testing.T) {
Convey("Testing Account DB Access", t, func() {
InitTestDB(t)
Convey("Given we have organizations, we can query them by IDs", func() {
var err error
var cmd *m.CreateOrgCommand
ids := []int64{}
for i := 1; i < 4; i++ {
cmd = &m.CreateOrgCommand{Name: fmt.Sprint("Org #", i)}
err = CreateOrg(cmd)
So(err, ShouldBeNil)
ids = append(ids, cmd.Result.Id)
}
query := &m.SearchOrgsQuery{Ids: ids}
err = SearchOrgs(query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 3)
})
Convey("Given single org mode", func() {
setting.AutoAssignOrg = true
setting.AutoAssignOrgId = 1

Loading…
Cancel
Save