LDAP: Move to single package cluster (#63035)

* move multildap to ldap package

* move LDAP api and tests to ldap package

* register background service

* lint
This commit is contained in:
Jo
2023-02-08 09:32:59 +01:00
committed by GitHub
parent dea108a8b9
commit 6322fce725
16 changed files with 395 additions and 264 deletions

View File

@@ -5,8 +5,8 @@ import (
"errors"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/ldap/multildap"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/multildap"
"github.com/grafana/grafana/pkg/setting"
)

View File

@@ -8,8 +8,8 @@ import (
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/ldap"
"github.com/grafana/grafana/pkg/services/ldap/multildap"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/multildap"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/setting"
)

View File

@@ -18,8 +18,8 @@ import (
"github.com/grafana/grafana/pkg/infra/remotecache"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/ldap"
"github.com/grafana/grafana/pkg/services/ldap/multildap"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/multildap"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"

View File

@@ -13,8 +13,8 @@ import (
"github.com/grafana/grafana/pkg/infra/remotecache"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/ldap"
"github.com/grafana/grafana/pkg/services/ldap/multildap"
"github.com/grafana/grafana/pkg/services/login/loginservice"
"github.com/grafana/grafana/pkg/services/multildap"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web"

View File

@@ -0,0 +1,437 @@
package api
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/middleware"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/auth"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/ldap"
"github.com/grafana/grafana/pkg/services/ldap/multildap"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
)
type Service struct {
cfg *setting.Cfg
userService user.Service
authInfoService login.AuthInfoService
ldapGroupsService ldap.Groups
loginService login.Service
orgService org.Service
sessionService auth.UserTokenService
log log.Logger
}
func ProvideService(cfg *setting.Cfg, router routing.RouteRegister, accessControl ac.AccessControl,
userService user.Service, authInfoService login.AuthInfoService, ldapGroupsService ldap.Groups,
loginService login.Service, orgService org.Service, sessionService auth.UserTokenService) *Service {
s := &Service{
cfg: cfg,
userService: userService,
authInfoService: authInfoService,
ldapGroupsService: ldapGroupsService,
loginService: loginService,
orgService: orgService,
sessionService: sessionService,
log: log.New("ldap.api"),
}
authorize := ac.Middleware(accessControl)
reqGrafanaAdmin := middleware.ReqGrafanaAdmin
router.Group("/api/admin", func(adminRoute routing.RouteRegister) {
adminRoute.Post("/ldap/reload", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPConfigReload)), routing.Wrap(s.ReloadLDAPCfg))
adminRoute.Post("/ldap/sync/:id", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPUsersSync)), routing.Wrap(s.PostSyncUserWithLDAP))
adminRoute.Get("/ldap/:username", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPUsersRead)), routing.Wrap(s.GetUserFromLDAP))
adminRoute.Get("/ldap/status", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPStatusRead)), routing.Wrap(s.GetLDAPStatus))
}, middleware.ReqSignedIn)
return s
}
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 LDAPRoleDTO struct {
OrgId int64 `json:"orgId"`
OrgName string `json:"orgName"`
OrgRole org.RoleType `json:"orgRole"`
GroupDN string `json:"groupDN"`
}
// 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 []LDAPRoleDTO `json:"roles"`
Teams []ldap.TeamOrgGroupDTO `json:"teams"`
}
// LDAPServerDTO is a serializer for LDAP server statuses
type LDAPServerDTO struct {
Host string `json:"host"`
Port int `json:"port"`
Available bool `json:"available"`
Error string `json:"error"`
}
// 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(ctx context.Context, orga org.Service) error {
orgIds := []int64{}
for _, or := range user.OrgRoles {
orgIds = append(orgIds, or.OrgId)
}
q := &org.SearchOrgsQuery{}
q.IDs = orgIds
result, err := orga.Search(ctx, q)
if err != nil {
return err
}
orgNamesById := map[int64]string{}
for _, org := range result {
orgNamesById[org.ID] = org.Name
}
for i, orgDTO := range user.OrgRoles {
if orgDTO.OrgId < 1 {
continue
}
orgName := orgNamesById[orgDTO.OrgId]
if orgName != "" {
user.OrgRoles[i].OrgName = orgName
} else {
return errOrganizationNotFound(orgDTO.OrgId)
}
}
return nil
}
// swagger:route POST /admin/ldap/reload admin_ldap reloadLDAPCfg
//
// Reloads the LDAP configuration.
//
// If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `ldap.config:reload`.
//
// Security:
// - basic:
//
// Responses:
// 200: okResponse
// 401: unauthorisedError
// 403: forbiddenError
// 500: internalServerError
func (s *Service) ReloadLDAPCfg(c *contextmodel.ReqContext) response.Response {
if !ldap.IsEnabled() {
return response.Error(http.StatusBadRequest, "LDAP is not enabled", nil)
}
err := ldap.ReloadConfig()
if err != nil {
return response.Error(http.StatusInternalServerError, "Failed to reload LDAP config", err)
}
return response.Success("LDAP config reloaded")
}
// swagger:route GET /admin/ldap/status admin_ldap getLDAPStatus
//
// Attempts to connect to all the configured LDAP servers and returns information on whenever they're available or not.
//
// If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `ldap.status:read`.
//
// Security:
// - basic:
//
// Responses:
// 200: okResponse
// 401: unauthorisedError
// 403: forbiddenError
// 500: internalServerError
func (s *Service) GetLDAPStatus(c *contextmodel.ReqContext) response.Response {
if !ldap.IsEnabled() {
return response.Error(http.StatusBadRequest, "LDAP is not enabled", nil)
}
ldapConfig, err := getLDAPConfig(s.cfg)
if err != nil {
return response.Error(http.StatusBadRequest, "Failed to obtain the LDAP configuration. Please verify the configuration and try again", err)
}
ldap := newLDAP(ldapConfig.Servers)
if ldap == nil {
return response.Error(http.StatusInternalServerError, "Failed to find the LDAP server", nil)
}
statuses, err := ldap.Ping()
if err != nil {
return response.Error(http.StatusBadRequest, "Failed to connect to the LDAP server(s)", err)
}
serverDTOs := []*LDAPServerDTO{}
for _, status := range statuses {
s := &LDAPServerDTO{
Host: status.Host,
Available: status.Available,
Port: status.Port,
}
if status.Error != nil {
s.Error = status.Error.Error()
}
serverDTOs = append(serverDTOs, s)
}
return response.JSON(http.StatusOK, serverDTOs)
}
// swagger:route POST /admin/ldap/sync/{user_id} admin_ldap postSyncUserWithLDAP
//
// Enables a single Grafana user to be synchronized against LDAP.
//
// If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `ldap.user:sync`.
//
// Security:
// - basic:
//
// Responses:
// 200: okResponse
// 401: unauthorisedError
// 403: forbiddenError
// 500: internalServerError
func (s *Service) PostSyncUserWithLDAP(c *contextmodel.ReqContext) response.Response {
if !ldap.IsEnabled() {
return response.Error(http.StatusBadRequest, "LDAP is not enabled", nil)
}
ldapConfig, err := getLDAPConfig(s.cfg)
if err != nil {
return response.Error(http.StatusBadRequest, "Failed to obtain the LDAP configuration. Please verify the configuration and try again", err)
}
userId, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64)
if err != nil {
return response.Error(http.StatusBadRequest, "id is invalid", err)
}
query := user.GetUserByIDQuery{ID: userId}
usr, err := s.userService.GetByID(c.Req.Context(), &query)
if err != nil { // validate the userId exists
if errors.Is(err, user.ErrUserNotFound) {
return response.Error(404, user.ErrUserNotFound.Error(), nil)
}
return response.Error(500, "Failed to get user", err)
}
authModuleQuery := &login.GetAuthInfoQuery{UserId: usr.ID, AuthModule: login.LDAPAuthModule}
if err := s.authInfoService.GetAuthInfo(c.Req.Context(), authModuleQuery); err != nil { // validate the userId comes from LDAP
if errors.Is(err, user.ErrUserNotFound) {
return response.Error(404, user.ErrUserNotFound.Error(), nil)
}
return response.Error(500, "Failed to get user", err)
}
ldapServer := newLDAP(ldapConfig.Servers)
userInfo, _, err := ldapServer.User(usr.Login)
if err != nil {
if errors.Is(err, multildap.ErrDidNotFindUser) { // User was not in the LDAP server - we need to take action:
if s.cfg.AdminUser == usr.Login { // User is *the* Grafana Admin. We cannot disable it.
errMsg := fmt.Sprintf(`Refusing to sync grafana super admin "%s" - it would be disabled`, usr.Login)
s.log.Error(errMsg)
return response.Error(http.StatusBadRequest, errMsg, err)
}
// Since the user was not in the LDAP server. Let's disable it.
err := s.loginService.DisableExternalUser(c.Req.Context(), usr.Login)
if err != nil {
return response.Error(http.StatusInternalServerError, "Failed to disable the user", err)
}
err = s.sessionService.RevokeAllUserTokens(c.Req.Context(), userId)
if err != nil {
return response.Error(http.StatusInternalServerError, "Failed to remove session tokens for the user", err)
}
return response.Error(http.StatusBadRequest, "User not found in LDAP. Disabled the user without updating information", nil) // should this be a success?
}
s.log.Debug("Failed to sync the user with LDAP", "err", err)
return response.Error(http.StatusBadRequest, "Something went wrong while finding the user in LDAP", err)
}
upsertCmd := &login.UpsertUserCommand{
ReqContext: c,
ExternalUser: userInfo,
SignupAllowed: s.cfg.LDAPAllowSignup,
UserLookupParams: login.UserLookupParams{
UserID: &usr.ID, // Upsert by ID only
Email: nil,
Login: nil,
},
}
err = s.loginService.UpsertUser(c.Req.Context(), upsertCmd)
if err != nil {
return response.Error(http.StatusInternalServerError, "Failed to update the user", err)
}
return response.Success("User synced successfully")
}
// swagger:route GET /admin/ldap/{user_name} admin_ldap getUserFromLDAP
//
// Finds an user based on a username in LDAP. This helps illustrate how would the particular user be mapped in Grafana when synced.
//
// If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `ldap.user:read`.
//
// Security:
// - basic:
//
// Responses:
// 200: okResponse
// 401: unauthorisedError
// 403: forbiddenError
// 500: internalServerError
func (s *Service) GetUserFromLDAP(c *contextmodel.ReqContext) response.Response {
if !ldap.IsEnabled() {
return response.Error(http.StatusBadRequest, "LDAP is not enabled", nil)
}
ldapConfig, err := getLDAPConfig(s.cfg)
if err != nil {
return response.Error(http.StatusBadRequest, "Failed to obtain the LDAP configuration", err)
}
multiLDAP := newLDAP(ldapConfig.Servers)
username := web.Params(c.Req)[":username"]
if len(username) == 0 {
return response.Error(http.StatusBadRequest, "Validation error. You must specify an username", nil)
}
user, serverConfig, err := multiLDAP.User(username)
if user == nil || err != nil {
return response.Error(http.StatusNotFound, "No user was found in the LDAP server(s) with that username", err)
}
s.log.Debug("user found", "user", user)
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,
}
unmappedUserGroups := map[string]struct{}{}
for _, userGroup := range user.Groups {
unmappedUserGroups[strings.ToLower(userGroup)] = struct{}{}
}
orgIDs := []int64{} // IDs of the orgs the user is a member of
orgRolesMap := map[int64]org.RoleType{}
for _, group := range serverConfig.Groups {
// only use the first match for each org
if orgRolesMap[group.OrgId] != "" {
continue
}
if ldap.IsMemberOf(user.Groups, group.GroupDN) {
orgRolesMap[group.OrgId] = group.OrgRole
u.OrgRoles = append(u.OrgRoles, LDAPRoleDTO{GroupDN: group.GroupDN,
OrgId: group.OrgId, OrgRole: group.OrgRole})
delete(unmappedUserGroups, strings.ToLower(group.GroupDN))
orgIDs = append(orgIDs, group.OrgId)
}
}
for userGroup := range unmappedUserGroups {
u.OrgRoles = append(u.OrgRoles, LDAPRoleDTO{GroupDN: userGroup})
}
s.log.Debug("mapping org roles", "orgsRoles", u.OrgRoles)
if err := u.FetchOrgs(c.Req.Context(), s.orgService); err != nil {
return response.Error(http.StatusBadRequest, "An organization was not found - Please verify your LDAP configuration", err)
}
u.Teams, err = s.ldapGroupsService.GetTeams(user.Groups, orgIDs)
if err != nil {
return response.Error(http.StatusBadRequest, "Unable to find the teams for this user", err)
}
return response.JSON(http.StatusOK, u)
}
// 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]
}
}
// swagger:parameters getUserFromLDAP
type GetLDAPUserParams struct {
// in:path
// required:true
UserName string `json:"user_name"`
}
// swagger:parameters postSyncUserWithLDAP
type SyncLDAPUserParams struct {
// in:path
// required:true
UserID int64 `json:"user_id"`
}

