feat(ldap): auto admin group mapping EE-994

This commit is contained in:
Hui
2021-07-16 11:26:06 +12:00
committed by GitHub
parent 3eebe3a08a
commit 5c774141f4
11 changed files with 420 additions and 12 deletions

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 TestMigrateStackEntryPoint(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,36 @@ func (handler *Handler) authenticateLDAP(w http.ResponseWriter, user *portainer.
return handler.authenticateInternal(w, user, password)
}
if ldapSettings.AdminAutoPopulate {
userGroups, err := handler.LDAPService.GetUserGroups(user.Username, ldapSettings)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusUnprocessableEntity,
Message: "Failed to retrieve user groups from LDAP server",
Err: err,
}
}
adminGroupsMap := make(map[string]bool)
for _, adminGroup := range ldapSettings.AdminGroups {
adminGroupsMap[adminGroup] = true
}
for _, userGroup := range userGroups {
if adminGroupsMap[userGroup] {
user.Role = portainer.AdministratorRole
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,
}
}
break
}
}
}
err = handler.addUserIntoTeams(user, ldapSettings)
if err != nil {
log.Printf("Warning: unable to automatically add user into teams: %s\n", err.Error())
@@ -100,7 +134,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 +143,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 +151,31 @@ func (handler *Handler) authenticateLDAPAndCreateUser(w http.ResponseWriter, use
Role: portainer.StandardUserRole,
}
if ldapSettings.AdminAutoPopulate {
userGroups, err := handler.LDAPService.GetUserGroups(username, ldapSettings)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusUnprocessableEntity,
Message: "Failed to retrieve user groups from LDAP server",
Err: err,
}
}
adminGroupsMap := make(map[string]bool)
for _, adminGroup := range ldapSettings.AdminGroups {
adminGroupsMap[adminGroup] = true
}
for _, userGroup := range userGroups {
if adminGroupsMap[userGroup] {
user.Role = portainer.AdministratorRole
break
}
}
}
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)

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,47 @@
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
}
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

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

View File

@@ -138,6 +138,67 @@ func (*Service) GetUserGroups(username string, settings *portainer.LDAPSettings)
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)
}
return groups, 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)

View File

@@ -1190,6 +1190,7 @@ type (
AuthenticateUser(username, password string, settings *LDAPSettings) error
TestConnectivity(settings *LDAPSettings) error
GetUserGroups(username string, settings *LDAPSettings) ([]string, error)
SearchAdminGroups(settings *LDAPSettings) ([]string, error)
}
// OAuthService represents a service used to authenticate users using OAuth
@@ -1348,7 +1349,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