Compare commits

...

9 Commits

Author SHA1 Message Date
ArrisLee
b5fd787814 cleanup 2021-07-19 02:31:59 +12:00
Hui
f577e6728e feat(ldap): update get user groups func 2021-07-19 02:16:59 +12:00
ArrisLee
0972ae534f Merge branch 'feat/EE-568/admin-auto-population-ldap' of github.com:portainer/portainer into feat/EE-568/admin-auto-population-ldap 2021-07-19 00:07:01 +12:00
ArrisLee
9da73e1594 sort admin groups 2021-07-19 00:02:01 +12:00
fhanportainer
aaa9c0f096 feat(settings): added auto populate admin group section in LDAP (#5280)
* feat(settings): added auto populate admin group section  in LDAP

* moved admin groups API call to newly created ldap service

* preload admin groups

* disable admin auto populate when not selecting any groups
2021-07-18 22:34:07 +12:00
ArrisLee
5474bfae5b update payload validator 2021-07-18 15:34:27 +12:00
ArrisLee
6d7a5da1f2 update handler struct to support ldap 2021-07-16 12:10:56 +12:00
Hui
5c774141f4 feat(ldap): auto admin group mapping EE-994 2021-07-16 11:26:06 +12:00
Hui
3eebe3a08a feat(ldap): update LDAP settings for auto admin population EE-992 (#5282)
* update LDAP settings for auto admin population

* cleanup
2021-07-14 14:51:45 +12:00
23 changed files with 829 additions and 26 deletions

View File

@@ -39,6 +39,9 @@ func (store *Store) Init() error {
GroupSearchSettings: []portainer.LDAPGroupSearchSettings{
portainer.LDAPGroupSearchSettings{},
},
AdminGroupSearchSettings: []portainer.LDAPGroupSearchSettings{
portainer.LDAPGroupSearchSettings{},
},
},
OAuthSettings: portainer.OAuthSettings{},

View File

@@ -0,0 +1,21 @@
package migrator
import portainer "github.com/portainer/portainer/api"
func (m *Migrator) migrateDBVersionTo32() error {
if err := m.migrateAdminGroupSearchSettings(); err != nil {
return err
}
return nil
}
func (m *Migrator) migrateAdminGroupSearchSettings() error {
legacySettings, err := m.settingsService.Settings()
if err != nil {
return err
}
if legacySettings.LDAPSettings.AdminGroupSearchSettings == nil {
legacySettings.LDAPSettings.AdminGroupSearchSettings = []portainer.LDAPGroupSearchSettings{}
}
return m.settingsService.UpdateSettings(legacySettings)
}

View File

@@ -0,0 +1,52 @@
package migrator
import (
"os"
"path"
"testing"
"time"
"github.com/boltdb/bolt"
"github.com/portainer/portainer/api/bolt/internal"
"github.com/portainer/portainer/api/bolt/settings"
)
func TestMigrateAdminGroupSearchSettings(t *testing.T) {
testingDBStorePath, _ = os.Getwd()
testingDBFileName = "portainer-ee-mig-32.db"
databasePath := path.Join(testingDBStorePath, testingDBFileName)
dbConn, err := bolt.Open(databasePath, 0600, &bolt.Options{Timeout: 1 * time.Second})
if err != nil {
t.Errorf("failed to init testing DB connection: %v", err)
}
defer dbConn.Close()
defer os.Remove(testingDBFileName)
internalDBConn := &internal.DbConnection{
DB: dbConn,
}
settingsService, err := settings.NewService(internalDBConn)
if err != nil {
t.Errorf("failed to init testing settings service: %v", err)
}
dummySettingsObj := map[string]interface{}{
"LogoURL": "example.com",
}
if err := internal.UpdateObject(internalDBConn, "settings", []byte("SETTINGS"), dummySettingsObj); err != nil {
t.Errorf("failed to create mock settings: %v", err)
}
m := &Migrator{
db: dbConn,
settingsService: settingsService,
}
if err := m.migrateAdminGroupSearchSettings(); err != nil {
t.Errorf("failed to update settings: %v", err)
}
updatedSettings, err := m.settingsService.Settings()
if err != nil {
t.Errorf("failed to retrieve the updated settings: %v", err)
}
if updatedSettings.LDAPSettings.AdminGroupSearchSettings == nil {
t.Error("LDAP AdminGroupSearchSettings should not be nil")
}
}

View File

@@ -366,5 +366,12 @@ func (m *Migrator) Migrate() error {
}
}
// Portainer 2.9.0
if m.currentDBVersion < 32 {
if err := m.migrateDBVersionTo32(); err != nil {
return err
}
}
return m.versionService.StoreDBVersion(portainer.DBVersion)
}

