feat(settings): introduce disable stack management setting (#4100)

* feat(stacks): add a setting to disable the creation of stacks for non-admin users

* feat(settings): introduce a setting to prevent non-admin from stack creation

* feat(settings): update stack creation setting

* feat(settings): fail stack creation if user is non admin

* fix(settings): save preventStackCreation setting to state

* feat(stacks): disable add button when settings is enabled

* format(stacks): remove line

* feat(stacks): setting to hide stacks from users

* feat(settings): rename disable stacks setting

* refactor(settings): rename setting to disableStackManagementForRegularUsers

* feat(settings): hide stacks for non admin when settings is set

* refactor(settings): replace disableDeviceMapping with allow

* feat(dashboard): hide stacks if settings disabled and non admin

* refactor(sidebar): check if user is endpoint admin

* feat(settings): set the default value for stack management

* feat(settings): rename field label

* fix(sidebar): refresh show stacks state

* fix(docker): hide stacks when not admin
pull/4115/head
Chaim Lev-Ari 2020-07-27 10:11:32 +03:00 committed by GitHub
parent 07efd4bdda
commit fa9eeaf3b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 264 additions and 147 deletions

View File

@ -24,16 +24,17 @@ func (store *Store) Init() error {
portainer.LDAPGroupSearchSettings{}, portainer.LDAPGroupSearchSettings{},
}, },
}, },
OAuthSettings: portainer.OAuthSettings{}, OAuthSettings: portainer.OAuthSettings{},
AllowBindMountsForRegularUsers: true, AllowBindMountsForRegularUsers: true,
AllowPrivilegedModeForRegularUsers: true, AllowPrivilegedModeForRegularUsers: true,
AllowVolumeBrowserForRegularUsers: false, AllowVolumeBrowserForRegularUsers: false,
AllowHostNamespaceForRegularUsers: true, AllowHostNamespaceForRegularUsers: true,
AllowDeviceMappingForRegularUsers: true, AllowDeviceMappingForRegularUsers: true,
EnableHostManagementFeatures: false, AllowStackManagementForRegularUsers: true,
EdgeAgentCheckinInterval: portainer.DefaultEdgeAgentCheckinIntervalInSeconds, EnableHostManagementFeatures: false,
TemplatesURL: portainer.DefaultTemplatesURL, EdgeAgentCheckinInterval: portainer.DefaultEdgeAgentCheckinIntervalInSeconds,
UserSessionTimeout: portainer.DefaultUserSessionTimeout, TemplatesURL: portainer.DefaultTemplatesURL,
UserSessionTimeout: portainer.DefaultUserSessionTimeout,
} }
err = store.SettingsService.UpdateSettings(defaultSettings) err = store.SettingsService.UpdateSettings(defaultSettings)

View File

@ -8,6 +8,7 @@ func (m *Migrator) updateSettingsToDB24() error {
legacySettings.AllowHostNamespaceForRegularUsers = true legacySettings.AllowHostNamespaceForRegularUsers = true
legacySettings.AllowDeviceMappingForRegularUsers = true legacySettings.AllowDeviceMappingForRegularUsers = true
legacySettings.AllowStackManagementForRegularUsers = true
return m.settingsService.UpdateSettings(legacySettings) return m.settingsService.UpdateSettings(legacySettings)
} }

View File

