mirror of https://github.com/grafana/grafana
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
parent
09032cd766
commit
0cbbb43222
@ -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") |
||||
} |
@ -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) |
||||
} |
Loading…
Reference in new issue