View File

@@ -54,28 +54,32 @@ func (handler *Handler) authenticate(w http.ResponseWriter, r *http.Request) *ht
var payload authenticatePayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
}
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve settings from the database", Err: err}
}
u, err := handler.DataStore.User().UserByUsername(payload.Username)
if err != nil && err != bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a user with the specified username from the database", err}
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve a user with the specified username from the database", Err: err}
}
if err == bolterrors.ErrObjectNotFound && (settings.AuthenticationMethod == portainer.AuthenticationInternal || settings.AuthenticationMethod == portainer.AuthenticationOAuth) {
return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized}
return &httperror.HandlerError{StatusCode: http.StatusUnprocessableEntity, Message: "Invalid credentials", Err: httperrors.ErrUnauthorized}
}
if settings.AuthenticationMethod == portainer.AuthenticationLDAP {
if u == nil && settings.LDAPSettings.AutoCreateUsers {
if u == nil && (settings.LDAPSettings.AutoCreateUsers || settings.LDAPSettings.AdminAutoPopulate) {
return handler.authenticateLDAPAndCreateUser(w, payload.Username, payload.Password, &settings.LDAPSettings)
} else if u == nil && !settings.LDAPSettings.AutoCreateUsers {
return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized}
} else if u == nil && !settings.LDAPSettings.AutoCreateUsers && !settings.LDAPSettings.AdminAutoPopulate {
return &httperror.HandlerError{
StatusCode: http.StatusUnprocessableEntity,
Message: "Invalid credentials",
Err: httperrors.ErrUnauthorized,
}
}
return handler.authenticateLDAP(w, u, payload.Password, &settings.LDAPSettings)
}
@@ -89,6 +93,43 @@ func (handler *Handler) authenticateLDAP(w http.ResponseWriter, user *portainer.
return handler.authenticateInternal(w, user, password)
}
if ldapSettings.AdminAutoPopulate {
matched, err := handler.isLDAPAdmin(user.Username, ldapSettings)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusUnprocessableEntity,
Message: "Failed to search and match LDAP admin groups",
Err: err,
}
}
if matched && user.Role != portainer.AdministratorRole {
if err := handler.updateUserRole(user, portainer.AdministratorRole); err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusUnprocessableEntity,
Message: "Failed to assign admin role to the user",
Err: err,
}
}
}
if !matched && user.Role == portainer.AdministratorRole {
if err := handler.updateUserRole(user, portainer.StandardUserRole); err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusUnprocessableEntity,
Message: "Failed to remove admin role from the user",
Err: err,
}
}
}
if err := handler.DataStore.User().UpdateUser(user.ID, user); err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusInternalServerError,
Message: "Unable to update user role inside the database",
Err: err,
}
}
}
err = handler.addUserIntoTeams(user, ldapSettings)
if err != nil {
log.Printf("Warning: unable to automatically add user into teams: %s\n", err.Error())
@@ -100,7 +141,7 @@ func (handler *Handler) authenticateLDAP(w http.ResponseWriter, user *portainer.
func (handler *Handler) authenticateInternal(w http.ResponseWriter, user *portainer.User, password string) *httperror.HandlerError {
err := handler.CryptoService.CompareHashAndData(user.Password, password)
if err != nil {
return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized}
return &httperror.HandlerError{StatusCode: http.StatusUnprocessableEntity, Message: "Invalid credentials", Err: httperrors.ErrUnauthorized}
}
return handler.writeToken(w, user)
@@ -109,7 +150,7 @@ func (handler *Handler) authenticateInternal(w http.ResponseWriter, user *portai
func (handler *Handler) authenticateLDAPAndCreateUser(w http.ResponseWriter, username, password string, ldapSettings *portainer.LDAPSettings) *httperror.HandlerError {
err := handler.LDAPService.AuthenticateUser(username, password, ldapSettings)
if err != nil {
return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", err}
return &httperror.HandlerError{StatusCode: http.StatusUnprocessableEntity, Message: "Invalid credentials", Err: err}
}
user := &portainer.User{
@@ -117,9 +158,23 @@ func (handler *Handler) authenticateLDAPAndCreateUser(w http.ResponseWriter, use
Role: portainer.StandardUserRole,
}
if ldapSettings.AdminAutoPopulate {
matched, err := handler.isLDAPAdmin(username, ldapSettings)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusUnprocessableEntity,
Message: "Failed to search and match LDAP admin groups",
Err: err,
}
}
if matched {
user.Role = portainer.AdministratorRole
}
}
err = handler.DataStore.User().CreateUser(user)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err}
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist user inside the database", Err: err}
}
err = handler.addUserIntoTeams(user, ldapSettings)
@@ -190,6 +245,32 @@ func (handler *Handler) addUserIntoTeams(user *portainer.User, settings *portain
return nil
}
func (handler *Handler) isLDAPAdmin(username string, ldapSettings *portainer.LDAPSettings) (bool, error) {
adminUserGroups, err := handler.LDAPService.GetUserAdminGroups(username, ldapSettings)
if err != nil {
return false, errors.New("Failed to retrieve user groups from LDAP server")
}
adminGroupsMap := make(map[string]bool)
for _, adminGroup := range ldapSettings.AdminGroups {
adminGroupsMap[adminGroup] = true
}
for _, userGroup := range adminUserGroups {
if adminGroupsMap[userGroup] {
return true, nil
}
}
return false, nil
}
func (handler *Handler) updateUserRole(user *portainer.User, role portainer.UserRole) error {
user.Role = role
if err := handler.DataStore.User().UpdateUser(user.ID, user); err != nil {
return errors.New("Unable to update user role inside the database")
}
return nil
}
func teamExists(teamName string, ldapGroups []string) bool {
for _, group := range ldapGroups {
if strings.ToLower(group) == strings.ToLower(teamName) {

View File

@@ -0,0 +1,37 @@
package auth
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
)
func TestIsLDAPAdmin_Match(t *testing.T) {
h := Handler{
LDAPService: testhelpers.NewLDAPService(),
}
mockLDAPSettings := &portainer.LDAPSettings{
AdminGroups: []string{"manager", "operator"},
}
matched, err := h.isLDAPAdmin("username", mockLDAPSettings)
assert.NoError(t, err)
assert.Equal(t, true, matched)
}
func TestIsLDAPAdmin_NotMatch(t *testing.T) {
h := Handler{
LDAPService: testhelpers.NewLDAPService(),
}
mockLDAPSettings := &portainer.LDAPSettings{
AdminGroups: []string{"admin", "operator"},
}
matched, err := h.isLDAPAdmin("username", mockLDAPSettings)
assert.NoError(t, err)
assert.Equal(t, false, matched)
}

View File

@@ -17,6 +17,7 @@ import (
"github.com/portainer/portainer/api/http/handler/endpointproxy"
"github.com/portainer/portainer/api/http/handler/endpoints"
"github.com/portainer/portainer/api/http/handler/file"
"github.com/portainer/portainer/api/http/handler/ldap"
"github.com/portainer/portainer/api/http/handler/motd"
"github.com/portainer/portainer/api/http/handler/registries"
"github.com/portainer/portainer/api/http/handler/resourcecontrols"
@@ -49,6 +50,7 @@ type Handler struct {
EndpointHandler *endpoints.Handler
EndpointProxyHandler *endpointproxy.Handler
FileHandler *file.Handler
LDAPHandler *ldap.Handler
MOTDHandler *motd.Handler
RegistryHandler *registries.Handler
ResourceControlHandler *resourcecontrols.Handler
@@ -177,6 +179,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
default:
http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r)
}
case strings.HasPrefix(r.URL.Path, "/api/ldap"):
http.StripPrefix("/api", h.LDAPHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/motd"):
http.StripPrefix("/api", h.MOTDHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/registries"):

View File

@@ -0,0 +1,57 @@
package ldap
import (
"net/http"
"github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/security"
)
// Handler is the HTTP handler used to handle LDAP search Operations
type Handler struct {
*mux.Router
DataStore portainer.DataStore
FileService portainer.FileService
LDAPService portainer.LDAPService
}
// NewHandler returns a new Handler
func NewHandler(bouncer *security.RequestBouncer) *Handler {
h := &Handler{
Router: mux.NewRouter(),
}
h.Handle("/ldap/check",
bouncer.AdminAccess(httperror.LoggerHandler(h.ldapCheck))).Methods(http.MethodPost)
h.Handle("/ldap/admin-groups",
bouncer.AdminAccess(httperror.LoggerHandler(h.ldapAdminGroups))).Methods(http.MethodPost)
h.Handle("/ldap/test",
bouncer.AdminAccess(httperror.LoggerHandler(h.ldapTestLogin))).Methods(http.MethodPost)
return h
}
func (handler *Handler) prefillSettings(ldapSettings *portainer.LDAPSettings) error {
if !ldapSettings.AnonymousMode && ldapSettings.Password == "" {
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return err
}
ldapSettings.Password = settings.LDAPSettings.Password
}
if (ldapSettings.TLSConfig.TLS || ldapSettings.StartTLS) && !ldapSettings.TLSConfig.TLSSkipVerify {
caCertPath, err := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA)
if err != nil {
return err
}
ldapSettings.TLSConfig.TLSCACertPath = caCertPath
}
return nil
}

View File

@@ -0,0 +1,46 @@
package ldap
import (
"errors"
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
)
type checkPayload struct {
LDAPSettings portainer.LDAPSettings
}
func (payload *checkPayload) Validate(r *http.Request) error {
if len(payload.LDAPSettings.URL) == 0 {
return errors.New("Invalid LDAP URL")
}
return nil
}
// POST request on /ldap/check
func (handler *Handler) ldapCheck(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload checkPayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
}
settings := &payload.LDAPSettings
err = handler.prefillSettings(settings)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to fetch default settings", Err: err}
}
err = handler.LDAPService.TestConnectivity(settings)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to connect to LDAP server", Err: err}
}
return response.Empty(w)
}