@ -6,20 +6,21 @@ import (
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/response" "github.com/portainer/libhttp/response"
"github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
) )
type publicSettingsResponse struct { type publicSettingsResponse struct {
LogoURL string `json:"LogoURL"` LogoURL string `json:"LogoURL"`
AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"` AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"`
AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"` AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
AllowVolumeBrowserForRegularUsers bool `json:"AllowVolumeBrowserForRegularUsers"` AllowVolumeBrowserForRegularUsers bool `json:"AllowVolumeBrowserForRegularUsers"`
AllowHostNamespaceForRegularUsers bool `json:"AllowHostNamespaceForRegularUsers"` AllowHostNamespaceForRegularUsers bool `json:"AllowHostNamespaceForRegularUsers"`
AllowDeviceMappingForRegularUsers bool `json:"AllowDeviceMappingForRegularUsers"` AllowDeviceMappingForRegularUsers bool `json:"AllowDeviceMappingForRegularUsers"`
EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"` AllowStackManagementForRegularUsers bool `json:"AllowStackManagementForRegularUsers"`
EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures"` EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"`
OAuthLoginURI string `json:"OAuthLoginURI"` EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures"`
OAuthLoginURI string `json:"OAuthLoginURI"`
} }
// GET request on /api/settings/public // GET request on /api/settings/public
@ -30,15 +31,16 @@ func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) *
} }
publicSettings := &publicSettingsResponse{ publicSettings := &publicSettingsResponse{
LogoURL: settings.LogoURL, LogoURL: settings.LogoURL,
AuthenticationMethod: settings.AuthenticationMethod, AuthenticationMethod: settings.AuthenticationMethod,
AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers, AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers,
AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers, AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers,
AllowVolumeBrowserForRegularUsers: settings.AllowVolumeBrowserForRegularUsers, AllowVolumeBrowserForRegularUsers: settings.AllowVolumeBrowserForRegularUsers,
AllowHostNamespaceForRegularUsers: settings.AllowHostNamespaceForRegularUsers, AllowHostNamespaceForRegularUsers: settings.AllowHostNamespaceForRegularUsers,
AllowDeviceMappingForRegularUsers: settings.AllowDeviceMappingForRegularUsers, AllowDeviceMappingForRegularUsers: settings.AllowDeviceMappingForRegularUsers,
EnableHostManagementFeatures: settings.EnableHostManagementFeatures, AllowStackManagementForRegularUsers: settings.AllowStackManagementForRegularUsers,
EnableEdgeComputeFeatures: settings.EnableEdgeComputeFeatures, EnableHostManagementFeatures: settings.EnableHostManagementFeatures,
EnableEdgeComputeFeatures: settings.EnableEdgeComputeFeatures,
OAuthLoginURI: fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=%s&prompt=login", OAuthLoginURI: fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=%s&prompt=login",
settings.OAuthSettings.AuthorizationURI, settings.OAuthSettings.AuthorizationURI,
settings.OAuthSettings.ClientID, settings.OAuthSettings.ClientID,

View File

@ -9,28 +9,29 @@ import (
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response" "github.com/portainer/libhttp/response"
"github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/bolt/errors" bolterrors "github.com/portainer/portainer/api/bolt/errors"
"github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/filesystem"
) )
type settingsUpdatePayload struct { type settingsUpdatePayload struct {
LogoURL *string LogoURL *string
BlackListedLabels []portainer.Pair BlackListedLabels []portainer.Pair
AuthenticationMethod *int AuthenticationMethod *int
LDAPSettings *portainer.LDAPSettings LDAPSettings *portainer.LDAPSettings
OAuthSettings *portainer.OAuthSettings OAuthSettings *portainer.OAuthSettings
AllowBindMountsForRegularUsers *bool AllowBindMountsForRegularUsers *bool
AllowPrivilegedModeForRegularUsers *bool AllowPrivilegedModeForRegularUsers *bool
AllowHostNamespaceForRegularUsers *bool AllowHostNamespaceForRegularUsers *bool
AllowVolumeBrowserForRegularUsers *bool AllowVolumeBrowserForRegularUsers *bool
AllowDeviceMappingForRegularUsers *bool AllowDeviceMappingForRegularUsers *bool
EnableHostManagementFeatures *bool AllowStackManagementForRegularUsers *bool
SnapshotInterval *string EnableHostManagementFeatures *bool
TemplatesURL *string SnapshotInterval *string
EdgeAgentCheckinInterval *int TemplatesURL *string
EnableEdgeComputeFeatures *bool EdgeAgentCheckinInterval *int
UserSessionTimeout *string EnableEdgeComputeFeatures *bool
UserSessionTimeout *string
} }
func (payload *settingsUpdatePayload) Validate(r *http.Request) error { func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
@ -131,6 +132,10 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
settings.AllowHostNamespaceForRegularUsers = *payload.AllowHostNamespaceForRegularUsers settings.AllowHostNamespaceForRegularUsers = *payload.AllowHostNamespaceForRegularUsers
} }
if payload.AllowStackManagementForRegularUsers != nil {
settings.AllowStackManagementForRegularUsers = *payload.AllowStackManagementForRegularUsers
}
if payload.SnapshotInterval != nil && *payload.SnapshotInterval != settings.SnapshotInterval { if payload.SnapshotInterval != nil && *payload.SnapshotInterval != settings.SnapshotInterval {
err := handler.updateSnapshotInterval(settings, *payload.SnapshotInterval) err := handler.updateSnapshotInterval(settings, *payload.SnapshotInterval)
if err != nil { if err != nil {

View File

@ -58,8 +58,9 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
} }
func (handler *Handler) userCanAccessStack(securityContext *security.RestrictedRequestContext, endpointID portainer.EndpointID, resourceControl *portainer.ResourceControl) (bool, error) { func (handler *Handler) userCanAccessStack(securityContext *security.RestrictedRequestContext, endpointID portainer.EndpointID, resourceControl *portainer.ResourceControl) (bool, error) {
if securityContext.IsAdmin { user, err := handler.DataStore.User().User(securityContext.UserID)
return true, nil if err != nil {
return false, err
} }
userTeamIDs := make([]portainer.TeamID, 0) userTeamIDs := make([]portainer.TeamID, 0)
@ -71,23 +72,7 @@ func (handler *Handler) userCanAccessStack(securityContext *security.RestrictedR
return true, nil return true, nil
} }
_, err := handler.DataStore.Extension().Extension(portainer.RBACExtension) return handler.userIsAdminOrEndpointAdmin(user, endpointID)
if err == bolterrors.ErrObjectNotFound {
return false, nil
} else if err != nil && err != bolterrors.ErrObjectNotFound {
return false, err
}
user, err := handler.DataStore.User().User(securityContext.UserID)
if err != nil {
return false, err
}
_, ok := user.EndpointAuthorizations[endpointID][portainer.EndpointResourcesAccess]
if ok {
return true, nil
}
return false, nil
} }
func (handler *Handler) userIsAdminOrEndpointAdmin(user *portainer.User, endpointID portainer.EndpointID) (bool, error) { func (handler *Handler) userIsAdminOrEndpointAdmin(user *portainer.User, endpointID portainer.EndpointID) (bool, error) {
@ -109,3 +94,12 @@ func (handler *Handler) userIsAdminOrEndpointAdmin(user *portainer.User, endpoin
return endpointResourceAccess, nil return endpointResourceAccess, nil
} }
func (handler *Handler) userCanCreateStack(securityContext *security.RestrictedRequestContext, endpointID portainer.EndpointID) (bool, error) {
user, err := handler.DataStore.User().User(securityContext.UserID)
if err != nil {
return false, err
}
return handler.userIsAdminOrEndpointAdmin(user, endpointID)
}

View File

@ -46,6 +46,29 @@ func (handler *Handler) stackCreate(w http.ResponseWriter, r *http.Request) *htt
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err}
} }
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
}
if !settings.AllowStackManagementForRegularUsers {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user info from request context", err}
}
canCreate, err := handler.userCanCreateStack(securityContext, portainer.EndpointID(endpointID))
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack creation", err}
}
if !canCreate {
errMsg := "Stack creation is disabled for non-admin users"
return &httperror.HandlerError{http.StatusForbidden, errMsg, errors.New(errMsg)}
}
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err == bolterrors.ErrObjectNotFound { if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}

