feat(teamleader) EE-294 redesign team leader (#6973)

feat(teamleader) EE-294 redesign team leader (#6973)
pull/7014/head
congs 2022-06-03 16:44:42 +12:00 committed by GitHub
parent bca1c6b9cf
commit 0522032515
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 223 additions and 135 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@
<div class="toolBar">
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px"></i> {{ $ctrl.titleText }} </div>
</div>
<div class="actionBar">
<div class="actionBar" ng-show="$ctrl.isAdmin">
<button type="button" class="btn btn-sm btn-danger" ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.removeAction($ctrl.state.selectedItems)">
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
</button>
@ -25,7 +25,7 @@
<thead>
<tr>
<th>
<span class="md-checkbox">
<span class="md-checkbox" ng-show="$ctrl.isAdmin">
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" />
<label for="select_all"></label>
</span>
@ -43,7 +43,7 @@
ng-class="{ active: item.Checked }"
>
<td>
<span class="md-checkbox">
<span class="md-checkbox" ng-show="$ctrl.isAdmin">
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-click="$ctrl.selectItem(item, $event)" />
<label for="select_{{ $index }}"></label>
</span>

View File

@ -9,5 +9,6 @@ angular.module('portainer.app').component('teamsDatatable', {
orderBy: '@',
reverseOrder: '<',
removeAction: '<',
isAdmin: '<',
},
});

View File

@ -4,7 +4,7 @@
<div class="toolBar">
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px"></i> {{ $ctrl.titleText }} </div>
</div>
<div class="actionBar">
<div class="actionBar" ng-show="$ctrl.isAdmin">
<button
type="button"
class="btn btn-sm btn-danger"
@ -31,7 +31,7 @@
<thead>
<tr>
<th>
<span class="md-checkbox">
<span class="md-checkbox" ng-show="$ctrl.isAdmin">
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" />
<label for="select_all"></label>
</span>
@ -63,11 +63,12 @@
ng-class="{ active: item.Checked }"
>
<td>
<span class="md-checkbox">
<span class="md-checkbox" ng-show="$ctrl.isAdmin">
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-click="$ctrl.selectItem(item, $event)" ng-disabled="!$ctrl.allowSelection(item)" />
<label for="select_{{ $index }}"></label>
</span>
<a ui-sref="portainer.users.user({id: item.Id})">{{ item.Username }}</a>
<a ui-sref="portainer.users.user({id: item.Id})" ng-show="$ctrl.isAdmin">{{ item.Username }}</a>
<span ng-show="!$ctrl.isAdmin">{{ item.Username }}</span>
</td>
<td>
<span>

View File

@ -13,5 +13,6 @@ angular.module('portainer.app').component('usersDatatable', {
reverseOrder: '<',
removeAction: '<',
authenticationMethod: '<',
isAdmin: '<',
},
});

View File