View File

@@ -0,0 +1,60 @@
package ldap
import (
"errors"
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
)
type adminGroupsPayload struct {
LDAPSettings portainer.LDAPSettings
}
func (payload *adminGroupsPayload) Validate(r *http.Request) error {
if len(payload.LDAPSettings.URL) == 0 {
return errors.New("Invalid LDAP URLs. At least one URL is required")
}
if len(payload.LDAPSettings.AdminGroupSearchSettings) == 0 {
return errors.New("Invalid AdminGroupSearchSettings. At least one search setting is required")
}
return nil
}
// @id LDAPAdminGroups
// @summary Fetch LDAP admin groups
// @description Fetch LDAP admin groups from LDAP server based on AdminGroupSearchSettings
// @description **Access policy**: administrator
// @tags ldap
// @security jwt
// @accept json
// @produce json
// @param body body adminGroupsPayload true "LDAPSettings"
// @success 200 {array} string "Success"
// @failure 400 "Invalid request"
// @failure 500 "Server error"
// @router /ldap/admin-groups [post]
func (handler *Handler) ldapAdminGroups(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload adminGroupsPayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
}
settings := &payload.LDAPSettings
err = handler.prefillSettings(settings)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to fetch default settings", Err: err}
}
groups, err := handler.LDAPService.SearchAdminGroups(settings)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to search admin groups", Err: err}
}
return response.JSON(w, groups)
}