View File

@ -510,22 +510,23 @@ type (
// Settings represents the application settings // Settings represents the application settings
Settings struct { Settings struct {
LogoURL string `json:"LogoURL"` LogoURL string `json:"LogoURL"`
BlackListedLabels []Pair `json:"BlackListedLabels"` BlackListedLabels []Pair `json:"BlackListedLabels"`
AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod"` AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod"`
LDAPSettings LDAPSettings `json:"LDAPSettings"` LDAPSettings LDAPSettings `json:"LDAPSettings"`
OAuthSettings OAuthSettings `json:"OAuthSettings"` OAuthSettings OAuthSettings `json:"OAuthSettings"`
AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"` AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
AllowVolumeBrowserForRegularUsers bool `json:"AllowVolumeBrowserForRegularUsers"` AllowVolumeBrowserForRegularUsers bool `json:"AllowVolumeBrowserForRegularUsers"`
AllowHostNamespaceForRegularUsers bool `json:"AllowHostNamespaceForRegularUsers"` AllowHostNamespaceForRegularUsers bool `json:"AllowHostNamespaceForRegularUsers"`
AllowDeviceMappingForRegularUsers bool `json:"AllowDeviceMappingForRegularUsers"` AllowDeviceMappingForRegularUsers bool `json:"AllowDeviceMappingForRegularUsers"`
SnapshotInterval string `json:"SnapshotInterval"` AllowStackManagementForRegularUsers bool `json:"AllowStackManagementForRegularUsers"`
TemplatesURL string `json:"TemplatesURL"` SnapshotInterval string `json:"SnapshotInterval"`
EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"` TemplatesURL string `json:"TemplatesURL"`
EdgeAgentCheckinInterval int `json:"EdgeAgentCheckinInterval"` EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"`
EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures"` EdgeAgentCheckinInterval int `json:"EdgeAgentCheckinInterval"`
UserSessionTimeout string `json:"UserSessionTimeout"` EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures"`
UserSessionTimeout string `json:"UserSessionTimeout"`
// Deprecated fields // Deprecated fields
DisplayDonationHeader bool DisplayDonationHeader bool

View File

@ -9,5 +9,6 @@ angular.module('portainer.docker').component('dockerSidebarContent', {
toggle: '<', toggle: '<',
currentRouteName: '<', currentRouteName: '<',
endpointId: '<', endpointId: '<',
showStacks: '<',
}, },
}); });

