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.
This commit is contained in:
gotjosh 2019-09-03 18:34:44 +01:00 committed by GitHub
parent 09032cd766
commit 0cbbb43222
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 478 additions and 48 deletions

View File

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

View File

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

183
pkg/api/ldap_debug.go Normal file
View File

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

223
pkg/api/ldap_debug_test.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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