View File

@@ -0,0 +1,54 @@
package ldap
import (
"errors"
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
httperrors "github.com/portainer/portainer/api/http/errors"
)
type testLoginPayload struct {
LDAPSettings portainer.LDAPSettings
Username string
Password string
}
type testLoginResponse struct {
Valid bool `json:"valid"`
}
func (payload *testLoginPayload) Validate(r *http.Request) error {
if len(payload.LDAPSettings.URL) == 0 {
return errors.New("Invalid LDAP URL")
}
return nil
}
// POST request on /ldap/test
func (handler *Handler) ldapTestLogin(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload testLoginPayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
settings := &payload.LDAPSettings
err = handler.prefillSettings(settings)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to fetch default settings", err}
}
err = handler.LDAPService.AuthenticateUser(payload.Username, payload.Password, settings)
if err != nil && err != httperrors.ErrUnauthorized {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to test user authorization", err}
}
return response.JSON(w, &testLoginResponse{Valid: err != httperrors.ErrUnauthorized})
}

View File

@@ -52,6 +52,20 @@ func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
return errors.New("Invalid user session timeout")
}
}
if payload.AuthenticationMethod != nil && *payload.AuthenticationMethod == 2 {
if payload.LDAPSettings == nil {
return errors.New("Invalid LDAP Configuration")
}
if payload.LDAPSettings.URL == "" {
return errors.New("Invalid LDAP URL")
}
if payload.LDAPSettings.AdminAutoPopulate && len(payload.LDAPSettings.AdminGroupSearchSettings) == 0 {
return errors.New("Invalid AdminGroupSearchSettings")
}
if !payload.LDAPSettings.AdminAutoPopulate && len(payload.LDAPSettings.AdminGroups) > 0 {
payload.LDAPSettings.AdminGroups = []string{}
}
}
return nil
}

View File