View File

@@ -0,0 +1,736 @@
package api
import (
"encoding/json"
"errors"
"io"
"net/http"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
"github.com/grafana/grafana/pkg/services/auth/authtest"
"github.com/grafana/grafana/pkg/services/ldap"
"github.com/grafana/grafana/pkg/services/ldap/multildap"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/login/logintest"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/org/orgtest"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/usertest"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web/webtest"
)
type LDAPMock struct {
Results []*login.ExternalUserInfo
UserSearchResult *login.ExternalUserInfo
UserSearchConfig ldap.ServerConfig
UserSearchError error
}
var pingResult []*multildap.ServerStatus
var pingError error
func (m *LDAPMock) Ping() ([]*multildap.ServerStatus, error) {
return pingResult, pingError
}
func (m *LDAPMock) Login(query *login.LoginUserQuery) (*login.ExternalUserInfo, error) {
return &login.ExternalUserInfo{}, nil
}
func (m *LDAPMock) Users(logins []string) ([]*login.ExternalUserInfo, error) {
s := []*login.ExternalUserInfo{}
return s, nil
}
func (m *LDAPMock) User(login string) (*login.ExternalUserInfo, ldap.ServerConfig, error) {
return m.UserSearchResult, m.UserSearchConfig, m.UserSearchError
}
func setupAPITest(t *testing.T, opts ...func(a *Service)) (*Service, *webtest.Server) {
t.Helper()
router := routing.NewRouteRegister()
cfg := setting.NewCfg()
a := ProvideService(cfg,
router,
acimpl.ProvideAccessControl(cfg),
usertest.NewUserServiceFake(),
&logintest.AuthInfoServiceFake{},
ldap.ProvideGroupsService(),
&logintest.LoginServiceFake{},
&orgtest.FakeOrgService{},
authtest.NewFakeUserAuthTokenService(),
)
for _, o := range opts {
o(a)
}
server := webtest.NewServer(t, router)
return a, server
}
func TestGetUserFromLDAPAPIEndpoint_UserNotFound(t *testing.T) {
setting.LDAPEnabled = true
defer func() { setting.LDAPEnabled = false }()
getLDAPConfig = func(*setting.Cfg) (*ldap.Config, error) {
return &ldap.Config{}, nil
}
newLDAP = func(_ []*ldap.ServerConfig) multildap.IMultiLDAP {
return &LDAPMock{
UserSearchResult: nil,
}
}
_, server := setupAPITest(t, func(a *Service) {
a.orgService = &orgtest.FakeOrgService{
ExpectedOrgs: []*org.OrgDTO{},
}
})
req := server.NewGetRequest("/api/admin/ldap/user-that-does-not-exist")
webtest.RequestWithSignedInUser(req, &user.SignedInUser{
OrgID: 1,
Permissions: map[int64]map[string][]string{
1: {"ldap.user:read": {"*"}}},
})
res, err := server.Send(req)
defer func() { require.NoError(t, res.Body.Close()) }()
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, res.StatusCode)
bodyBytes, _ := io.ReadAll(res.Body)
assert.JSONEq(t, "{\"message\":\"No user was found in the LDAP server(s) with that username\"}", string(bodyBytes))
}
func TestGetUserFromLDAPAPIEndpoint_OrgNotfound(t *testing.T) {
setting.LDAPEnabled = true
defer func() { setting.LDAPEnabled = false }()
isAdmin := true
userSearchResult := &login.ExternalUserInfo{
Name: "John Doe",
Email: "john.doe@example.com",
Login: "johndoe",
Groups: []string{"cn=admins,ou=groups,dc=grafana,dc=org"},
OrgRoles: map[int64]org.RoleType{1: org.RoleAdmin, 2: org.RoleViewer},
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: org.RoleAdmin,
},
{
GroupDN: "cn=admins,ou=groups,dc=grafana,dc=org",
OrgId: 2,
OrgRole: org.RoleViewer,
},
},
}
newLDAP = func(_ []*ldap.ServerConfig) multildap.IMultiLDAP {
return &LDAPMock{
UserSearchResult: userSearchResult,
UserSearchConfig: userSearchConfig,
}
}
mockOrgSearchResult := []*org.OrgDTO{
{ID: 1, Name: "Main Org."},
}
_, server := setupAPITest(t, func(a *Service) {
a.orgService = &orgtest.FakeOrgService{
ExpectedOrgs: mockOrgSearchResult,
}
})
getLDAPConfig = func(*setting.Cfg) (*ldap.Config, error) {
return &ldap.Config{}, nil
}
req := server.NewGetRequest("/api/admin/ldap/johndoe")
webtest.RequestWithSignedInUser(req, &user.SignedInUser{
OrgID: 1,
Permissions: map[int64]map[string][]string{
1: {"ldap.user:read": {"*"}}},
})
res, err := server.Send(req)
defer func() { require.NoError(t, res.Body.Close()) }()
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
bodyBytes, _ := io.ReadAll(res.Body)
var resMap map[string]interface{}
err = json.Unmarshal(bodyBytes, &resMap)
assert.NoError(t, err)
assert.Equal(t, "unable to find organization with ID '2'", resMap["error"])
assert.Equal(t, "An organization was not found - Please verify your LDAP configuration", resMap["message"])
}
func TestGetUserFromLDAPAPIEndpoint(t *testing.T) {
setting.LDAPEnabled = true
defer func() { setting.LDAPEnabled = false }()
isAdmin := true
userSearchResult := &login.ExternalUserInfo{
Name: "John Doe",
Email: "john.doe@example.com",
Login: "johndoe",
Groups: []string{"cn=admins,ou=groups,dc=grafana,dc=org", "another-group-not-matched"},
OrgRoles: map[int64]org.RoleType{1: org.RoleAdmin},
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: org.RoleAdmin,
},
{
GroupDN: "cn=admins2,ou=groups,dc=grafana,dc=org",
OrgId: 1,
OrgRole: org.RoleAdmin,
},
},
}
mockOrgSearchResult := []*org.OrgDTO{
{ID: 1, Name: "Main Org."},
}
getLDAPConfig = func(*setting.Cfg) (*ldap.Config, error) {
return &ldap.Config{}, nil
}
newLDAP = func(_ []*ldap.ServerConfig) multildap.IMultiLDAP {
return &LDAPMock{
UserSearchResult: userSearchResult,
UserSearchConfig: userSearchConfig,
}
}
_, server := setupAPITest(t, func(a *Service) {
a.orgService = &orgtest.FakeOrgService{
ExpectedOrgs: mockOrgSearchResult,
}
})
req := server.NewGetRequest("/api/admin/ldap/johndoe")
webtest.RequestWithSignedInUser(req, &user.SignedInUser{
OrgID: 1,
Permissions: map[int64]map[string][]string{
1: {"ldap.user:read": {"*"}}},
})
res, err := server.Send(req)
defer func() { require.NoError(t, res.Body.Close()) }()
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
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" },
{ "orgId": 0, "orgRole": "", "orgName": "", "groupDN": "another-group-not-matched" }
],
"teams": null
}
`
bodyBytes, _ := io.ReadAll(res.Body)
assert.JSONEq(t, expected, string(bodyBytes))
}
func TestGetUserFromLDAPAPIEndpoint_WithTeamHandler(t *testing.T) {
setting.LDAPEnabled = true
defer func() { setting.LDAPEnabled = false }()
isAdmin := true
userSearchResult := &login.ExternalUserInfo{
Name: "John Doe",
Email: "john.doe@example.com",
Login: "johndoe",
Groups: []string{"cn=admins,ou=groups,dc=grafana,dc=org"},
OrgRoles: map[int64]org.RoleType{1: org.RoleAdmin},
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: org.RoleAdmin,
},
},
}
mockOrgSearchResult := []*org.OrgDTO{
{ID: 1, Name: "Main Org."},
}
getLDAPConfig = func(*setting.Cfg) (*ldap.Config, error) {
return &ldap.Config{}, nil
}
newLDAP = func(_ []*ldap.ServerConfig) multildap.IMultiLDAP {
return &LDAPMock{
UserSearchResult: userSearchResult,
UserSearchConfig: userSearchConfig,
}
}
_, server := setupAPITest(t, func(a *Service) {
a.orgService = &orgtest.FakeOrgService{
ExpectedOrgs: mockOrgSearchResult,
}
})
req := server.NewGetRequest("/api/admin/ldap/johndoe")
webtest.RequestWithSignedInUser(req, &user.SignedInUser{
OrgID: 1,
Permissions: map[int64]map[string][]string{
1: {"ldap.user:read": {"*"}}},
})
res, err := server.Send(req)
defer func() { require.NoError(t, res.Body.Close()) }()
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
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
}
`
bodyBytes, _ := io.ReadAll(res.Body)
assert.JSONEq(t, expected, string(bodyBytes))
}
func TestGetLDAPStatusAPIEndpoint(t *testing.T) {
setting.LDAPEnabled = true
defer func() { setting.LDAPEnabled = false }()
pingResult = []*multildap.ServerStatus{
{Host: "10.0.0.3", Port: 361, Available: true, Error: nil},
{Host: "10.0.0.3", Port: 362, Available: true, Error: nil},
{Host: "10.0.0.5", Port: 361, Available: false, Error: errors.New("something is awfully wrong")},
}
getLDAPConfig = func(*setting.Cfg) (*ldap.Config, error) {
return &ldap.Config{}, nil
}
newLDAP = func(_ []*ldap.ServerConfig) multildap.IMultiLDAP {
return &LDAPMock{}
}
_, server := setupAPITest(t, func(a *Service) {
})
req := server.NewGetRequest("/api/admin/ldap/status")
webtest.RequestWithSignedInUser(req, &user.SignedInUser{
OrgID: 1,
Permissions: map[int64]map[string][]string{
1: {"ldap.status:read": {}}},
})
res, err := server.Send(req)
defer func() { require.NoError(t, res.Body.Close()) }()
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
expected := `
[
{ "host": "10.0.0.3", "port": 361, "available": true, "error": "" },
{ "host": "10.0.0.3", "port": 362, "available": true, "error": "" },
{ "host": "10.0.0.5", "port": 361, "available": false, "error": "something is awfully wrong" }
]
`
bodyBytes, _ := io.ReadAll(res.Body)
assert.JSONEq(t, expected, string(bodyBytes))
}
func TestPostSyncUserWithLDAPAPIEndpoint_Success(t *testing.T) {
setting.LDAPEnabled = true
defer func() { setting.LDAPEnabled = false }()
userServiceMock := usertest.NewUserServiceFake()
userServiceMock.ExpectedUser = &user.User{Login: "ldap-daniel", ID: 34}
getLDAPConfig = func(*setting.Cfg) (*ldap.Config, error) {
return &ldap.Config{}, nil
}
newLDAP = func(_ []*ldap.ServerConfig) multildap.IMultiLDAP {
return &LDAPMock{UserSearchResult: &login.ExternalUserInfo{
Login: "ldap-daniel",
}}
}
_, server := setupAPITest(t, func(a *Service) {
a.userService = userServiceMock
})
req := server.NewPostRequest("/api/admin/ldap/sync/34", nil)
webtest.RequestWithSignedInUser(req, &user.SignedInUser{
OrgID: 1,
Permissions: map[int64]map[string][]string{
1: {"ldap.user:sync": {}}},
})
res, err := server.Send(req)
defer func() { require.NoError(t, res.Body.Close()) }()
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
expected := `
{
"message": "User synced successfully"
}
`
bodyBytes, _ := io.ReadAll(res.Body)
assert.JSONEq(t, expected, string(bodyBytes))
}
func TestPostSyncUserWithLDAPAPIEndpoint_WhenUserNotFound(t *testing.T) {
setting.LDAPEnabled = true
defer func() { setting.LDAPEnabled = false }()
userServiceMock := usertest.NewUserServiceFake()
userServiceMock.ExpectedError = user.ErrUserNotFound
getLDAPConfig = func(*setting.Cfg) (*ldap.Config, error) {
return &ldap.Config{}, nil
}
newLDAP = func(_ []*ldap.ServerConfig) multildap.IMultiLDAP {
return &LDAPMock{}
}
_, server := setupAPITest(t, func(a *Service) {
a.userService = userServiceMock
})
req := server.NewPostRequest("/api/admin/ldap/sync/34", nil)
webtest.RequestWithSignedInUser(req, &user.SignedInUser{
OrgID: 1,
Permissions: map[int64]map[string][]string{
1: {"ldap.user:sync": {}}},
})
res, err := server.Send(req)
defer func() { require.NoError(t, res.Body.Close()) }()
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, res.StatusCode)
expected := `
{
"message": "user not found"
}
`
bodyBytes, _ := io.ReadAll(res.Body)
assert.JSONEq(t, expected, string(bodyBytes))
}
func TestPostSyncUserWithLDAPAPIEndpoint_WhenGrafanaAdmin(t *testing.T) {
setting.LDAPEnabled = true
defer func() { setting.LDAPEnabled = false }()
userServiceMock := usertest.NewUserServiceFake()
userServiceMock.ExpectedUser = &user.User{Login: "ldap-daniel", ID: 34}
getLDAPConfig = func(*setting.Cfg) (*ldap.Config, error) {
return &ldap.Config{}, nil
}
newLDAP = func(_ []*ldap.ServerConfig) multildap.IMultiLDAP {
return &LDAPMock{UserSearchError: multildap.ErrDidNotFindUser}
}
_, server := setupAPITest(t, func(a *Service) {
a.userService = userServiceMock
a.cfg.AdminUser = "ldap-daniel"
})
req := server.NewPostRequest("/api/admin/ldap/sync/34", nil)
webtest.RequestWithSignedInUser(req, &user.SignedInUser{
OrgID: 1,
Permissions: map[int64]map[string][]string{
1: {"ldap.user:sync": {}}},
})
res, err := server.Send(req)
defer func() { require.NoError(t, res.Body.Close()) }()
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
bodyBytes, _ := io.ReadAll(res.Body)
var resMap map[string]interface{}
err = json.Unmarshal(bodyBytes, &resMap)
assert.NoError(t, err)
assert.Equal(t, "did not find a user", resMap["error"])
assert.Equal(t, "Refusing to sync grafana super admin \"ldap-daniel\" - it would be disabled", resMap["message"])
}
func TestPostSyncUserWithLDAPAPIEndpoint_WhenUserNotInLDAP(t *testing.T) {
setting.LDAPEnabled = true
defer func() { setting.LDAPEnabled = false }()
userServiceMock := usertest.NewUserServiceFake()
userServiceMock.ExpectedUser = &user.User{Login: "ldap-daniel", ID: 34}
getLDAPConfig = func(*setting.Cfg) (*ldap.Config, error) {
return &ldap.Config{}, nil
}
newLDAP = func(_ []*ldap.ServerConfig) multildap.IMultiLDAP {
return &LDAPMock{UserSearchError: multildap.ErrDidNotFindUser}
}
_, server := setupAPITest(t, func(a *Service) {
a.userService = userServiceMock
a.authInfoService = &logintest.AuthInfoServiceFake{ExpectedExternalUser: &login.ExternalUserInfo{IsDisabled: true, UserId: 34}}
})
req := server.NewPostRequest("/api/admin/ldap/sync/34", nil)
webtest.RequestWithSignedInUser(req, &user.SignedInUser{
OrgID: 1,
Permissions: map[int64]map[string][]string{
1: {"ldap.user:sync": {}}},
})
res, err := server.Send(req)
defer func() { require.NoError(t, res.Body.Close()) }()
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
expected := `
{
"message": "User not found in LDAP. Disabled the user without updating information"
}
`
bodyBytes, _ := io.ReadAll(res.Body)
assert.JSONEq(t, expected, string(bodyBytes))
}
func TestLDAP_AccessControl(t *testing.T) {
setting.LDAPEnabled = true
f, errC := os.CreateTemp("", "ldap.toml")
require.NoError(t, errC)
_, errF := f.WriteString(
`[[servers]]
host = "127.0.0.1"
port = 389
search_filter = "(cn=%s)"
search_base_dns = ["dc=grafana,dc=org"]`)
require.NoError(t, errF)
setting.LDAPConfigFile = f.Name()
errF = f.Close()
require.NoError(t, errF)
defer func() {
setting.LDAPEnabled = false
setting.LDAPConfigFile = ""
}()
getLDAPConfig = func(*setting.Cfg) (*ldap.Config, error) {
return &ldap.Config{}, nil
}
newLDAP = func(_ []*ldap.ServerConfig) multildap.IMultiLDAP {
return &LDAPMock{
UserSearchResult: &login.ExternalUserInfo{
Login: "ldap-daniel",
},
}
}
type testCase struct {
desc string
method string
url string
expectedCode int
permissions []accesscontrol.Permission
}
tests := []testCase{
{
url: "/api/admin/ldap/reload",
method: http.MethodPost,
desc: "ReloadLDAPCfg should return 200 for user with correct permissions",
expectedCode: http.StatusOK,
permissions: []accesscontrol.Permission{
{Action: accesscontrol.ActionLDAPConfigReload},
},
},
{
url: "/api/admin/ldap/reload",
method: http.MethodPost,
desc: "ReloadLDAPCfg should return 403 for user without required permissions",
expectedCode: http.StatusForbidden,
permissions: []accesscontrol.Permission{
{Action: "wrong"},
},
},
{
url: "/api/admin/ldap/status",
method: http.MethodGet,
desc: "GetLDAPStatus should return 200 for user without required permissions",
expectedCode: http.StatusOK,
permissions: []accesscontrol.Permission{
{Action: accesscontrol.ActionLDAPStatusRead},
},
},
{
url: "/api/admin/ldap/status",
method: http.MethodGet,
desc: "GetLDAPStatus should return 200 for user without required permissions",
expectedCode: http.StatusForbidden,
permissions: []accesscontrol.Permission{
{Action: "wrong"},
},
},
{
url: "/api/admin/ldap/test",
method: http.MethodGet,
desc: "GetUserFromLDAP should return 200 for user with required permissions",
expectedCode: http.StatusOK,
permissions: []accesscontrol.Permission{
{Action: accesscontrol.ActionLDAPUsersRead},
},
},
{
url: "/api/admin/ldap/test",
method: http.MethodGet,
desc: "GetUserFromLDAP should return 403 for user without required permissions",
expectedCode: http.StatusForbidden,
permissions: []accesscontrol.Permission{
{Action: "wrong"},
},
},
{
url: "/api/admin/ldap/sync/1",
method: http.MethodPost,
desc: "PostSyncUserWithLDAP should return 200 for user without required permissions",
expectedCode: http.StatusOK,
permissions: []accesscontrol.Permission{
{Action: accesscontrol.ActionLDAPUsersSync},
},
},
{
url: "/api/admin/ldap/sync/1",
method: http.MethodPost,
desc: "PostSyncUserWithLDAP should return 200 for user without required permissions",
expectedCode: http.StatusForbidden,
permissions: []accesscontrol.Permission{
{Action: "wrong"},
},
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
_, server := setupAPITest(t, func(a *Service) {
a.userService = &usertest.FakeUserService{ExpectedUser: &user.User{Login: "ldap-daniel", ID: 1}}
})
// Add minimal setup to pass handler
res, err := server.Send(
webtest.RequestWithSignedInUser(server.NewRequest(tt.method, tt.url, nil),
userWithPermissions(1, tt.permissions)))
require.NoError(t, err)
bodyBytes, _ := io.ReadAll(res.Body)
assert.Equal(t, tt.expectedCode, res.StatusCode, string(bodyBytes))
require.NoError(t, res.Body.Close())
})
}
}
func userWithPermissions(orgID int64, permissions []accesscontrol.Permission) *user.SignedInUser {
return &user.SignedInUser{OrgID: orgID, OrgRole: org.RoleViewer, Permissions: map[int64]map[string][]string{orgID: accesscontrol.GroupScopesByAction(permissions)}}
}