@ -24,6 +24,7 @@ export function SettingsViewModel(data) {
export function PublicSettingsViewModel(settings) {
this.AuthenticationMethod = settings.AuthenticationMethod;
this.TeamSync = settings.TeamSync;
this.RequiredPasswordLength = settings.RequiredPasswordLength;
this.EnableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures;
this.EnforceEdgeID = settings.EnforceEdgeID;

View File

@ -33,10 +33,10 @@
<td
>{{ item.TeamName ? 'Team' : 'User' }} <code ng-if="item.TeamName">{{ item.TeamName }}</code> access defined on {{ item.AccessLocation }}
<code ng-if="item.GroupName">{{ item.GroupName }}</code>
<a ng-if="!item.GroupName" ui-sref="portainer.endpoints.endpoint.access({id: item.EndpointId})"
<a ng-if="!item.GroupName && $ctrl.isAdmin" ui-sref="portainer.endpoints.endpoint.access({id: item.EndpointId})"
><i style="margin-left: 5px" class="fa fa-users" aria-hidden="true"></i> Manage access
</a>
<a ng-if="item.GroupName" ui-sref="portainer.groups.group.access({id: item.GroupId})"
<a ng-if="item.GroupName && $ctrl.isAdmin" ui-sref="portainer.groups.group.access({id: item.GroupId})"
><i style="margin-left: 5px" class="fa fa-users" aria-hidden="true"></i> Manage access
</a>
</td>

View File

@ -7,5 +7,6 @@ export const accessViewerDatatable = {
tableKey: '@',
orderBy: '@',
dataset: '<',
isAdmin: '<',
},
};

View File

@ -5,7 +5,7 @@ import AccessViewerPolicyModel from '../../models/access';
export default class AccessViewerController {
/* @ngInject */
constructor(Notifications, RoleService, UserService, EndpointService, GroupService, TeamService, TeamMembershipService) {
constructor(Notifications, RoleService, UserService, EndpointService, GroupService, TeamService, TeamMembershipService, Authentication) {
this.Notifications = Notifications;
this.RoleService = RoleService;
this.UserService = UserService;
@ -13,6 +13,7 @@ export default class AccessViewerController {
this.GroupService = GroupService;
this.TeamService = TeamService;
this.TeamMembershipService = TeamMembershipService;
this.Authentication = Authentication;
this.limitedFeature = 'rbac-roles';
this.users = [];
@ -100,6 +101,33 @@ export default class AccessViewerController {
return this.findLowestRole(policyRoles);
}
// for admin, returns all users
// for team leader, only return all his/her team member users
async teamMemberUsers(users, teamMemberships) {
if (this.isAdmin) {
return users;
}
const filteredUsers = [];
const userId = this.Authentication.getUserDetails().ID;
const leadingTeams = await this.UserService.userLeadingTeams(userId);
const isMember = (userId, teamId) => {
return !!_.find(teamMemberships, { UserId: userId, TeamId: teamId });
};
for (const user of users) {
for (const leadingTeam of leadingTeams) {
if (isMember(user.Id, leadingTeam.Id)) {
filteredUsers.push(user);
break;
}
}
}
return filteredUsers;
}
async $onInit() {
try {
const limitedToBE = isLimitedToBE(this.limitedFeature);
@ -108,7 +136,8 @@ export default class AccessViewerController {
return;
}
this.users = await this.UserService.users();
this.isAdmin = this.Authentication.isAdmin();
this.allUsers = await this.UserService.users();
this.endpoints = _.keyBy((await this.EndpointService.endpoints()).value, 'Id');
const groups = await this.GroupService.groups();
this.groupUserAccessPolicies = {};
@ -121,6 +150,7 @@ export default class AccessViewerController {
this.roles = _.keyBy(await this.RoleService.roles(), 'Id');
this.teams = _.keyBy(await this.TeamService.teams(), 'Id');
this.teamMemberships = await this.TeamMembershipService.memberships();
this.users = await this.teamMemberUsers(this.allUsers, this.teamMemberships);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve accesses');
}

View File

@ -30,7 +30,7 @@
Effective role for each environment will be displayed for the selected user
</div>
</div>
<access-viewer-datatable table-key="access_viewer" dataset="$ctrl.userRoles" order-by="EndpointName"> </access-viewer-datatable>
<access-viewer-datatable table-key="access_viewer" dataset="$ctrl.userRoles" order-by="EndpointName" is-admin="$ctrl.isAdmin"> </access-viewer-datatable>
</form>
</rd-widget-body>
</rd-widget>

View File

@ -4,8 +4,7 @@ import { TeamMembershipModel } from '../../models/teamMembership';
angular.module('portainer.app').factory('TeamService', [
'$q',
'Teams',
'TeamMembershipService',
function TeamServiceFactory($q, Teams, TeamMembershipService) {
function TeamServiceFactory($q, Teams) {
'use strict';
var service = {};
@ -41,17 +40,11 @@ angular.module('portainer.app').factory('TeamService', [
var deferred = $q.defer();
var payload = {
Name: name,
TeamLeaders: leaderIds,
};
Teams.create({}, payload)
.$promise.then(function success(data) {
var teamId = data.Id;
var teamMembershipQueries = [];
angular.forEach(leaderIds, function (userId) {
teamMembershipQueries.push(TeamMembershipService.createMembership(userId, teamId, 1));
});
$q.all(teamMembershipQueries).then(function success() {
deferred.resolve();
});
.$promise.then(function success() {
deferred.resolve();
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to create team', err: err });

View File

@ -54,7 +54,7 @@
>
</sidebar-section>
<sidebar-section ng-if="isAdmin || isTeamLeader" title="Settings">
<sidebar-section ng-if="showUsersSection" title="Settings">
<sidebar-menu
ng-show="display"
icon-class="fa-users fa-fw"

View File

@ -1,6 +1,6 @@
angular.module('portainer.app').controller('SidebarController', SidebarController);
function SidebarController($rootScope, $scope, $transitions, StateManager, Notifications, Authentication, UserService, EndpointProvider) {
function SidebarController($rootScope, $scope, $transitions, StateManager, Notifications, Authentication, UserService, EndpointProvider, SettingsService) {
$scope.applicationState = StateManager.getState();
$scope.endpointState = EndpointProvider.endpoint();
$scope.display = !window.ddExtension;
@ -29,11 +29,14 @@ function SidebarController($rootScope, $scope, $transitions, StateManager, Notif
const userDetails = Authentication.getUserDetails();
const isAdmin = isClusterAdmin();
$scope.isAdmin = isAdmin;
$scope.showUsersSection = isAdmin;
if (!isAdmin) {
try {
const memberships = await UserService.userMemberships(userDetails.ID);
checkPermissions(memberships);
const settings = await SettingsService.publicSettings();
$scope.showUsersSection = $scope.isTeamLeader && !settings.TeamSync;
} catch (err) {
Notifications.error('Failure', err, 'Unable to retrieve user memberships');
}

View File

@ -36,6 +36,13 @@
</div>
</div>
<div class="row" ng-if="team && settings.TeamSync">
<div class="col-sm-12 text-muted">
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px"></i>
The team leader feature is disabled as external authentication is currently enabled with team sync.
</div>
</div>
<div class="row" ng-if="team">
<div class="col-sm-6">
<rd-widget>
@ -53,7 +60,7 @@
</rd-widget-header>
<rd-widget-taskbar classes="col-sm-12 nopadding">
<div class="col-sm-12 col-md-6 nopadding">
<button class="btn btn-primary btn-sm" ng-click="addAllUsers()" ng-if="isAdmin" ng-disabled="users.length === 0 || filteredUsers.length === 0"
<button class="btn btn-primary btn-sm" ng-click="addAllUsers()" ng-if="isAdmin" ng-disabled="users.length === 0 || filteredUsers.length === 0 || settings.TeamSync"
><i class="fa fa-user-plus space-right" aria-hidden="true"></i>Add all users</button
>
</div>
@ -83,7 +90,7 @@
<td>
{{ user.Username }}
<span style="margin-left: 5px">
<a ng-click="addUser(user)"><i class="fa fa-plus-circle space-right" aria-hidden="true"></i>Add</a>
<a ng-click="addUser(user)" ng-class="{ 'btn disabled py-0': settings.TeamSync }"> <i class="fa fa-plus-circle space-right" aria-hidden="true"></i>Add </a>
</span>
</td>
</tr>
@ -118,7 +125,11 @@
</rd-widget-header>
<rd-widget-taskbar classes="col-sm-12 nopadding">
<div class="col-sm-12 col-md-6 nopadding">
<button class="btn btn-primary btn-sm" ng-click="removeAllUsers()" ng-if="isAdmin" ng-disabled="teamMembers.length === 0 || filteredGroupMembers.length === 0"
<button
class="btn btn-primary btn-sm"
ng-click="removeAllUsers()"
ng-if="isAdmin"
ng-disabled="teamMembers.length === 0 || filteredGroupMembers.length === 0 || settings.TeamSync"
><i class="fa fa-user-times space-right" aria-hidden="true"></i>Remove all users</button
>
</div>
@ -155,7 +166,9 @@
<td>
{{ user.Username }}
<span style="margin-left: 5px" ng-if="isAdmin || user.TeamRole === 'Member'">
<a ng-click="removeUser(user)"><i class="fa fa-minus-circle space-right" aria-hidden="true"></i>Remove</a>
<a ng-click="removeUser(user)" ng-class="{ 'btn disabled py-0': settings.TeamSync }">
<i class="fa fa-minus-circle space-right" aria-hidden="true"></i>Remove
</a>
</span>
</td>
<td>
@ -163,10 +176,10 @@
<i ng-if="user.TeamRole === 'Member'" class="fa fa-user" aria-hidden="true" style="margin-right: 2px"></i>
{{ user.TeamRole }}
<span style="margin-left: 5px" ng-if="isAdmin">
<a style="margin-left: 5px" ng-click="promoteToLeader(user)" ng-if="user.TeamRole === 'Member'"
<a style="margin-left: 5px" ng-click="promoteToLeader(user)" ng-if="user.TeamRole === 'Member'" ng-class="{ 'btn disabled py-0': settings.TeamSync }"
><i class="fa fa-user-plus space-right" aria-hidden="true"></i>Leader</a
>
<a style="margin-left: 5px" ng-click="demoteToMember(user)" ng-if="user.TeamRole === 'Leader'"
<a style="margin-left: 5px" ng-click="demoteToMember(user)" ng-if="user.TeamRole === 'Leader'" ng-class="{ 'btn disabled py-0': settings.TeamSync }"
><i class="fa fa-user-times space-right" aria-hidden="true"></i>Member</a
>
</span>

View File

@ -10,7 +10,8 @@ angular.module('portainer.app').controller('TeamController', [
'Notifications',
'PaginationService',
'Authentication',
function ($q, $scope, $state, $transition$, TeamService, UserService, TeamMembershipService, ModalService, Notifications, PaginationService, Authentication) {
'SettingsService',
function ($q, $scope, $state, $transition$, TeamService, UserService, TeamMembershipService, ModalService, Notifications, PaginationService, Authentication, SettingsService) {
$scope.state = {
pagination_count_users: PaginationService.getPaginationLimit('team_available_users'),
pagination_count_members: PaginationService.getPaginationLimit('team_members'),
@ -189,21 +190,23 @@ angular.module('portainer.app').controller('TeamController', [
}
}
function initView() {
async function initView() {
$scope.isAdmin = Authentication.isAdmin();
$q.all({
team: TeamService.team($transition$.params().id),
users: UserService.users(false),
memberships: TeamService.userMemberships($transition$.params().id),
})
.then(function success(data) {
var users = data.users;
$scope.team = data.team;
assignUsersAndMembers(users, data.memberships);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve team details');
try {
$scope.settings = await SettingsService.publicSettings();
const data = await $q.all({
team: TeamService.team($transition$.params().id),
users: UserService.users($scope.isAdmin && $scope.settings.TeamSync),
memberships: TeamService.userMemberships($transition$.params().id),
});
$scope.team = data.team;
assignUsersAndMembers(data.users, data.memberships);
} catch (err) {
Notifications.error('Failure', err, 'Unable to retrieve team details');
}
}
initView();

View File

@ -11,6 +11,6 @@
<div class="row">
<div class="col-sm-12">
<teams-datatable title-text="Teams" title-icon="fa-users" dataset="teams" table-key="teams" order-by="Name" remove-action="removeAction"></teams-datatable>
<teams-datatable title-text="Teams" title-icon="fa-users" dataset="teams" table-key="teams" order-by="Name" remove-action="removeAction" is-admin="isAdmin"></teams-datatable>
</div>
</div>

View File

@ -89,6 +89,7 @@ angular.module('portainer.app').controller('TeamsController', [
var teams = data.teams;
$scope.teams = teams;
$scope.users = _.orderBy(data.users, 'Username', 'asc');
$scope.isTeamLeader = !!teams.length;
})
.catch(function error(err) {
$scope.teams = [];

View File

@ -7,7 +7,7 @@
<rd-header-content>User management</rd-header-content>
</rd-header>
<div class="row">
<div class="row" ng-if="isAdmin">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-plus" title-text="Add a new user"> </rd-widget-header>
@ -169,6 +169,7 @@
order-by="Username"
authentication-method="AuthenticationMethod"
remove-action="removeAction"
is-admin="isAdmin"
></users-datatable>
</div>
</div>