View File

@ -8,7 +8,7 @@
<a ui-sref="docker.templates.custom({endpointId: $ctrl.endpointId})" ui-sref-active="active">Custom Templates</a> <a ui-sref="docker.templates.custom({endpointId: $ctrl.endpointId})" ui-sref-active="active">Custom Templates</a>
</div> </div>
</li> </li>
<li class="sidebar-list"> <li class="sidebar-list" ng-if="$ctrl.showStacks">
<a ui-sref="docker.stacks({endpointId: $ctrl.endpointId})" ui-sref-active="active">Stacks <span class="menu-icon fa fa-th-list fa-fw"></span></a> <a ui-sref="docker.stacks({endpointId: $ctrl.endpointId})" ui-sref-active="active">Stacks <span class="menu-icon fa fa-th-list fa-fw"></span></a>
</li> </li>
<li class="sidebar-list" ng-if="$ctrl.swarmManagement"> <li class="sidebar-list" ng-if="$ctrl.swarmManagement">

View File

@ -81,7 +81,7 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-6"> <div class="col-xs-12 col-md-6" ng-if="showStacks">
<a ui-sref="docker.stacks"> <a ui-sref="docker.stacks">
<rd-widget> <rd-widget>
<rd-widget-body> <rd-widget-body>

View File

