diff --git a/api/http/handler/endpoints/endpoint_list.go b/api/http/handler/endpoints/endpoint_list.go index cf9bbb69c..47aaebcb5 100644 --- a/api/http/handler/endpoints/endpoint_list.go +++ b/api/http/handler/endpoints/endpoint_list.go @@ -34,7 +34,7 @@ var endpointGroupNames map[portainer.EndpointGroupID]string // @id EndpointList // @summary List environments(endpoints) // @description List all environments(endpoints) based on the current user authorizations. Will -// @description return all environments(endpoints) if using an administrator account otherwise it will +// @description return all environments(endpoints) if using an administrator or team leader account otherwise it will // @description only return authorized environments(endpoints). // @description **Access policy**: restricted // @tags endpoints diff --git a/api/http/handler/settings/settings_public.go b/api/http/handler/settings/settings_public.go index 7b7c6f8e1..9eac33878 100644 --- a/api/http/handler/settings/settings_public.go +++ b/api/http/handler/settings/settings_public.go @@ -28,6 +28,8 @@ type publicSettingsResponse struct { EnableTelemetry bool `json:"EnableTelemetry" example:"true"` // The expiry of a Kubeconfig KubeconfigExpiry string `example:"24h" default:"0"` + // Whether team sync is enabled + TeamSync bool `json:"TeamSync" example:"true"` } // @id SettingsPublic @@ -72,5 +74,9 @@ func generatePublicSettings(appSettings *portainer.Settings) *publicSettingsResp publicSettings.OAuthLoginURI += "&prompt=login" } } + //if LDAP authentication is on, compose the related fields from application settings + if publicSettings.AuthenticationMethod == portainer.AuthenticationLDAP { + publicSettings.TeamSync = len(appSettings.LDAPSettings.GroupSearchSettings) > 0 + } return publicSettings } diff --git a/api/http/handler/teammemberships/handler.go b/api/http/handler/teammemberships/handler.go index 2b0f49096..e1c4ab998 100644 --- a/api/http/handler/teammemberships/handler.go +++ b/api/http/handler/teammemberships/handler.go @@ -21,14 +21,13 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { h := &Handler{ Router: mux.NewRouter(), } - h.Handle("/team_memberships", - bouncer.AdminAccess(httperror.LoggerHandler(h.teamMembershipCreate))).Methods(http.MethodPost) - h.Handle("/team_memberships", - bouncer.AdminAccess(httperror.LoggerHandler(h.teamMembershipList))).Methods(http.MethodGet) - h.Handle("/team_memberships/{id}", - bouncer.AdminAccess(httperror.LoggerHandler(h.teamMembershipUpdate))).Methods(http.MethodPut) - h.Handle("/team_memberships/{id}", - bouncer.AdminAccess(httperror.LoggerHandler(h.teamMembershipDelete))).Methods(http.MethodDelete) + + h.Use(bouncer.TeamLeaderAccess) + + h.Handle("/team_memberships", httperror.LoggerHandler(h.teamMembershipCreate)).Methods(http.MethodPost) + h.Handle("/team_memberships", httperror.LoggerHandler(h.teamMembershipList)).Methods(http.MethodGet) + h.Handle("/team_memberships/{id}", httperror.LoggerHandler(h.teamMembershipUpdate)).Methods(http.MethodPut) + h.Handle("/team_memberships/{id}", httperror.LoggerHandler(h.teamMembershipDelete)).Methods(http.MethodDelete) return h } diff --git a/api/http/handler/teammemberships/teammembership_list.go b/api/http/handler/teammemberships/teammembership_list.go index a7b8a0d60..afaa58d41 100644 --- a/api/http/handler/teammemberships/teammembership_list.go +++ b/api/http/handler/teammemberships/teammembership_list.go @@ -5,8 +5,6 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api/http/errors" - "github.com/portainer/portainer/api/http/security" ) // @id TeamMembershipList @@ -23,15 +21,6 @@ import ( // @failure 500 "Server error" // @router /team_memberships [get] func (handler *Handler) teamMembershipList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} - } - - if !securityContext.IsAdmin && !securityContext.IsTeamLeader { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to list team memberships", errors.ErrResourceAccessDenied} - } - memberships, err := handler.DataStore.TeamMembership().TeamMemberships() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve team memberships from the database", err} diff --git a/api/http/handler/teammemberships/teammembership_update.go b/api/http/handler/teammemberships/teammembership_update.go index 5deceb145..a54eb5205 100644 --- a/api/http/handler/teammemberships/teammembership_update.go +++ b/api/http/handler/teammemberships/teammembership_update.go @@ -36,8 +36,8 @@ func (payload *teamMembershipUpdatePayload) Validate(r *http.Request) error { // @id TeamMembershipUpdate // @summary Update a team membership -// @description Update a team membership. Access is only available to administrators leaders of the associated team. -// @description **Access policy**: administrator +// @description Update a team membership. Access is only available to administrators or leaders of the associated team. +// @description **Access policy**: administrator or leaders of the associated team // @tags team_memberships // @security ApiKeyAuth // @security jwt @@ -63,15 +63,6 @@ func (handler *Handler) teamMembershipUpdate(w http.ResponseWriter, r *http.Requ return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} - } - - if !security.AuthorizedTeamManagement(portainer.TeamID(payload.TeamID), securityContext) { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update the membership", httperrors.ErrResourceAccessDenied} - } - membership, err := handler.DataStore.TeamMembership().TeamMembership(portainer.TeamMembershipID(membershipID)) if handler.DataStore.IsErrObjectNotFound(err) { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team membership with the specified identifier inside the database", err} @@ -79,8 +70,15 @@ func (handler *Handler) teamMembershipUpdate(w http.ResponseWriter, r *http.Requ return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team membership with the specified identifier inside the database", err} } - if securityContext.IsTeamLeader && membership.Role != portainer.MembershipRole(payload.Role) { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update the role of membership", httperrors.ErrResourceAccessDenied} + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + isLeadingBothTeam := security.AuthorizedTeamManagement(portainer.TeamID(payload.TeamID), securityContext) && + security.AuthorizedTeamManagement(membership.TeamID, securityContext) + if !(securityContext.IsAdmin || isLeadingBothTeam) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update the membership", httperrors.ErrResourceAccessDenied} } membership.UserID = portainer.UserID(payload.UserID) diff --git a/api/http/handler/teams/handler.go b/api/http/handler/teams/handler.go index 3965af470..7bf2bc3a7 100644 --- a/api/http/handler/teams/handler.go +++ b/api/http/handler/teams/handler.go @@ -20,18 +20,19 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { h := &Handler{ Router: mux.NewRouter(), } - h.Handle("/teams", - bouncer.AdminAccess(httperror.LoggerHandler(h.teamCreate))).Methods(http.MethodPost) - h.Handle("/teams", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.teamList))).Methods(http.MethodGet) - h.Handle("/teams/{id}", - bouncer.AdminAccess(httperror.LoggerHandler(h.teamInspect))).Methods(http.MethodGet) - h.Handle("/teams/{id}", - bouncer.AdminAccess(httperror.LoggerHandler(h.teamUpdate))).Methods(http.MethodPut) - h.Handle("/teams/{id}", - bouncer.AdminAccess(httperror.LoggerHandler(h.teamDelete))).Methods(http.MethodDelete) - h.Handle("/teams/{id}/memberships", - bouncer.AdminAccess(httperror.LoggerHandler(h.teamMemberships))).Methods(http.MethodGet) + + adminRouter := h.NewRoute().Subrouter() + adminRouter.Use(bouncer.AdminAccess) + + teamLeaderRouter := h.NewRoute().Subrouter() + teamLeaderRouter.Use(bouncer.TeamLeaderAccess) + + adminRouter.Handle("/teams", httperror.LoggerHandler(h.teamCreate)).Methods(http.MethodPost) + teamLeaderRouter.Handle("/teams", httperror.LoggerHandler(h.teamList)).Methods(http.MethodGet) + teamLeaderRouter.Handle("/teams/{id}", httperror.LoggerHandler(h.teamInspect)).Methods(http.MethodGet) + adminRouter.Handle("/teams/{id}", httperror.LoggerHandler(h.teamUpdate)).Methods(http.MethodPut) + adminRouter.Handle("/teams/{id}", httperror.LoggerHandler(h.teamDelete)).Methods(http.MethodDelete) + teamLeaderRouter.Handle("/teams/{id}/memberships", httperror.LoggerHandler(h.teamMemberships)).Methods(http.MethodGet) return h } diff --git a/api/http/handler/teams/team_create.go b/api/http/handler/teams/team_create.go index 62c1ad869..6f6035e24 100644 --- a/api/http/handler/teams/team_create.go +++ b/api/http/handler/teams/team_create.go @@ -14,6 +14,8 @@ import ( type teamCreatePayload struct { // Name Name string `example:"developers" validate:"required"` + // TeamLeaders + TeamLeaders []portainer.UserID `example:"3,5"` } func (payload *teamCreatePayload) Validate(r *http.Request) error { @@ -62,5 +64,18 @@ func (handler *Handler) teamCreate(w http.ResponseWriter, r *http.Request) *http return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the team inside the database", err} } + for _, teamLeader := range payload.TeamLeaders { + membership := &portainer.TeamMembership{ + UserID: teamLeader, + TeamID: team.ID, + Role: portainer.TeamLeader, + } + + err = handler.DataStore.TeamMembership().Create(membership) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist team leadership inside the database", err} + } + } + return response.JSON(w, team) } diff --git a/api/http/handler/users/handler.go b/api/http/handler/users/handler.go index e55055609..03cf3fd9e 100644 --- a/api/http/handler/users/handler.go +++ b/api/http/handler/users/handler.go @@ -48,30 +48,34 @@ func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimi demoService: demoService, passwordStrengthChecker: passwordStrengthChecker, } - h.Handle("/users", - bouncer.AdminAccess(httperror.LoggerHandler(h.userCreate))).Methods(http.MethodPost) - h.Handle("/users", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.userList))).Methods(http.MethodGet) - h.Handle("/users/{id}", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.userInspect))).Methods(http.MethodGet) - h.Handle("/users/{id}", - bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.userUpdate))).Methods(http.MethodPut) - h.Handle("/users/{id}", - bouncer.AdminAccess(httperror.LoggerHandler(h.userDelete))).Methods(http.MethodDelete) - h.Handle("/users/{id}/tokens", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.userGetAccessTokens))).Methods(http.MethodGet) - h.Handle("/users/{id}/tokens", - rateLimiter.LimitAccess(bouncer.RestrictedAccess(httperror.LoggerHandler(h.userCreateAccessToken)))).Methods(http.MethodPost) - h.Handle("/users/{id}/tokens/{keyID}", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.userRemoveAccessToken))).Methods(http.MethodDelete) - h.Handle("/users/{id}/memberships", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.userMemberships))).Methods(http.MethodGet) - h.Handle("/users/{id}/passwd", - rateLimiter.LimitAccess(bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.userUpdatePassword)))).Methods(http.MethodPut) - h.Handle("/users/admin/check", - bouncer.PublicAccess(httperror.LoggerHandler(h.adminCheck))).Methods(http.MethodGet) - h.Handle("/users/admin/init", - bouncer.PublicAccess(httperror.LoggerHandler(h.adminInit))).Methods(http.MethodPost) + + adminRouter := h.NewRoute().Subrouter() + adminRouter.Use(bouncer.AdminAccess) + + teamLeaderRouter := h.NewRoute().Subrouter() + teamLeaderRouter.Use(bouncer.TeamLeaderAccess) + + restrictedRouter := h.NewRoute().Subrouter() + restrictedRouter.Use(bouncer.RestrictedAccess) + + authenticatedRouter := h.NewRoute().Subrouter() + authenticatedRouter.Use(bouncer.AuthenticatedAccess) + + publicRouter := h.NewRoute().Subrouter() + publicRouter.Use(bouncer.PublicAccess) + + adminRouter.Handle("/users", httperror.LoggerHandler(h.userCreate)).Methods(http.MethodPost) + restrictedRouter.Handle("/users", httperror.LoggerHandler(h.userList)).Methods(http.MethodGet) + restrictedRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userInspect)).Methods(http.MethodGet) + authenticatedRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userUpdate)).Methods(http.MethodPut) + adminRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userDelete)).Methods(http.MethodDelete) + restrictedRouter.Handle("/users/{id}/tokens", httperror.LoggerHandler(h.userGetAccessTokens)).Methods(http.MethodGet) + restrictedRouter.Handle("/users/{id}/tokens", rateLimiter.LimitAccess(httperror.LoggerHandler(h.userCreateAccessToken))).Methods(http.MethodPost) + restrictedRouter.Handle("/users/{id}/tokens/{keyID}", httperror.LoggerHandler(h.userRemoveAccessToken)).Methods(http.MethodDelete) + restrictedRouter.Handle("/users/{id}/memberships", httperror.LoggerHandler(h.userMemberships)).Methods(http.MethodGet) + authenticatedRouter.Handle("/users/{id}/passwd", rateLimiter.LimitAccess(httperror.LoggerHandler(h.userUpdatePassword))).Methods(http.MethodPut) + publicRouter.Handle("/users/admin/check", httperror.LoggerHandler(h.adminCheck)).Methods(http.MethodGet) + publicRouter.Handle("/users/admin/init", httperror.LoggerHandler(h.adminInit)).Methods(http.MethodPost) return h } diff --git a/api/http/handler/users/user_create.go b/api/http/handler/users/user_create.go index 555f0333a..f9395b804 100644 --- a/api/http/handler/users/user_create.go +++ b/api/http/handler/users/user_create.go @@ -34,8 +34,7 @@ func (payload *userCreatePayload) Validate(r *http.Request) error { // @id UserCreate // @summary Create a new user // @description Create a new Portainer user. -// @description Only team leaders and administrators can create users. -// @description Only administrators can create an administrator user account. +// @description Only administrators can create users. // @description **Access policy**: restricted // @tags users // @security ApiKeyAuth @@ -56,19 +55,6 @@ func (handler *Handler) userCreate(w http.ResponseWriter, r *http.Request) *http return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} - } - - if !securityContext.IsAdmin && !securityContext.IsTeamLeader { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to create user", httperrors.ErrResourceAccessDenied} - } - - if securityContext.IsTeamLeader && payload.Role == 1 { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to create administrator user", httperrors.ErrResourceAccessDenied} - } - user, err := handler.DataStore.User().UserByUsername(payload.Username) if err != nil && !handler.DataStore.IsErrObjectNotFound(err) { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve users from the database", err} diff --git a/api/http/security/authorization.go b/api/http/security/authorization.go index 5be89aad3..52c70b8ef 100644 --- a/api/http/security/authorization.go +++ b/api/http/security/authorization.go @@ -103,6 +103,16 @@ func AuthorizedTeamManagement(teamID portainer.TeamID, context *RestrictedReques return false } +// AuthorizedIsTeamLeader ensure that the user is an admin or a team leader +func AuthorizedIsTeamLeader(context *RestrictedRequestContext) bool { + return context.IsAdmin || context.IsTeamLeader +} + +// AuthorizedIsAdmin ensure that the user is an admin +func AuthorizedIsAdmin(context *RestrictedRequestContext) bool { + return context.IsAdmin +} + // authorizedEndpointAccess ensure that the user can access the specified environment(endpoint). // It will check if the user is part of the authorized users or part of a team that is // listed in the authorized teams of the environment(endpoint) and the associated group. diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index dfd3d2dc0..233b73107 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -78,6 +78,19 @@ func (bouncer *RequestBouncer) RestrictedAccess(h http.Handler) http.Handler { return h } +// TeamLeaderAccess defines a security check for APIs require team leader privilege +// +// Bouncer operations are applied backwards: +// - Parse the JWT from the request and stored in context, user has to be authenticated +// - Upgrade to the restricted request +// - User is admin or team leader +func (bouncer *RequestBouncer) TeamLeaderAccess(h http.Handler) http.Handler { + h = bouncer.mwIsTeamLeader(h) + h = bouncer.mwUpgradeToRestrictedRequest(h) + h = bouncer.mwAuthenticatedUser(h) + return h +} + // AuthenticatedAccess defines a security check for restricted API environments(endpoints). // Authentication is required to access these environments(endpoints). // The request context will be enhanced with a RestrictedRequestContext object @@ -219,6 +232,24 @@ func (bouncer *RequestBouncer) mwUpgradeToRestrictedRequest(next http.Handler) h }) } +// mwIsTeamLeader will verify that the user is an admin or a team leader +func (bouncer *RequestBouncer) mwIsTeamLeader(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + securityContext, err := RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve restricted request context ", err) + return + } + + if !securityContext.IsAdmin && !securityContext.IsTeamLeader { + httperror.WriteError(w, http.StatusForbidden, "Access denied", httperrors.ErrUnauthorized) + return + } + + next.ServeHTTP(w, r) + }) +} + // mwAuthenticateFirst authenticates a request an auth token. // A result of a first succeded token lookup would be used for the authentication. func (bouncer *RequestBouncer) mwAuthenticateFirst(tokenLookups []tokenLookup, next http.Handler) http.Handler { diff --git a/api/http/security/filter.go b/api/http/security/filter.go index a2593de80..bfe4a2a18 100644 --- a/api/http/security/filter.go +++ b/api/http/security/filter.go @@ -81,11 +81,11 @@ func FilterRegistries(registries []portainer.Registry, user *portainer.User, tea } // FilterEndpoints filters environments(endpoints) based on user role and team memberships. -// Non administrator users only have access to authorized environments(endpoints) (can be inherited via endpoint groups). +// Non administrator and non-team-leader only have access to authorized environments(endpoints) (can be inherited via endpoint groups). func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.EndpointGroup, context *RestrictedRequestContext) []portainer.Endpoint { filteredEndpoints := endpoints - if !context.IsAdmin { + if !context.IsAdmin && !context.IsTeamLeader { filteredEndpoints = make([]portainer.Endpoint, 0) for _, endpoint := range endpoints { @@ -101,11 +101,11 @@ func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.Endpoint } // FilterEndpointGroups filters environment(endpoint) groups based on user role and team memberships. -// Non administrator users only have access to authorized environment(endpoint) groups. +// Non administrator users and Non-team-leaders only have access to authorized environment(endpoint) groups. func FilterEndpointGroups(endpointGroups []portainer.EndpointGroup, context *RestrictedRequestContext) []portainer.EndpointGroup { filteredEndpointGroups := endpointGroups - if !context.IsAdmin { + if !context.IsAdmin && !context.IsTeamLeader { filteredEndpointGroups = make([]portainer.EndpointGroup, 0) for _, group := range endpointGroups { diff --git a/app/portainer/components/datatables/teams-datatable/teamsDatatable.html b/app/portainer/components/datatables/teams-datatable/teamsDatatable.html index 23339ac5d..421c1eb1f 100644 --- a/app/portainer/components/datatables/teams-datatable/teamsDatatable.html +++ b/app/portainer/components/datatables/teams-datatable/teamsDatatable.html @@ -4,7 +4,7 @@