@@ -26,6 +26,7 @@ import (
"github.com/portainer/portainer/api/http/handler/endpointproxy"
"github.com/portainer/portainer/api/http/handler/endpoints"
"github.com/portainer/portainer/api/http/handler/file"
"github.com/portainer/portainer/api/http/handler/ldap"
"github.com/portainer/portainer/api/http/handler/motd"
"github.com/portainer/portainer/api/http/handler/registries"
"github.com/portainer/portainer/api/http/handler/resourcecontrols"
@@ -51,7 +52,7 @@ import (
// Server implements the portainer.Server interface
type Server struct {
AuthorizationService *authorization.Service
AuthorizationService *authorization.Service
BindAddress string
AssetsPath string
Status *portainer.Status
@@ -155,6 +156,11 @@ func (server *Server) Start() error {
var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public"))
var ldapHandler = ldap.NewHandler(requestBouncer)
ldapHandler.DataStore = server.DataStore
ldapHandler.FileService = server.FileService
ldapHandler.LDAPService = server.LDAPService
var motdHandler = motd.NewHandler(requestBouncer)
var registryHandler = registries.NewHandler(requestBouncer)
@@ -229,6 +235,7 @@ func (server *Server) Start() error {
EndpointEdgeHandler: endpointEdgeHandler,
EndpointProxyHandler: endpointProxyHandler,
FileHandler: fileHandler,
LDAPHandler: ldapHandler,
MOTDHandler: motdHandler,
RegistryHandler: registryHandler,
ResourceControlHandler: resourceControlHandler,

View File

@@ -0,0 +1,35 @@
package testhelpers
import (
portainer "github.com/portainer/portainer/api"
)
type ldapService struct{}
// NewGitService creates new mock for portainer.GitService.
func NewLDAPService() *ldapService {
return &ldapService{}
}
// AuthenticateUser is used to authenticate a user against a LDAP/AD.
func (service *ldapService) AuthenticateUser(username, password string, settings *portainer.LDAPSettings) error {
return nil
}
// GetUserGroups is used to retrieve user groups from LDAP/AD.
func (service *ldapService) GetUserGroups(username string, settings *portainer.LDAPSettings) ([]string, error) {
return []string{"stuff"}, nil
}
func (service *ldapService) GetUserAdminGroups(username string, settings *portainer.LDAPSettings) ([]string, error) {
return []string{"manager", "lead"}, nil
}
// SearchGroups searches for groups with the specified settings
func (service *ldapService) SearchAdminGroups(settings *portainer.LDAPSettings) ([]string, error) {
return nil, nil
}
func (service *ldapService) TestConnectivity(settings *portainer.LDAPSettings) error {
return nil
}

View File

@@ -3,6 +3,7 @@ package ldap
import (
"errors"
"fmt"
"sort"
"strings"
ldap "github.com/go-ldap/ldap/v3"
@@ -138,6 +139,109 @@ func (*Service) GetUserGroups(username string, settings *portainer.LDAPSettings)
return userGroups, nil
}
// GetUserGroups is used to retrieve user groups from LDAP/AD.
func (*Service) GetUserAdminGroups(username string, settings *portainer.LDAPSettings) ([]string, error) {
connection, err := createConnection(settings)
if err != nil {
return nil, err
}
defer connection.Close()
if !settings.AnonymousMode {
err = connection.Bind(settings.ReaderDN, settings.Password)
if err != nil {
return nil, err
}
}
userDN, err := searchUser(username, connection, settings.SearchSettings)
if err != nil {
return nil, err
}
userGroups := getGroupsByUser(userDN, connection, settings.AdminGroupSearchSettings)
return userGroups, nil
}
// SearchGroups searches for groups with the specified settings
func (*Service) SearchAdminGroups(settings *portainer.LDAPSettings) ([]string, error) {
type groupSet map[string]bool
connection, err := createConnection(settings)
if err != nil {
return nil, err
}
defer connection.Close()
if !settings.AnonymousMode {
err = connection.Bind(settings.ReaderDN, settings.Password)
if err != nil {
return nil, err
}
}
userGroups := map[string]groupSet{}
for _, searchSettings := range settings.AdminGroupSearchSettings {
searchRequest := ldap.NewSearchRequest(
searchSettings.GroupBaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
searchSettings.GroupFilter,
[]string{"cn", searchSettings.GroupAttribute},
nil,
)
sr, err := connection.Search(searchRequest)
if err != nil {
return nil, err
}
for _, entry := range sr.Entries {
members := entry.GetAttributeValues(searchSettings.GroupAttribute)
for _, username := range members {
_, ok := userGroups[username]
if !ok {
userGroups[username] = groupSet{}
}
userGroups[username][entry.GetAttributeValue("cn")] = true
}
}
}
groupsMap := make(map[string]bool)
for _, groups := range userGroups {
for group := range groups {
groupsMap[group] = true
}
}
groups := make([]string, 0, len(groupsMap))
for group := range groupsMap {
groups = append(groups, group)
}
sort.Strings(groups)
return groups, nil
}
// TestConnectivity is used to test a connection against the LDAP server using the credentials
// specified in the LDAPSettings.
func (*Service) TestConnectivity(settings *portainer.LDAPSettings) error {
connection, err := createConnection(settings)
if err != nil {
return err
}
defer connection.Close()
err = connection.Bind(settings.ReaderDN, settings.Password)
if err != nil {
return err
}
return nil
}
// Get a list of group names for specified user from LDAP/AD
func getGroups(userDN string, conn *ldap.Conn, settings []portainer.LDAPGroupSearchSettings) []string {
groups := make([]string, 0)
@@ -169,19 +273,33 @@ func getGroups(userDN string, conn *ldap.Conn, settings []portainer.LDAPGroupSea
return groups
}
// TestConnectivity is used to test a connection against the LDAP server using the credentials
// specified in the LDAPSettings.
func (*Service) TestConnectivity(settings *portainer.LDAPSettings) error {
// Get a list of group names for specified user from LDAP/AD
func getGroupsByUser(userDN string, conn *ldap.Conn, settings []portainer.LDAPGroupSearchSettings) []string {
groups := make([]string, 0)
userDNEscaped := ldap.EscapeFilter(userDN)
connection, err := createConnection(settings)
if err != nil {
return err
}
defer connection.Close()
for _, searchSettings := range settings {
searchRequest := ldap.NewSearchRequest(
searchSettings.GroupBaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
fmt.Sprintf("(&%s(%s=%s))", searchSettings.GroupFilter, searchSettings.GroupAttribute, userDNEscaped),
[]string{"cn"},
nil,
)
err = connection.Bind(settings.ReaderDN, settings.Password)
if err != nil {
return err
// Deliberately skip errors on the search request so that we can jump to other search settings
// if any issue arise with the current one.
sr, err := conn.Search(searchRequest)
if err != nil {
continue
}
for _, entry := range sr.Entries {
for _, attr := range entry.Attributes {
groups = append(groups, attr.Values[0])
}
}
}
return nil
return groups
}

View File

@@ -470,7 +470,10 @@ type (
SearchSettings []LDAPSearchSettings `json:"SearchSettings"`
GroupSearchSettings []LDAPGroupSearchSettings `json:"GroupSearchSettings"`
// Automatically provision users and assign them to matching LDAP group names
AutoCreateUsers bool `json:"AutoCreateUsers" example:"true"`
AutoCreateUsers bool `json:"AutoCreateUsers" example:"true"`
AdminAutoPopulate bool `json:"AdminAutoPopulate" example:"true"`
AdminGroupSearchSettings []LDAPGroupSearchSettings `json:"AdminGroupSearchSettings"`
AdminGroups []string `json:"AdminGroups"`
}
// LicenseInformation represents information about an extension license
@@ -1187,6 +1190,8 @@ type (
AuthenticateUser(username, password string, settings *LDAPSettings) error
TestConnectivity(settings *LDAPSettings) error
GetUserGroups(username string, settings *LDAPSettings) ([]string, error)
GetUserAdminGroups(username string, settings *LDAPSettings) ([]string, error)
SearchAdminGroups(settings *LDAPSettings) ([]string, error)
}
// OAuthService represents a service used to authenticate users using OAuth
@@ -1345,7 +1350,7 @@ const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.6.0"
// DBVersion is the version number of the Portainer database
DBVersion = 30
DBVersion = 32
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
ComposeSyntaxMaxVersion = "3.9"
// AssetsServerURL represents the URL of the Portainer asset server

View File

@@ -13,6 +13,7 @@ angular
.constant('API_ENDPOINT_REGISTRIES', 'api/registries')
.constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls')
.constant('API_ENDPOINT_SETTINGS', 'api/settings')
.constant('API_ENDPOINT_LDAP', 'api/ldap')
.constant('API_ENDPOINT_STACKS', 'api/stacks')
.constant('API_ENDPOINT_STATUS', 'api/status')
.constant('API_ENDPOINT_SUPPORT', 'api/support')

View File

@@ -0,0 +1,14 @@
angular.module('portainer.app').factory('LDAP', [
'$resource',
'API_ENDPOINT_LDAP',
function SettingsFactory($resource, API_ENDPOINT_LDAP) {
'use strict';
return $resource(
API_ENDPOINT_LDAP + '/:action',
{},
{
adminGroups: { method: 'POST', isArray: true, params: { action: 'admin-groups' } },
}
);
},
]);

View File

@@ -0,0 +1,24 @@
angular.module('portainer.app').factory('LDAPService', [
'$q',
'LDAP',
function LdapServiceFactory($q, LDAP) {
'use strict';
var service = {};
service.adminGroups = function (ldapSettings) {
var deferred = $q.defer();
LDAP.adminGroups({ ldapSettings })
.$promise.then(function success(data) {
const userGroups = data.map((Name) => ({ Name, selected: ldapSettings.AdminGroups && ldapSettings.AdminGroups.includes(Name) ? true : false }));
deferred.resolve(userGroups);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve admin grous', err: err });
});
return deferred.promise;
};
return service;
},
]);

View File

@@ -364,6 +364,104 @@
</div>
</div>
<!-- !group-search-settings -->
<!-- admin group-search-settings -->
<div class="col-sm-12 form-section-title" style="float: initial;">
Auto-populate team admins
</div>
<div
ng-repeat="config in formValues.LDAPSettings.AdminGroupSearchSettings | limitTo: (1 - formValues.LDAPSettings.AdminGroupSearchSettings)"
style="display: block; margin-bottom: 10px;"
>
<div class="form-group" ng-if="$index > 0" style="margin-bottom: 10px;">
<span class="col-sm-12 text-muted small">
Extra search configuration
</span>
</div>
<div class="form-group">
<label for="ldap_admin_group_basedn_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
Group Base DN
<portainer-tooltip position="bottom" message="The distinguished name of the element from which the LDAP server will search for groups."></portainer-tooltip>
</label>
<div class="col-sm-8 col-md-4">
<input type="text" class="form-control" id="ldap_admin_group_basedn_{{ $index }}" ng-model="config.GroupBaseDN" placeholder="dc=ldap,dc=domain,dc=tld" />
</div>
<label for="ldap_admin_group_att_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
Group Membership Attribute
<portainer-tooltip position="bottom" message="LDAP attribute which denotes the group membership."></portainer-tooltip>
</label>
<div class="col-sm-8 col-md-4">
<input type="text" class="form-control" id="ldap_admin_group_att_{{ $index }}" ng-model="config.GroupAttribute" placeholder="member" />
</div>
</div>
<div class="form-group">
<label for="ldap_admin_group_filter_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
Group Filter
<portainer-tooltip position="bottom" message="The LDAP search filter used to select group elements, optional."></portainer-tooltip>
</label>
<div ng-class="{ 'col-sm-7 col-md-9': $index, 'col-sm-8 col-md-10': !$index }">
<input type="text" class="form-control" id="ldap_admin_group_filter_{{ $index }}" ng-model="config.GroupFilter" placeholder="(objectClass=account)" />
</div>
<div class="col-sm-1" ng-if="$index > 0">
<button class="btn btn-sm btn-danger" type="button" ng-click="removeAdminGroupSearchConfiguration($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
<div class="form-group" style="margin-top: 10px;">
<div class="col-sm-12">
<button class="label label-default interactive" style="border: 0;" ng-click="addAdminGroupSearchConfiguration()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add group search configuration
</button>
</div>
<div class="col-sm-12" style="margin-top: 10px;">
<button class="btn btm-sm btn-primary" type="button" ng-click="searchAdminGroups()">
Fetch Admin Group(s)
</button>
<span ng-if="formValues.groups && formValues.groups.length === 0" style="margin-left: 30px;">
<i class="fa fa-exclamation-triangle text-warning" aria-hidden="true"></i> No groups found</span
>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
ng-model="formValues.LDAPSettings.AdminAutoPopulate"
name="admin-auto-populate"
label="Assign admin rights to group(s)"
label-class="col-sm-2 text-muted small"
disabled="groups === null || groups.length === 0"
></por-switch-field>
</div>
</div>
<div class="form-group" ng-if="formValues.LDAPSettings.AdminAutoPopulate">
<div class="col-sm-12">
<label for="group-access" class="control-label text-left">
Select Group(s)
</label>
<span
isteven-multi-select
ng-if="groups.length > 0 || groups.length > 1"
input-model="groups"
output-model="formValues.selectedAdminGroups"
button-label="Name"
item-label="Name"
tick-property="selected"
helper-elements="filter"
search-property="Name"
translation="{nothingSelected: 'Select one or more teams', search: 'Search...'}"
style="margin-left: 20px;"
>
</span>
</div>
</div>
</div>
<div ng-if="isOauthEnabled()">

View File

@@ -1,3 +1,4 @@
import _ from 'lodash-es';
angular.module('portainer.app').controller('SettingsAuthenticationController', [
'$q',
'$scope',
@@ -6,7 +7,10 @@ angular.module('portainer.app').controller('SettingsAuthenticationController', [
'SettingsService',
'FileUploadService',
'TeamService',
function ($q, $scope, $state, Notifications, SettingsService, FileUploadService, TeamService) {
'LDAPService',
function ($q, $scope, $state, Notifications, SettingsService, FileUploadService, TeamService, LDAPService) {
const DEFAULT_GROUP_FILTER = '(objectClass=groupOfNames)';
$scope.state = {
successfulConnectivityCheck: false,
failedConnectivityCheck: false,
@@ -35,6 +39,7 @@ angular.module('portainer.app').controller('SettingsAuthenticationController', [
{ key: '6 months', value: `${24 * 30 * 6}h` },
{ key: '1 year', value: `${24 * 30 * 12}h` },
],
enableAssignAdminGroup: false,
};
$scope.formValues = {
@@ -63,8 +68,32 @@ angular.module('portainer.app').controller('SettingsAuthenticationController', [
GroupAttribute: '',
},
],
AdminGroupSearchSettings: [
{
GroupBaseDN: '',
GroupFilter: '',
GroupAttribute: '',
},
],
AdminAutoPopulate: false,
AutoCreateUsers: true,
},
selectedAdminGroups: '',
};
$scope.groups = [];
$scope.searchAdminGroups = async function () {
const settings = {
...$scope.settings.LDAPSettings,
AdminGroupSearchSettings: $scope.settings.LDAPSettings.AdminGroupSearchSettings.map((search) => ({ ...search, GroupFilter: search.GroupFilter || DEFAULT_GROUP_FILTER })),
};
$scope.groups = await LDAPService.adminGroups(settings);
if ($scope.groups && $scope.groups.length > 0) {
$scope.state.enableAssignAdminGroup = true;
}
};
$scope.isOauthEnabled = function isOauthEnabled() {
@@ -87,6 +116,14 @@ angular.module('portainer.app').controller('SettingsAuthenticationController', [
$scope.formValues.LDAPSettings.GroupSearchSettings.splice(index, 1);
};
$scope.addAdminGroupSearchConfiguration = function () {
$scope.formValues.LDAPSettings.GroupSearchSettings.push({ GroupBaseDN: '', GroupAttribute: '', GroupFilter: '' });
};
$scope.removeAdminGroupSearchConfiguration = function (index) {
$scope.formValues.LDAPSettings.GroupSearchSettings.splice(index, 1);
};
$scope.LDAPConnectivityCheck = function () {
var settings = angular.copy($scope.settings);
var TLSCAFile = $scope.formValues.TLSCACert !== settings.LDAPSettings.TLSConfig.TLSCACert ? $scope.formValues.TLSCACert : null;
@@ -123,6 +160,16 @@ angular.module('portainer.app').controller('SettingsAuthenticationController', [
$scope.saveSettings = function () {
var settings = angular.copy($scope.settings);
if ($scope.formValues.LDAPSettings.AdminAutoPopulate && $scope.formValues.selectedAdminGroups && $scope.formValues.selectedAdminGroups.length > 0) {
settings.LDAPSettings.AdminGroups = _.map($scope.formValues.selectedAdminGroups, (team) => team.Name);
} else {
settings.LDAPSettings.AdminGroups = [];
}
if ($scope.formValues.selectedAdminGroups && $scope.formValues.selectedAdminGroups.length === 0) {
settings.LDAPSettings.AdminAutoPopulate = false;
}
var TLSCAFile = $scope.formValues.TLSCACert !== settings.LDAPSettings.TLSConfig.TLSCACert ? $scope.formValues.TLSCACert : null;
if ($scope.formValues.LDAPSettings.AnonymousMode) {
@@ -158,6 +205,23 @@ angular.module('portainer.app').controller('SettingsAuthenticationController', [
}
}
async function prelodaAdminGroup() {
if ($scope.settings.LDAPSettings.AdminAutoPopulate && $scope.settings.LDAPSettings.AdminGroups && $scope.settings.LDAPSettings.AdminGroups.length > 0) {
const settings = {
...$scope.settings.LDAPSettings,
AdminGroupSearchSettings: $scope.settings.LDAPSettings.AdminGroupSearchSettings.map((search) => ({
...search,
GroupFilter: search.GroupFilter || '(objectClass=groupOfNames)',
})),
};
$scope.groups = await LDAPService.adminGroups(settings);
}
if ($scope.groups && $scope.groups.length > 0) {
$scope.state.enableAssignAdminGroup = true;
}
}
function initView() {
$q.all({
settings: SettingsService.settings(),
@@ -170,6 +234,7 @@ angular.module('portainer.app').controller('SettingsAuthenticationController', [
$scope.formValues.LDAPSettings = settings.LDAPSettings;
$scope.OAuthSettings = settings.OAuthSettings;
$scope.formValues.TLSCACert = settings.LDAPSettings.TLSConfig.TLSCACert;
prelodaAdminGroup();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve application settings');