@ -1,6 +1,7 @@
angular.module('portainer.docker').controller('DashboardController', [ angular.module('portainer.docker').controller('DashboardController', [
'$scope', '$scope',
'$q', '$q',
'Authentication',
'ContainerService', 'ContainerService',
'ImageService', 'ImageService',
'NetworkService', 'NetworkService',
@ -11,10 +12,12 @@ angular.module('portainer.docker').controller('DashboardController', [
'EndpointService', 'EndpointService',
'Notifications', 'Notifications',
'EndpointProvider', 'EndpointProvider',
'ExtensionService',
'StateManager', 'StateManager',
function ( function (
$scope, $scope,
$q, $q,
Authentication,
ContainerService, ContainerService,
ImageService, ImageService,
NetworkService, NetworkService,
@ -25,6 +28,7 @@ angular.module('portainer.docker').controller('DashboardController', [
EndpointService, EndpointService,
Notifications, Notifications,
EndpointProvider, EndpointProvider,
ExtensionService,
StateManager StateManager
) { ) {
$scope.dismissInformationPanel = function (id) { $scope.dismissInformationPanel = function (id) {
@ -32,12 +36,15 @@ angular.module('portainer.docker').controller('DashboardController', [
}; };
$scope.offlineMode = false; $scope.offlineMode = false;
$scope.showStacks = false;
function initView() { async function initView() {
const endpointMode = $scope.applicationState.endpoint.mode; const endpointMode = $scope.applicationState.endpoint.mode;
const endpointId = EndpointProvider.endpointID(); const endpointId = EndpointProvider.endpointID();
$scope.endpointId = endpointId; $scope.endpointId = endpointId;
$scope.showStacks = await shouldShowStacks();
$q.all({ $q.all({
containers: ContainerService.containers(1), containers: ContainerService.containers(1),
images: ImageService.images(false), images: ImageService.images(false),
@ -64,6 +71,19 @@ angular.module('portainer.docker').controller('DashboardController', [
}); });
} }
async function shouldShowStacks() {
const isAdmin = Authentication.isAdmin();
const { allowStackManagementForRegularUsers } = $scope.applicationState.application;
if (isAdmin || allowStackManagementForRegularUsers) {
return true;
}
const rbacEnabled = await ExtensionService.extensionEnabled(ExtensionService.EXTENSIONS.RBAC);
if (rbacEnabled) {
return Authentication.hasAuthorizations(['EndpointResourcesAccess']);
}
}
initView(); initView();
}, },
]); ]);

View File

@ -2,7 +2,7 @@
<rd-widget> <rd-widget>
<rd-widget-body classes="no-padding"> <rd-widget-body classes="no-padding">
<div class="toolBar"> <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 class="toolBarTitle"><i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} </div>
<div class="settings"> <div class="settings">
<span class="setting" ng-class="{ 'setting-active': $ctrl.settings.open }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.settings.open"> <span class="setting" ng-class="{ 'setting-active': $ctrl.settings.open }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.settings.open">
<span uib-dropdown-toggle><i class="fa fa-cog" aria-hidden="true"></i> Settings</span> <span uib-dropdown-toggle><i class="fa fa-cog" aria-hidden="true"></i> Settings</span>
@ -52,7 +52,7 @@
> >
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove <i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
</button> </button>
<button type="button" class="btn btn-sm btn-primary" ui-sref="docker.stacks.newstack" authorization="PortainerStackCreate"> <button ng-disabled="!$ctrl.createEnabled" type="button" class="btn btn-sm btn-primary" ui-sref="docker.stacks.newstack" authorization="PortainerStackCreate">
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add stack <i class="fa fa-plus space-right" aria-hidden="true"></i>Add stack
</button> </button>
</div> </div>
@ -144,7 +144,10 @@
</table> </table>
</div> </div>
<div class="footer" ng-if="$ctrl.dataset"> <div class="footer" ng-if="$ctrl.dataset">
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0"> {{ $ctrl.state.selectedItemCount }} item(s) selected </div> <div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0">
{{ $ctrl.state.selectedItemCount }}
item(s) selected
</div>
<div class="paginationControls"> <div class="paginationControls">
<form class="form-inline"> <form class="form-inline">
<span class="limitSelector"> <span class="limitSelector">

View File

@ -11,5 +11,6 @@ angular.module('portainer.app').component('stacksDatatable', {
removeAction: '<', removeAction: '<',
offlineMode: '<', offlineMode: '<',
refreshCallback: '<', refreshCallback: '<',
createEnabled: '<',
}, },
}); });

View File

@ -9,6 +9,7 @@ export function SettingsViewModel(data) {
this.AllowVolumeBrowserForRegularUsers = data.AllowVolumeBrowserForRegularUsers; this.AllowVolumeBrowserForRegularUsers = data.AllowVolumeBrowserForRegularUsers;
this.AllowHostNamespaceForRegularUsers = data.AllowHostNamespaceForRegularUsers; this.AllowHostNamespaceForRegularUsers = data.AllowHostNamespaceForRegularUsers;
this.AllowDeviceMappingForRegularUsers = data.AllowDeviceMappingForRegularUsers; this.AllowDeviceMappingForRegularUsers = data.AllowDeviceMappingForRegularUsers;
this.AllowStackManagementForRegularUsers = data.AllowStackManagementForRegularUsers;
this.SnapshotInterval = data.SnapshotInterval; this.SnapshotInterval = data.SnapshotInterval;
this.TemplatesURL = data.TemplatesURL; this.TemplatesURL = data.TemplatesURL;
this.EnableHostManagementFeatures = data.EnableHostManagementFeatures; this.EnableHostManagementFeatures = data.EnableHostManagementFeatures;
@ -21,12 +22,13 @@ export function PublicSettingsViewModel(settings) {
this.AllowBindMountsForRegularUsers = settings.AllowBindMountsForRegularUsers; this.AllowBindMountsForRegularUsers = settings.AllowBindMountsForRegularUsers;
this.AllowPrivilegedModeForRegularUsers = settings.AllowPrivilegedModeForRegularUsers; this.AllowPrivilegedModeForRegularUsers = settings.AllowPrivilegedModeForRegularUsers;
this.AllowVolumeBrowserForRegularUsers = settings.AllowVolumeBrowserForRegularUsers; this.AllowVolumeBrowserForRegularUsers = settings.AllowVolumeBrowserForRegularUsers;
this.AllowDeviceMappingForRegularUsers = settings.AllowDeviceMappingForRegularUsers;
this.AllowStackManagementForRegularUsers = settings.AllowStackManagementForRegularUsers;
this.AuthenticationMethod = settings.AuthenticationMethod; this.AuthenticationMethod = settings.AuthenticationMethod;
this.EnableHostManagementFeatures = settings.EnableHostManagementFeatures; this.EnableHostManagementFeatures = settings.EnableHostManagementFeatures;
this.EnableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures; this.EnableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures;
this.LogoURL = settings.LogoURL; this.LogoURL = settings.LogoURL;
this.OAuthLoginURI = settings.OAuthLoginURI; this.OAuthLoginURI = settings.OAuthLoginURI;
this.AllowDeviceMappingForRegularUsers = settings.AllowDeviceMappingForRegularUsers;
} }
export function LDAPSettingsViewModel(data) { export function LDAPSettingsViewModel(data) {

View File

@ -86,6 +86,11 @@ angular.module('portainer.app').factory('StateManager', [
LocalStorage.storeApplicationState(state.application); LocalStorage.storeApplicationState(state.application);
}; };
manager.updateAllowStackManagementForRegularUsers = function updateAllowStackManagementForRegularUsers(allowStackManagementForRegularUsers) {
state.application.allowStackManagementForRegularUsers = allowStackManagementForRegularUsers;
LocalStorage.storeApplicationState(state.application);
};
function assignStateFromStatusAndSettings(status, settings) { function assignStateFromStatusAndSettings(status, settings) {
state.application.analytics = status.Analytics; state.application.analytics = status.Analytics;
state.application.version = status.Version; state.application.version = status.Version;
@ -95,6 +100,7 @@ angular.module('portainer.app').factory('StateManager', [
state.application.enableVolumeBrowserForNonAdminUsers = settings.AllowVolumeBrowserForRegularUsers; state.application.enableVolumeBrowserForNonAdminUsers = settings.AllowVolumeBrowserForRegularUsers;
state.application.enableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures; state.application.enableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures;
state.application.allowDeviceMappingForRegularUsers = settings.AllowDeviceMappingForRegularUsers; state.application.allowDeviceMappingForRegularUsers = settings.AllowDeviceMappingForRegularUsers;
state.application.allowStackManagementForRegularUsers = settings.AllowStackManagementForRegularUsers;
state.application.validity = moment().unix(); state.application.validity = moment().unix();
} }

View File

@ -116,6 +116,16 @@
</label> </label>
<label class="switch" style="margin-left: 20px;"> <label class="switch" style="margin-left: 20px;">
<input type="checkbox" name="toggle_allowHostNamespaceForRegularUsers" ng-model="formValues.restrictHostNamespaceForRegularUsers" /><i></i> <input type="checkbox" name="toggle_allowHostNamespaceForRegularUsers" ng-model="formValues.restrictHostNamespaceForRegularUsers" /><i></i>
</label>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<label for="toggle_disableStackManagementForRegularUsers" class="control-label text-left">
Disable the use of Stacks for non-administrators
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" name="toggle_disableStackManagementForRegularUsers" ng-model="formValues.disableStackManagementForRegularUsers" /><i></i>
</label> </label>
</div> </div>
</div> </div>

View File

@ -34,6 +34,7 @@ angular.module('portainer.app').controller('SettingsController', [
enableEdgeComputeFeatures: false, enableEdgeComputeFeatures: false,
restrictHostNamespaceForRegularUsers: false, restrictHostNamespaceForRegularUsers: false,
allowDeviceMappingForRegularUsers: false, allowDeviceMappingForRegularUsers: false,
allowStackManagementForRegularUsers: false,
}; };
$scope.removeFilteredContainerLabel = function (index) { $scope.removeFilteredContainerLabel = function (index) {
@ -68,6 +69,7 @@ angular.module('portainer.app').controller('SettingsController', [
settings.EnableEdgeComputeFeatures = $scope.formValues.enableEdgeComputeFeatures; settings.EnableEdgeComputeFeatures = $scope.formValues.enableEdgeComputeFeatures;
settings.AllowHostNamespaceForRegularUsers = !$scope.formValues.restrictHostNamespaceForRegularUsers; settings.AllowHostNamespaceForRegularUsers = !$scope.formValues.restrictHostNamespaceForRegularUsers;
settings.AllowDeviceMappingForRegularUsers = !$scope.formValues.disableDeviceMappingForRegularUsers; settings.AllowDeviceMappingForRegularUsers = !$scope.formValues.disableDeviceMappingForRegularUsers;
settings.AllowStackManagementForRegularUsers = !$scope.formValues.disableStackManagementForRegularUsers;
$scope.state.actionInProgress = true; $scope.state.actionInProgress = true;
updateSettings(settings); updateSettings(settings);
@ -84,6 +86,7 @@ angular.module('portainer.app').controller('SettingsController', [
StateManager.updateAllowHostNamespaceForRegularUsers(settings.AllowHostNamespaceForRegularUsers); StateManager.updateAllowHostNamespaceForRegularUsers(settings.AllowHostNamespaceForRegularUsers);
StateManager.updateEnableEdgeComputeFeatures(settings.EnableEdgeComputeFeatures); StateManager.updateEnableEdgeComputeFeatures(settings.EnableEdgeComputeFeatures);
StateManager.updateAllowDeviceMappingForRegularUsers(settings.AllowDeviceMappingForRegularUsers); StateManager.updateAllowDeviceMappingForRegularUsers(settings.AllowDeviceMappingForRegularUsers);
StateManager.updateAllowStackManagementForRegularUsers(settings.AllowStackManagementForRegularUsers);
$state.reload(); $state.reload();
}) })
.catch(function error(err) { .catch(function error(err) {
@ -110,6 +113,7 @@ angular.module('portainer.app').controller('SettingsController', [
$scope.formValues.enableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures; $scope.formValues.enableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures;
$scope.formValues.restrictHostNamespaceForRegularUsers = !settings.AllowHostNamespaceForRegularUsers; $scope.formValues.restrictHostNamespaceForRegularUsers = !settings.AllowHostNamespaceForRegularUsers;
$scope.formValues.disableDeviceMappingForRegularUsers = !settings.AllowDeviceMappingForRegularUsers; $scope.formValues.disableDeviceMappingForRegularUsers = !settings.AllowDeviceMappingForRegularUsers;
$scope.formValues.disableStackManagementForRegularUsers = !settings.AllowStackManagementForRegularUsers;
}) })
.catch(function error(err) { .catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve application settings'); Notifications.error('Failure', err, 'Unable to retrieve application settings');

View File

@ -24,6 +24,7 @@
ng-if="applicationState.endpoint.mode && applicationState.endpoint.mode.provider !== 'AZURE' && applicationState.endpoint.mode.provider !== 'KUBERNETES'" ng-if="applicationState.endpoint.mode && applicationState.endpoint.mode.provider !== 'AZURE' && applicationState.endpoint.mode.provider !== 'KUBERNETES'"
current-route-name="$state.current.name" current-route-name="$state.current.name"
toggle="toggle" toggle="toggle"
show-stacks="showStacks"
endpoint-api-version="applicationState.endpoint.apiVersion" endpoint-api-version="applicationState.endpoint.apiVersion"
swarm-management="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'" swarm-management="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'"
standalone-management="applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE'" standalone-management="applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE'"

View File

@ -7,7 +7,8 @@ angular.module('portainer.app').controller('SidebarController', [
'Authentication', 'Authentication',
'UserService', 'UserService',
'EndpointProvider', 'EndpointProvider',
function ($q, $scope, $transitions, StateManager, Notifications, Authentication, UserService, EndpointProvider) { 'ExtensionService',
function ($q, $scope, $transitions, StateManager, Notifications, Authentication, UserService, EndpointProvider, ExtensionService) {
function checkPermissions(memberships) { function checkPermissions(memberships) {
var isLeader = false; var isLeader = false;
angular.forEach(memberships, function (membership) { angular.forEach(memberships, function (membership) {
@ -18,9 +19,10 @@ angular.module('portainer.app').controller('SidebarController', [
$scope.isTeamLeader = isLeader; $scope.isTeamLeader = isLeader;
} }
function initView() { async function initView() {
$scope.uiVersion = StateManager.getState().application.version; $scope.uiVersion = StateManager.getState().application.version;
$scope.logo = StateManager.getState().application.logo; $scope.logo = StateManager.getState().application.logo;
$scope.showStacks = await shouldShowStacks();
let userDetails = Authentication.getUserDetails(); let userDetails = Authentication.getUserDetails();
let isAdmin = Authentication.isAdmin(); let isAdmin = Authentication.isAdmin();
@ -41,5 +43,24 @@ angular.module('portainer.app').controller('SidebarController', [
} }
initView(); initView();
async function shouldShowStacks() {
const isAdmin = Authentication.isAdmin();
const { allowStackManagementForRegularUsers } = $scope.applicationState.application;
if (isAdmin || allowStackManagementForRegularUsers) {
return true;
}
const rbacEnabled = await ExtensionService.extensionEnabled(ExtensionService.EXTENSIONS.RBAC);
if (rbacEnabled) {
return Authentication.hasAuthorizations(['EndpointResourcesAccess']);
}
return false;
}
$transitions.onEnter({}, async () => {
$scope.showStacks = await shouldShowStacks();
});
}, },
]); ]);

View File

@ -18,6 +18,7 @@
remove-action="removeAction" remove-action="removeAction"
offline-mode="offlineMode" offline-mode="offlineMode"
refresh-callback="getStacks" refresh-callback="getStacks"
create-enabled="createEnabled"
></stacks-datatable> ></stacks-datatable>
</div> </div>
</div> </div>

View File

@ -1,65 +1,85 @@
angular.module('portainer.app').controller('StacksController', [ angular.module('portainer.app').controller('StacksController', StacksController);
'$scope',
'$state',
'Notifications',
'StackService',
'ModalService',
'EndpointProvider',
function ($scope, $state, Notifications, StackService, ModalService, EndpointProvider) {
$scope.removeAction = function (selectedItems) {
ModalService.confirmDeletion('Do you want to remove the selected stack(s)? Associated services will be removed as well.', function onConfirm(confirmed) {
if (!confirmed) {
return;
}
deleteSelectedStacks(selectedItems);
});
};
function deleteSelectedStacks(stacks) { /* @ngInject */
var endpointId = EndpointProvider.endpointID(); function StacksController($scope, $state, Notifications, StackService, ModalService, EndpointProvider, Authentication, StateManager, ExtensionService) {
var actionCount = stacks.length; $scope.removeAction = function (selectedItems) {
angular.forEach(stacks, function (stack) { ModalService.confirmDeletion('Do you want to remove the selected stack(s)? Associated services will be removed as well.', function onConfirm(confirmed) {
StackService.remove(stack, stack.External, endpointId) if (!confirmed) {
.then(function success() { return;
Notifications.success('Stack successfully removed', stack.Name); }
var index = $scope.stacks.indexOf(stack); deleteSelectedStacks(selectedItems);
$scope.stacks.splice(index, 1); });
}) };
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove stack ' + stack.Name);
})
.finally(function final() {
--actionCount;
if (actionCount === 0) {
$state.reload();
}
});
});
}
$scope.offlineMode = false; function deleteSelectedStacks(stacks) {
var endpointId = EndpointProvider.endpointID();
$scope.getStacks = getStacks; var actionCount = stacks.length;
function getStacks() { angular.forEach(stacks, function (stack) {
var endpointMode = $scope.applicationState.endpoint.mode; StackService.remove(stack, stack.External, endpointId)
var endpointId = EndpointProvider.endpointID(); .then(function success() {
Notifications.success('Stack successfully removed', stack.Name);
StackService.stacks(true, endpointMode.provider === 'DOCKER_SWARM_MODE' && endpointMode.role === 'MANAGER', endpointId) var index = $scope.stacks.indexOf(stack);
.then(function success(data) { $scope.stacks.splice(index, 1);
var stacks = data;
$scope.stacks = stacks;
$scope.offlineMode = EndpointProvider.offlineMode();
}) })
.catch(function error(err) { .catch(function error(err) {
$scope.stacks = []; Notifications.error('Failure', err, 'Unable to remove stack ' + stack.Name);
Notifications.error('Failure', err, 'Unable to retrieve stacks'); })
.finally(function final() {
--actionCount;
if (actionCount === 0) {
$state.reload();
}
}); });
});
}
$scope.offlineMode = false;
$scope.createEnabled = false;
$scope.getStacks = getStacks;
function getStacks() {
var endpointMode = $scope.applicationState.endpoint.mode;
var endpointId = EndpointProvider.endpointID();
StackService.stacks(true, endpointMode.provider === 'DOCKER_SWARM_MODE' && endpointMode.role === 'MANAGER', endpointId)
.then(function success(data) {
var stacks = data;
$scope.stacks = stacks;
$scope.offlineMode = EndpointProvider.offlineMode();
})
.catch(function error(err) {
$scope.stacks = [];
Notifications.error('Failure', err, 'Unable to retrieve stacks');
});
}
async function loadCreateEnabled() {
const appState = StateManager.getState().application;
if (appState.allowStackManagementForRegularUsers) {
return true;
} }
function initView() { let isAdmin = true;
getStacks(); if (appState.authentication) {
isAdmin = Authentication.isAdmin();
}
if (isAdmin) {
return true;
} }
initView(); const RBACExtensionEnabled = await ExtensionService.extensionEnabled(ExtensionService.EXTENSIONS.RBAC);
}, if (!RBACExtensionEnabled) {
]); return false;
}
return Authentication.hasAuthorizations(['EndpointResourcesAccess']);
}
async function initView() {
getStacks();
$scope.createEnabled = await loadCreateEnabled();
}
initView();
}