From b537a9ad0d2f2b5345144c0ded539ee0786dbf45 Mon Sep 17 00:00:00 2001 From: xAt0mZ Date: Fri, 26 Jul 2019 18:17:44 +0200 Subject: [PATCH 01/49] fix(oauth): okta support --- app/portainer/services/localStorage.js | 6 +++++ app/portainer/views/auth/authController.js | 30 ++++++++++++++++++---- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/app/portainer/services/localStorage.js b/app/portainer/services/localStorage.js index 134eaff60..d50369710 100644 --- a/app/portainer/services/localStorage.js +++ b/app/portainer/services/localStorage.js @@ -14,6 +14,12 @@ angular.module('portainer.app') getEndpointPublicURL: function() { return localStorageService.get('ENDPOINT_PUBLIC_URL'); }, + storeLoginStateUUID: function(uuid) { + localStorageService.set('LOGIN_STATE_UUID', uuid); + }, + getLoginStateUUID: function() { + return localStorageService.get('LOGIN_STATE_UUID'); + }, storeOfflineMode: function(isOffline) { localStorageService.set('ENDPOINT_OFFLINE_MODE', isOffline); }, diff --git a/app/portainer/views/auth/authController.js b/app/portainer/views/auth/authController.js index 120de69af..f9c22f4d3 100644 --- a/app/portainer/views/auth/authController.js +++ b/app/portainer/views/auth/authController.js @@ -1,6 +1,8 @@ +import uuidv4 from 'uuid/v4'; + angular.module('portainer.app') -.controller('AuthenticationController', ['$async', '$q', '$scope', '$state', '$stateParams', '$sanitize', 'Authentication', 'UserService', 'EndpointService', 'ExtensionService', 'StateManager', 'Notifications', 'SettingsService', 'URLHelper', -function($async, $q, $scope, $state, $stateParams, $sanitize, Authentication, UserService, EndpointService, ExtensionService, StateManager, Notifications, SettingsService, URLHelper) { +.controller('AuthenticationController', ['$async', '$q', '$scope', '$state', '$stateParams', '$sanitize', 'Authentication', 'UserService', 'EndpointService', 'ExtensionService', 'StateManager', 'Notifications', 'SettingsService', 'URLHelper', 'LocalStorage', +function($async, $q, $scope, $state, $stateParams, $sanitize, Authentication, UserService, EndpointService, ExtensionService, StateManager, Notifications, SettingsService, URLHelper, LocalStorage) { $scope.logo = StateManager.getState().application.logo; $scope.formValues = { @@ -116,12 +118,29 @@ function($async, $q, $scope, $state, $stateParams, $sanitize, Authentication, Us return 'OAuth'; } + function generateState() { + if ($scope.state.OAuthProvider !== 'OAuth') { + return ''; + } + const uuid = uuidv4(); + LocalStorage.storeLoginStateUUID(uuid); + return '&state=' + uuid; + } + + function hasValidState(state) { + if ($scope.state.OAuthProvider !== 'OAuth') { + return true; + } + const savedUUID = LocalStorage.getLoginStateUUID(); + return savedUUID === state; + } + function initView() { SettingsService.publicSettings() .then(function success(settings) { $scope.AuthenticationMethod = settings.AuthenticationMethod; - $scope.OAuthLoginURI = settings.OAuthLoginURI; $scope.state.OAuthProvider = determineOauthProvider(settings.OAuthLoginURI); + $scope.OAuthLoginURI = settings.OAuthLoginURI + generateState(); }); if ($stateParams.logout || $stateParams.error) { @@ -142,8 +161,9 @@ function($async, $q, $scope, $state, $stateParams, $sanitize, Authentication, Us authenticatedFlow(); } - var code = URLHelper.getParameter('code'); - if (code) { + const code = URLHelper.getParameter('code'); + const state = URLHelper.getParameter('state'); + if (code && hasValidState(state)) { oAuthLogin(code); } else { $scope.state.isInOAuthProcess = false; From a33dbd1e919e9b11b69cb735f81746db86f06d2f Mon Sep 17 00:00:00 2001 From: xAt0mZ Date: Fri, 26 Jul 2019 18:21:23 +0200 Subject: [PATCH 02/49] fix(oauth): state to follow OAuth 2 RFC against CSRF --- app/portainer/views/auth/authController.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/app/portainer/views/auth/authController.js b/app/portainer/views/auth/authController.js index f9c22f4d3..7bd3295cb 100644 --- a/app/portainer/views/auth/authController.js +++ b/app/portainer/views/auth/authController.js @@ -119,18 +119,12 @@ function($async, $q, $scope, $state, $stateParams, $sanitize, Authentication, Us } function generateState() { - if ($scope.state.OAuthProvider !== 'OAuth') { - return ''; - } const uuid = uuidv4(); LocalStorage.storeLoginStateUUID(uuid); return '&state=' + uuid; } function hasValidState(state) { - if ($scope.state.OAuthProvider !== 'OAuth') { - return true; - } const savedUUID = LocalStorage.getLoginStateUUID(); return savedUUID === state; } From e11df28df658c44ea316efeb2080bc615fc61d61 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Sun, 28 Jul 2019 10:30:12 +1200 Subject: [PATCH 03/49] fix(api): fix missing windows dependency --- api/http/proxy/factory_local_windows.go | 1 + 1 file changed, 1 insertion(+) diff --git a/api/http/proxy/factory_local_windows.go b/api/http/proxy/factory_local_windows.go index 01b020cf8..c90d09643 100644 --- a/api/http/proxy/factory_local_windows.go +++ b/api/http/proxy/factory_local_windows.go @@ -3,6 +3,7 @@ package proxy import ( + "github.com/Microsoft/go-winio" "net" "net/http" From 3afeb138912beeab7ef1758baac27163ea18515c Mon Sep 17 00:00:00 2001 From: William Date: Mon, 12 Aug 2019 20:30:19 +1200 Subject: [PATCH 04/49] chore(project): adjust stalebot config (#3081) --- .github/stale.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/stale.yml b/.github/stale.yml index 9efe94673..85d558ed5 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -15,6 +15,7 @@ issues: - kind/feature - kind/question - kind/style + - kind/workaround - bug/need-confirmation - bug/confirmed - status/discuss From 24013bc5243c6936a92c0a045dda7f94c5eae688 Mon Sep 17 00:00:00 2001 From: xAt0mZ Date: Mon, 12 Aug 2019 16:25:35 +0200 Subject: [PATCH 05/49] fix(datatables): saved orderBy was always overridden by the default one (#3052) --- .../containers-datatable/containersDatatableController.js | 2 +- .../datatables/host-jobs-datatable/jobsDatatableController.js | 2 +- .../datatables/images-datatable/imagesDatatableController.js | 2 +- .../networks-datatable/networksDatatableController.js | 2 +- .../service-tasks-datatable/serviceTasksDatatableController.js | 2 +- .../services-datatable/servicesDatatableController.js | 2 +- .../datatables/tasks-datatable/tasksDatatableController.js | 2 +- .../datatables/volumes-datatable/volumesDatatableController.js | 2 +- .../drives-datatable/storidgeDrivesDatatableController.js | 2 +- .../nodes-datatable/storidgeNodesDatatableController.js | 2 +- .../components/access-datatable/accessDatatableController.js | 2 +- .../components/datatables/genericDatatableController.js | 2 +- .../schedules-datatable/schedulesDatatableController.js | 2 +- .../datatables/stacks-datatable/stacksDatatableController.js | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/app/docker/components/datatables/containers-datatable/containersDatatableController.js b/app/docker/components/datatables/containers-datatable/containersDatatableController.js index 929686a60..c95cc7d69 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatableController.js +++ b/app/docker/components/datatables/containers-datatable/containersDatatableController.js @@ -172,6 +172,7 @@ function ($scope, $controller, DatatableService, EndpointProvider) { this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -203,6 +204,5 @@ function ($scope, $controller, DatatableService, EndpointProvider) { this.columnVisibility = storedColumnVisibility; this.columnVisibility.state.open = false; } - this.state.orderBy = this.orderBy; }; }]); diff --git a/app/docker/components/datatables/host-jobs-datatable/jobsDatatableController.js b/app/docker/components/datatables/host-jobs-datatable/jobsDatatableController.js index 4acc5472a..c95cd9ef3 100644 --- a/app/docker/components/datatables/host-jobs-datatable/jobsDatatableController.js +++ b/app/docker/components/datatables/host-jobs-datatable/jobsDatatableController.js @@ -108,6 +108,7 @@ angular.module('portainer.docker') this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -135,7 +136,6 @@ angular.module('portainer.docker') this.settings.open = false; } this.onSettingsRepeaterChange(); - this.state.orderBy = this.orderBy; }; } ]); diff --git a/app/docker/components/datatables/images-datatable/imagesDatatableController.js b/app/docker/components/datatables/images-datatable/imagesDatatableController.js index 67bb932e2..55e96f36a 100644 --- a/app/docker/components/datatables/images-datatable/imagesDatatableController.js +++ b/app/docker/components/datatables/images-datatable/imagesDatatableController.js @@ -39,6 +39,7 @@ function ($scope, $controller, DatatableService) { this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -66,6 +67,5 @@ function ($scope, $controller, DatatableService) { } this.onSettingsRepeaterChange(); - this.state.orderBy = this.orderBy; }; }]); diff --git a/app/docker/components/datatables/networks-datatable/networksDatatableController.js b/app/docker/components/datatables/networks-datatable/networksDatatableController.js index 1fcaec634..f079bda46 100644 --- a/app/docker/components/datatables/networks-datatable/networksDatatableController.js +++ b/app/docker/components/datatables/networks-datatable/networksDatatableController.js @@ -19,6 +19,7 @@ angular.module('portainer.docker') this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -45,7 +46,6 @@ angular.module('portainer.docker') this.settings.open = false; } this.onSettingsRepeaterChange(); - this.state.orderBy = this.orderBy; }; } ]); \ No newline at end of file diff --git a/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatableController.js b/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatableController.js index 23f5c8e72..7e6afa667 100644 --- a/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatableController.js +++ b/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatableController.js @@ -61,6 +61,7 @@ angular.module('portainer.docker') this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -87,7 +88,6 @@ angular.module('portainer.docker') this.settings.open = false; } this.onSettingsRepeaterChange(); - this.state.orderBy = this.orderBy; }; } ]); diff --git a/app/docker/components/datatables/services-datatable/servicesDatatableController.js b/app/docker/components/datatables/services-datatable/servicesDatatableController.js index 13a4314e1..9289733e8 100644 --- a/app/docker/components/datatables/services-datatable/servicesDatatableController.js +++ b/app/docker/components/datatables/services-datatable/servicesDatatableController.js @@ -68,6 +68,7 @@ function ($scope, $controller, DatatableService, EndpointProvider) { this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -99,6 +100,5 @@ function ($scope, $controller, DatatableService, EndpointProvider) { this.settings.open = false; } this.onSettingsRepeaterChange(); - this.state.orderBy = this.orderBy; }; }]); diff --git a/app/docker/components/datatables/tasks-datatable/tasksDatatableController.js b/app/docker/components/datatables/tasks-datatable/tasksDatatableController.js index 0d8c4d1f2..ab2456b27 100644 --- a/app/docker/components/datatables/tasks-datatable/tasksDatatableController.js +++ b/app/docker/components/datatables/tasks-datatable/tasksDatatableController.js @@ -16,6 +16,7 @@ function ($scope, $controller, DatatableService) { this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -42,6 +43,5 @@ function ($scope, $controller, DatatableService) { this.settings.open = false; } this.onSettingsRepeaterChange(); - this.state.orderBy = this.orderBy; }; }]); diff --git a/app/docker/components/datatables/volumes-datatable/volumesDatatableController.js b/app/docker/components/datatables/volumes-datatable/volumesDatatableController.js index 68cc70055..5a49ea70d 100644 --- a/app/docker/components/datatables/volumes-datatable/volumesDatatableController.js +++ b/app/docker/components/datatables/volumes-datatable/volumesDatatableController.js @@ -39,6 +39,7 @@ function ($scope, $controller, DatatableService) { this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -65,6 +66,5 @@ function ($scope, $controller, DatatableService) { this.settings.open = false; } this.onSettingsRepeaterChange(); - this.state.orderBy = this.orderBy; }; }]); diff --git a/app/integrations/storidge/components/drives-datatable/storidgeDrivesDatatableController.js b/app/integrations/storidge/components/drives-datatable/storidgeDrivesDatatableController.js index 68348427b..5e0b6c33d 100644 --- a/app/integrations/storidge/components/drives-datatable/storidgeDrivesDatatableController.js +++ b/app/integrations/storidge/components/drives-datatable/storidgeDrivesDatatableController.js @@ -11,6 +11,7 @@ angular.module('portainer.docker') this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -37,7 +38,6 @@ angular.module('portainer.docker') this.settings.open = false; } this.onSettingsRepeaterChange(); - this.state.orderBy = this.orderBy; }; } ]); \ No newline at end of file diff --git a/app/integrations/storidge/components/nodes-datatable/storidgeNodesDatatableController.js b/app/integrations/storidge/components/nodes-datatable/storidgeNodesDatatableController.js index 61e8fec46..7a1d263af 100644 --- a/app/integrations/storidge/components/nodes-datatable/storidgeNodesDatatableController.js +++ b/app/integrations/storidge/components/nodes-datatable/storidgeNodesDatatableController.js @@ -25,6 +25,7 @@ function($scope, $controller, clipboard, Notifications, StoridgeNodeService, Dat this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -51,6 +52,5 @@ function($scope, $controller, clipboard, Notifications, StoridgeNodeService, Dat this.settings.open = false; } this.onSettingsRepeaterChange(); - this.state.orderBy = this.orderBy; }; }]); diff --git a/app/portainer/components/access-datatable/accessDatatableController.js b/app/portainer/components/access-datatable/accessDatatableController.js index 2ed056421..c53f26c27 100644 --- a/app/portainer/components/access-datatable/accessDatatableController.js +++ b/app/portainer/components/access-datatable/accessDatatableController.js @@ -15,6 +15,7 @@ angular.module('portainer.app') this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -41,7 +42,6 @@ angular.module('portainer.app') this.settings.open = false; } this.onSettingsRepeaterChange(); - this.state.orderBy = this.orderBy; }; } ]); \ No newline at end of file diff --git a/app/portainer/components/datatables/genericDatatableController.js b/app/portainer/components/datatables/genericDatatableController.js index 991869481..4658c03ea 100644 --- a/app/portainer/components/datatables/genericDatatableController.js +++ b/app/portainer/components/datatables/genericDatatableController.js @@ -119,6 +119,7 @@ function ($interval, PaginationService, DatatableService, PAGINATION_MAX_ITEMS) this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -145,7 +146,6 @@ function ($interval, PaginationService, DatatableService, PAGINATION_MAX_ITEMS) this.settings.open = false; } this.onSettingsRepeaterChange(); - this.state.orderBy = this.orderBy; }; /** diff --git a/app/portainer/components/datatables/schedules-datatable/schedulesDatatableController.js b/app/portainer/components/datatables/schedules-datatable/schedulesDatatableController.js index 9a61f74d9..cba684691 100644 --- a/app/portainer/components/datatables/schedules-datatable/schedulesDatatableController.js +++ b/app/portainer/components/datatables/schedules-datatable/schedulesDatatableController.js @@ -15,6 +15,7 @@ angular.module('portainer.app') this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -41,7 +42,6 @@ angular.module('portainer.app') this.settings.open = false; } this.onSettingsRepeaterChange(); - this.state.orderBy = this.orderBy; }; } ]); diff --git a/app/portainer/components/datatables/stacks-datatable/stacksDatatableController.js b/app/portainer/components/datatables/stacks-datatable/stacksDatatableController.js index 15b2a11b7..34e40387a 100644 --- a/app/portainer/components/datatables/stacks-datatable/stacksDatatableController.js +++ b/app/portainer/components/datatables/stacks-datatable/stacksDatatableController.js @@ -15,6 +15,7 @@ function ($scope, $controller, DatatableService) { this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -41,7 +42,6 @@ function ($scope, $controller, DatatableService) { this.settings.open = false; } this.onSettingsRepeaterChange(); - this.state.orderBy = this.orderBy; }; }]); From c12ce5a5c7fd8086dd8aaf4f5b00df6ac0b263de Mon Sep 17 00:00:00 2001 From: xAt0mZ Date: Mon, 12 Aug 2019 16:26:44 +0200 Subject: [PATCH 06/49] feat(networks): group networks for swarm endpoints (#3028) * feat(networks): group networks for swarm endpoints * fix(networks): display error on networks with 1 sub --- .../networkRowContent.html | 27 ++++++++++++ .../network-row-content/networkRowContent.js | 15 +++++++ .../networks-datatable/networksDatatable.html | 36 +++++---------- .../networksDatatableController.js | 27 ++++++++++++ .../views/networks/networksController.js | 44 ++++++++++++++++--- 5 files changed, 118 insertions(+), 31 deletions(-) create mode 100644 app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.html create mode 100644 app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.js diff --git a/app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.html b/app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.html new file mode 100644 index 000000000..691569467 --- /dev/null +++ b/app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.html @@ -0,0 +1,27 @@ + + + + + + + + + + {{ item.Name | truncate:40 }} + {{ item.Name | truncate:40 }} + +{{ item.StackName ? item.StackName : '-' }} +{{ item.Scope }} +{{ item.Driver }} +{{ item.Attachable }} +{{ item.Internal }} +{{ item.IPAM.Driver }} +{{ item.IPAM.Config[0].Subnet ? item.IPAM.Config[0].Subnet : '-' }} +{{ item.IPAM.Config[0].Gateway ? item.IPAM.Config[0].Gateway : '-' }} +{{ item.NodeName ? item.NodeName : '-' }} + + + + {{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = 'administrators' }} + + \ No newline at end of file diff --git a/app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.js b/app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.js new file mode 100644 index 000000000..90dc60c57 --- /dev/null +++ b/app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.js @@ -0,0 +1,15 @@ +angular.module('portainer.docker') +.directive('networkRowContent', [function networkRowContent() { + var directive = { + templateUrl: './networkRowContent.html', + restrict: 'A', + transclude: true, + scope: { + item: '<', + parentCtrl: '<', + allowCheckbox: '<', + allowExpand: '<' + } + }; + return directive; +}]); diff --git a/app/docker/components/datatables/networks-datatable/networksDatatable.html b/app/docker/components/datatables/networks-datatable/networksDatatable.html index c2347d9cd..fa6e478ac 100644 --- a/app/docker/components/datatables/networks-datatable/networksDatatable.html +++ b/app/docker/components/datatables/networks-datatable/networksDatatable.html @@ -61,11 +61,16 @@ - + - - - - - - - - - - - - + + + diff --git a/app/docker/components/datatables/networks-datatable/networksDatatableController.js b/app/docker/components/datatables/networks-datatable/networksDatatableController.js index f079bda46..7550111b4 100644 --- a/app/docker/components/datatables/networks-datatable/networksDatatableController.js +++ b/app/docker/components/datatables/networks-datatable/networksDatatableController.js @@ -1,3 +1,5 @@ +import _ from 'lodash-es'; + angular.module('portainer.docker') .controller('NetworksDatatableController', ['$scope', '$controller', 'PREDEFINED_NETWORKS', 'DatatableService', function ($scope, $controller, PREDEFINED_NETWORKS, DatatableService) { @@ -8,6 +10,10 @@ angular.module('portainer.docker') return PREDEFINED_NETWORKS.includes(item.Name); }; + this.state = Object.assign(this.state, { + expandedItems: [] + }) + /** * Do not allow PREDEFINED_NETWORKS to be selected */ @@ -47,5 +53,26 @@ angular.module('portainer.docker') } this.onSettingsRepeaterChange(); }; + + this.expandItem = function(item, expanded) { + item.Expanded = expanded; + }; + + this.itemCanExpand = function(item) { + return item.Subs.length > 0; + } + + this.hasExpandableItems = function() { + return _.filter(this.state.filteredDataSet, (item) => this.itemCanExpand(item)).length; + }; + + this.expandAll = function() { + this.state.expandAll = !this.state.expandAll; + _.forEach(this.state.filteredDataSet, (item) => { + if (this.itemCanExpand(item)) { + this.expandItem(item, this.state.expandAll); + } + }); + }; } ]); \ No newline at end of file diff --git a/app/docker/views/networks/networksController.js b/app/docker/views/networks/networksController.js index f2f79b395..6beb8da73 100644 --- a/app/docker/views/networks/networksController.js +++ b/app/docker/views/networks/networksController.js @@ -1,6 +1,8 @@ +import _ from 'lodash-es'; + angular.module('portainer.docker') -.controller('NetworksController', ['$scope', '$state', 'NetworkService', 'Notifications', 'HttpRequestHelper', 'EndpointProvider', -function ($scope, $state, NetworkService, Notifications, HttpRequestHelper, EndpointProvider) { +.controller('NetworksController', ['$q', '$scope', '$state', 'NetworkService', 'Notifications', 'HttpRequestHelper', 'EndpointProvider', 'AgentService', +function ($q, $scope, $state, NetworkService, Notifications, HttpRequestHelper, EndpointProvider, AgentService) { $scope.removeAction = function (selectedItems) { var actionCount = selectedItems.length; @@ -28,13 +30,43 @@ function ($scope, $state, NetworkService, Notifications, HttpRequestHelper, Endp $scope.getNetworks = getNetworks; + function groupSwarmNetworksManagerNodesFirst(networks, agents) { + const getRole = (item) => _.find(agents, (agent) => agent.NodeName === item.NodeName).NodeRole; + + const nonSwarmNetworks = _.remove(networks, (item) => item.Scope !== 'swarm') + const grouped = _.toArray(_.groupBy(networks, (item) => item.Id)); + const sorted = _.map(grouped, (arr) => _.sortBy(arr, (item) => getRole(item))); + const arr = _.map(sorted, (a) => { + const item = a[0]; + for (let i = 1; i < a.length; i++) { + item.Subs.push(a[i]); + } + return item; + }); + const res = _.concat(arr, ...nonSwarmNetworks); + return res; + } + function getNetworks() { - NetworkService.networks(true, true, true) - .then(function success(data) { - $scope.networks = data; + const req = { + networks: NetworkService.networks(true, true, true) + }; + + if ($scope.applicationState.endpoint.mode.agentProxy && $scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') { + req.agents = AgentService.agents(); + } + + $q.all(req) + .then((data) => { $scope.offlineMode = EndpointProvider.offlineMode(); + const networks = _.forEach(data.networks, (item) => item.Subs = []); + if ($scope.applicationState.endpoint.mode.agentProxy && $scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') { + $scope.networks = groupSwarmNetworksManagerNodesFirst(data.networks, data.agents); + } else { + $scope.networks = networks; + } }) - .catch(function error(err) { + .catch((err) => { $scope.networks = []; Notifications.error('Failure', err, 'Unable to retrieve networks'); }); From 96155ac97f0807106b4cc8e0bf2972b59bb2884e Mon Sep 17 00:00:00 2001 From: xAt0mZ Date: Mon, 12 Aug 2019 16:27:05 +0200 Subject: [PATCH 07/49] feat(app): debounce on all search fields (#3058) --- app/agent/components/files-datatable/files-datatable.html | 2 +- .../containergroups-datatable/containerGroupsDatatable.html | 2 +- .../datatables/configs-datatable/configsDatatable.html | 2 +- .../containerProcessesDatatable.html | 2 +- .../datatables/containers-datatable/containersDatatable.html | 2 +- .../components/datatables/events-datatable/eventsDatatable.html | 2 +- .../datatables/host-jobs-datatable/jobsDatatable.html | 2 +- .../components/datatables/images-datatable/imagesDatatable.html | 2 +- .../macvlan-nodes-datatable/macvlanNodesDatatable.html | 2 +- .../datatables/networks-datatable/networksDatatable.html | 2 +- .../datatables/node-tasks-datatable/nodeTasksDatatable.html | 2 +- .../components/datatables/nodes-datatable/nodesDatatable.html | 2 +- .../datatables/secrets-datatable/secretsDatatable.html | 2 +- .../datatables/services-datatable/servicesDatatable.html | 2 +- .../components/datatables/tasks-datatable/tasksDatatable.html | 2 +- .../datatables/volumes-datatable/volumesDatatable.html | 2 +- .../access-viewer/datatable/accessViewerDatatable.html | 2 +- .../rbac/components/roles-datatable/rolesDatatable.html | 2 +- .../registryRepositoriesDatatable.html | 2 +- .../registriesRepositoryTagsDatatable.html | 2 +- .../storidgeClusterEventsDatatable.html | 2 +- .../components/drives-datatable/storidgeDrivesDatatable.html | 2 +- .../components/nodes-datatable/storidgeNodesDatatable.html | 2 +- .../profiles-datatable/storidgeProfilesDatatable.html | 2 +- .../snapshots-datatable/storidgeSnapshotsDatatable.html | 2 +- app/portainer/components/access-datatable/accessDatatable.html | 2 +- app/portainer/components/access-table/accessTable.html | 2 +- .../components/datatables/groups-datatable/groupsDatatable.html | 2 +- .../datatables/registries-datatable/registriesDatatable.html | 2 +- .../schedule-tasks-datatable/scheduleTasksDatatable.html | 2 +- .../datatables/schedules-datatable/schedulesDatatable.html | 2 +- .../components/datatables/stacks-datatable/stacksDatatable.html | 2 +- .../components/datatables/tags-datatable/tagsDatatable.html | 2 +- .../components/datatables/teams-datatable/teamsDatatable.html | 2 +- .../components/datatables/users-datatable/usersDatatable.html | 2 +- app/portainer/components/template-list/templateList.html | 2 +- 36 files changed, 36 insertions(+), 36 deletions(-) diff --git a/app/agent/components/files-datatable/files-datatable.html b/app/agent/components/files-datatable/files-datatable.html index f3d7ba564..0cda7d450 100644 --- a/app/agent/components/files-datatable/files-datatable.html +++ b/app/agent/components/files-datatable/files-datatable.html @@ -7,7 +7,7 @@
diff --git a/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html index ae7a459ce..54361570c 100644 --- a/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html +++ b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html @@ -17,7 +17,7 @@
+ + + + + Name @@ -145,30 +150,11 @@
- - - - - {{ item.Name | truncate:40 }} - {{ item.Name | truncate:40 }} - {{ item.StackName ? item.StackName : '-' }}{{ item.Scope }}{{ item.Driver }}{{ item.Attachable }}{{ item.Internal }}{{ item.IPAM.Driver }}{{ item.IPAM.Config[0].Subnet ? item.IPAM.Config[0].Subnet : '-' }}{{ item.IPAM.Config[0].Gateway ? item.IPAM.Config[0].Gateway : '-' }}{{ item.NodeName ? item.NodeName : '-' }} - - - {{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = 'administrators' }} - -
Loading...
diff --git a/app/docker/components/datatables/configs-datatable/configsDatatable.html b/app/docker/components/datatables/configs-datatable/configsDatatable.html index 846fdbee4..31fd0cc67 100644 --- a/app/docker/components/datatables/configs-datatable/configsDatatable.html +++ b/app/docker/components/datatables/configs-datatable/configsDatatable.html @@ -55,7 +55,7 @@
diff --git a/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.html b/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.html index 5449c012d..37f3152b5 100644 --- a/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.html +++ b/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.html @@ -8,7 +8,7 @@
diff --git a/app/docker/components/datatables/containers-datatable/containersDatatable.html b/app/docker/components/datatables/containers-datatable/containersDatatable.html index 8e7ff7bb7..fb2a24cd8 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatable.html +++ b/app/docker/components/datatables/containers-datatable/containersDatatable.html @@ -136,7 +136,7 @@ >
diff --git a/app/docker/components/datatables/events-datatable/eventsDatatable.html b/app/docker/components/datatables/events-datatable/eventsDatatable.html index f70cbb9e7..377d97f7d 100644 --- a/app/docker/components/datatables/events-datatable/eventsDatatable.html +++ b/app/docker/components/datatables/events-datatable/eventsDatatable.html @@ -8,7 +8,7 @@
diff --git a/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html b/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html index 2e0ce6ffe..599ce8669 100644 --- a/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html +++ b/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html @@ -16,7 +16,7 @@
diff --git a/app/docker/components/datatables/images-datatable/imagesDatatable.html b/app/docker/components/datatables/images-datatable/imagesDatatable.html index 8a536f2ae..0fd576f66 100644 --- a/app/docker/components/datatables/images-datatable/imagesDatatable.html +++ b/app/docker/components/datatables/images-datatable/imagesDatatable.html @@ -75,7 +75,7 @@
diff --git a/app/docker/components/datatables/macvlan-nodes-datatable/macvlanNodesDatatable.html b/app/docker/components/datatables/macvlan-nodes-datatable/macvlanNodesDatatable.html index 574aa2fe6..334ecc6ac 100644 --- a/app/docker/components/datatables/macvlan-nodes-datatable/macvlanNodesDatatable.html +++ b/app/docker/components/datatables/macvlan-nodes-datatable/macvlanNodesDatatable.html @@ -8,7 +8,7 @@
diff --git a/app/docker/components/datatables/networks-datatable/networksDatatable.html b/app/docker/components/datatables/networks-datatable/networksDatatable.html index fa6e478ac..7dc2d199f 100644 --- a/app/docker/components/datatables/networks-datatable/networksDatatable.html +++ b/app/docker/components/datatables/networks-datatable/networksDatatable.html @@ -55,7 +55,7 @@
diff --git a/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.html b/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.html index ea87ad303..874b2facf 100644 --- a/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.html +++ b/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.html @@ -8,7 +8,7 @@
diff --git a/app/docker/components/datatables/nodes-datatable/nodesDatatable.html b/app/docker/components/datatables/nodes-datatable/nodesDatatable.html index 5e28c0b41..b49b20f29 100644 --- a/app/docker/components/datatables/nodes-datatable/nodesDatatable.html +++ b/app/docker/components/datatables/nodes-datatable/nodesDatatable.html @@ -46,7 +46,7 @@
diff --git a/app/docker/components/datatables/secrets-datatable/secretsDatatable.html b/app/docker/components/datatables/secrets-datatable/secretsDatatable.html index fb82e5c31..4f9916f53 100644 --- a/app/docker/components/datatables/secrets-datatable/secretsDatatable.html +++ b/app/docker/components/datatables/secrets-datatable/secretsDatatable.html @@ -55,7 +55,7 @@
diff --git a/app/docker/components/datatables/services-datatable/servicesDatatable.html b/app/docker/components/datatables/services-datatable/servicesDatatable.html index 9b3b86805..0952d091f 100644 --- a/app/docker/components/datatables/services-datatable/servicesDatatable.html +++ b/app/docker/components/datatables/services-datatable/servicesDatatable.html @@ -52,7 +52,7 @@ >
diff --git a/app/docker/components/datatables/tasks-datatable/tasksDatatable.html b/app/docker/components/datatables/tasks-datatable/tasksDatatable.html index 391852180..e71b8b805 100644 --- a/app/docker/components/datatables/tasks-datatable/tasksDatatable.html +++ b/app/docker/components/datatables/tasks-datatable/tasksDatatable.html @@ -8,7 +8,7 @@
diff --git a/app/docker/components/datatables/volumes-datatable/volumesDatatable.html b/app/docker/components/datatables/volumes-datatable/volumesDatatable.html index b1c3ff958..ca115011d 100644 --- a/app/docker/components/datatables/volumes-datatable/volumesDatatable.html +++ b/app/docker/components/datatables/volumes-datatable/volumesDatatable.html @@ -55,7 +55,7 @@
diff --git a/app/extensions/rbac/components/access-viewer/datatable/accessViewerDatatable.html b/app/extensions/rbac/components/access-viewer/datatable/accessViewerDatatable.html index 47f38fcfe..951388c3f 100644 --- a/app/extensions/rbac/components/access-viewer/datatable/accessViewerDatatable.html +++ b/app/extensions/rbac/components/access-viewer/datatable/accessViewerDatatable.html @@ -2,7 +2,7 @@
diff --git a/app/extensions/rbac/components/roles-datatable/rolesDatatable.html b/app/extensions/rbac/components/roles-datatable/rolesDatatable.html index 8da5671c4..ccbef5168 100644 --- a/app/extensions/rbac/components/roles-datatable/rolesDatatable.html +++ b/app/extensions/rbac/components/roles-datatable/rolesDatatable.html @@ -8,7 +8,7 @@
diff --git a/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html b/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html index 7ea3164b1..8f60ae379 100644 --- a/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html +++ b/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html @@ -8,7 +8,7 @@
diff --git a/app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.html b/app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.html index 9a02df286..0da259f37 100644 --- a/app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.html +++ b/app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.html @@ -14,7 +14,7 @@
diff --git a/app/integrations/storidge/components/cluster-events-datatable/storidgeClusterEventsDatatable.html b/app/integrations/storidge/components/cluster-events-datatable/storidgeClusterEventsDatatable.html index 2094f938b..905cc2779 100644 --- a/app/integrations/storidge/components/cluster-events-datatable/storidgeClusterEventsDatatable.html +++ b/app/integrations/storidge/components/cluster-events-datatable/storidgeClusterEventsDatatable.html @@ -8,7 +8,7 @@
diff --git a/app/integrations/storidge/components/drives-datatable/storidgeDrivesDatatable.html b/app/integrations/storidge/components/drives-datatable/storidgeDrivesDatatable.html index ed46b9a5d..2c47c1b72 100644 --- a/app/integrations/storidge/components/drives-datatable/storidgeDrivesDatatable.html +++ b/app/integrations/storidge/components/drives-datatable/storidgeDrivesDatatable.html @@ -13,7 +13,7 @@
diff --git a/app/integrations/storidge/components/nodes-datatable/storidgeNodesDatatable.html b/app/integrations/storidge/components/nodes-datatable/storidgeNodesDatatable.html index 4a8857844..f4453c640 100644 --- a/app/integrations/storidge/components/nodes-datatable/storidgeNodesDatatable.html +++ b/app/integrations/storidge/components/nodes-datatable/storidgeNodesDatatable.html @@ -27,7 +27,7 @@
diff --git a/app/integrations/storidge/components/profiles-datatable/storidgeProfilesDatatable.html b/app/integrations/storidge/components/profiles-datatable/storidgeProfilesDatatable.html index 36147404f..c1a074bd3 100644 --- a/app/integrations/storidge/components/profiles-datatable/storidgeProfilesDatatable.html +++ b/app/integrations/storidge/components/profiles-datatable/storidgeProfilesDatatable.html @@ -14,7 +14,7 @@
diff --git a/app/integrations/storidge/components/snapshots-datatable/storidgeSnapshotsDatatable.html b/app/integrations/storidge/components/snapshots-datatable/storidgeSnapshotsDatatable.html index 915e07280..78696c88b 100644 --- a/app/integrations/storidge/components/snapshots-datatable/storidgeSnapshotsDatatable.html +++ b/app/integrations/storidge/components/snapshots-datatable/storidgeSnapshotsDatatable.html @@ -14,7 +14,7 @@
diff --git a/app/portainer/components/access-datatable/accessDatatable.html b/app/portainer/components/access-datatable/accessDatatable.html index 2d33f779e..8368580ff 100644 --- a/app/portainer/components/access-datatable/accessDatatable.html +++ b/app/portainer/components/access-datatable/accessDatatable.html @@ -23,7 +23,7 @@
diff --git a/app/portainer/components/access-table/accessTable.html b/app/portainer/components/access-table/accessTable.html index a69147435..0664d1c48 100644 --- a/app/portainer/components/access-table/accessTable.html +++ b/app/portainer/components/access-table/accessTable.html @@ -2,7 +2,7 @@
- +
diff --git a/app/portainer/components/datatables/groups-datatable/groupsDatatable.html b/app/portainer/components/datatables/groups-datatable/groupsDatatable.html index 64a63c413..0deec20f0 100644 --- a/app/portainer/components/datatables/groups-datatable/groupsDatatable.html +++ b/app/portainer/components/datatables/groups-datatable/groupsDatatable.html @@ -17,7 +17,7 @@
diff --git a/app/portainer/components/datatables/registries-datatable/registriesDatatable.html b/app/portainer/components/datatables/registries-datatable/registriesDatatable.html index 45ebc13a8..9952686fb 100644 --- a/app/portainer/components/datatables/registries-datatable/registriesDatatable.html +++ b/app/portainer/components/datatables/registries-datatable/registriesDatatable.html @@ -17,7 +17,7 @@
diff --git a/app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.html b/app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.html index 00b395086..c278e7bd9 100644 --- a/app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.html +++ b/app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.html @@ -11,7 +11,7 @@
diff --git a/app/portainer/components/datatables/schedules-datatable/schedulesDatatable.html b/app/portainer/components/datatables/schedules-datatable/schedulesDatatable.html index 86134c34e..035d3706b 100644 --- a/app/portainer/components/datatables/schedules-datatable/schedulesDatatable.html +++ b/app/portainer/components/datatables/schedules-datatable/schedulesDatatable.html @@ -17,7 +17,7 @@
diff --git a/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html b/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html index 480888746..43f6104c6 100644 --- a/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html +++ b/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html @@ -55,7 +55,7 @@
diff --git a/app/portainer/components/datatables/tags-datatable/tagsDatatable.html b/app/portainer/components/datatables/tags-datatable/tagsDatatable.html index a34d8b8a2..c42a46638 100644 --- a/app/portainer/components/datatables/tags-datatable/tagsDatatable.html +++ b/app/portainer/components/datatables/tags-datatable/tagsDatatable.html @@ -14,7 +14,7 @@
diff --git a/app/portainer/components/datatables/teams-datatable/teamsDatatable.html b/app/portainer/components/datatables/teams-datatable/teamsDatatable.html index 268526737..9906e979d 100644 --- a/app/portainer/components/datatables/teams-datatable/teamsDatatable.html +++ b/app/portainer/components/datatables/teams-datatable/teamsDatatable.html @@ -14,7 +14,7 @@
diff --git a/app/portainer/components/datatables/users-datatable/usersDatatable.html b/app/portainer/components/datatables/users-datatable/usersDatatable.html index 74ce4b5b0..9b92819a7 100644 --- a/app/portainer/components/datatables/users-datatable/usersDatatable.html +++ b/app/portainer/components/datatables/users-datatable/usersDatatable.html @@ -14,7 +14,7 @@
diff --git a/app/portainer/components/template-list/templateList.html b/app/portainer/components/template-list/templateList.html index 1f3d818a2..cd241c343 100644 --- a/app/portainer/components/template-list/templateList.html +++ b/app/portainer/components/template-list/templateList.html @@ -37,7 +37,7 @@
From ea6cddcfd3cfb6becdd486a18182f5fa911c5778 Mon Sep 17 00:00:00 2001 From: Anthony Brame Date: Tue, 13 Aug 2019 17:38:04 +0200 Subject: [PATCH 08/49] feat(swarmvisualizer): add labels display under node info (#2886) * feat(swarmvisualizer): add labels display under node info * feat(swarmvisualizer): fix css * add toggle to display node labels * feat(swarmvisualizer): rename filters section + fix display when label has no value * feat(swarmvisualizer): retrieve state from local storage for node labels display toggle --- .../visualizer/swarmVisualizerController.js | 10 +++++++++ .../swarm/visualizer/swarmvisualizer.html | 21 ++++++++++++++++++- assets/css/app.css | 11 ++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/app/docker/views/swarm/visualizer/swarmVisualizerController.js b/app/docker/views/swarm/visualizer/swarmVisualizerController.js index 761ea1228..d811477bd 100644 --- a/app/docker/views/swarm/visualizer/swarmVisualizerController.js +++ b/app/docker/views/swarm/visualizer/swarmVisualizerController.js @@ -5,6 +5,7 @@ function ($q, $scope, $document, $interval, NodeService, ServiceService, TaskSer $scope.state = { ShowInformationPanel: true, DisplayOnlyRunningTasks: false, + DisplayNodeLabels: false, refreshRate: '5' }; @@ -22,6 +23,11 @@ function ($q, $scope, $document, $interval, NodeService, ServiceService, TaskSer LocalStorage.storeSwarmVisualizerSettings('display_only_running_tasks', value); }; + $scope.changeDisplayNodeLabels = function() { + var value = $scope.state.DisplayNodeLabels; + LocalStorage.storeSwarmVisualizerSettings('display_node_labels', value); +}; + $scope.changeUpdateRepeater = function() { stopRepeater(); setUpdateRepeater(); @@ -110,6 +116,10 @@ function ($q, $scope, $document, $interval, NodeService, ServiceService, TaskSer if (displayOnlyRunningTasks !== undefined && displayOnlyRunningTasks !== null) $scope.state.DisplayOnlyRunningTasks = displayOnlyRunningTasks; + var displayNodeLabels = LocalStorage.getSwarmVisualizerSettings('display_node_labels'); + if (displayNodeLabels !== undefined && displayNodeLabels !== null) + $scope.state.DisplayNodeLabels = displayNodeLabels; + var refreshRate = LocalStorage.getSwarmVisualizerSettings('refresh_rate'); if (refreshRate !== undefined && refreshRate !== null) $scope.state.refreshRate = refreshRate; diff --git a/app/docker/views/swarm/visualizer/swarmvisualizer.html b/app/docker/views/swarm/visualizer/swarmvisualizer.html index 8851ac41c..79d2318c9 100644 --- a/app/docker/views/swarm/visualizer/swarmvisualizer.html +++ b/app/docker/views/swarm/visualizer/swarmvisualizer.html @@ -37,7 +37,7 @@
- Filters + Options
@@ -48,6 +48,14 @@
+
+ + +
@@ -95,6 +103,17 @@
CPU: {{ node.CPUs / 1000000000 }}
Memory: {{ node.Memory|humansize: 2 }}
{{ node.Status }}
+
+
Labels
+
+ + {{ label.key }} + + + = {{ label.value }} + +
+
diff --git a/assets/css/app.css b/assets/css/app.css index f397a0a2f..44ce2625d 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -718,6 +718,17 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { font-size: 16px; } +.visualizer_container .node .node_info .node_labels { + border-top: 1px solid #777; + padding-top: 10px; + margin-top: 10px; +} + +.visualizer_container .node .node_info .node_label { + font-style: italic; + color: #787878; +} + .visualizer_container .node .tasks { display: flex; flex-direction: column; From c34e83cafdc95846ca78e2824e26cbbcce3909ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20K=C3=A5gstr=C3=B6m?= Date: Wed, 14 Aug 2019 15:03:47 +0200 Subject: [PATCH 09/49] docs(README): fix typo in readme (#3071) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e1297a611..a2d2441d4 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ **_Portainer_** is a lightweight management UI which allows you to **easily** manage your different Docker environments (Docker hosts or Swarm clusters). **_Portainer_** is meant to be as **simple** to deploy as it is to use. It consists of a single container that can run on any Docker engine (can be deployed as Linux container or a Windows native container, supports other platforms too). -**_Portainer_** allows you to manage your all your Docker resources (containers, images, volumes, networks and more) ! It is compatible with the *standalone Docker* engine and with *Docker Swarm mode*. +**_Portainer_** allows you to manage all your Docker resources (containers, images, volumes, networks and more) ! It is compatible with the *standalone Docker* engine and with *Docker Swarm mode*. ## Demo From a90fa857eef8166440a8dbeb39a799f1182d78ab Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Fri, 30 Aug 2019 09:30:30 +1200 Subject: [PATCH 10/49] docs(api): document Edge agent environment type --- api/swagger.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/swagger.yaml b/api/swagger.yaml index f7d87939a..8213b4bbf 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -254,7 +254,7 @@ paths: - name: "EndpointType" in: "formData" type: "integer" - description: "Environment type. Value must be one of: 1 (Docker environment), 2 (Agent environment) or 3 (Azure environment)" + description: "Environment type. Value must be one of: 1 (Docker environment), 2 (Agent environment), 3 (Azure environment) or 4 (Edge agent environment)" required: true - name: "URL" in: "formData" From 849ff8cf9bf32c792a9ab62017cdcbcd70b2522a Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 2 Sep 2019 07:17:41 +1200 Subject: [PATCH 11/49] docs(api): document EdgeAgentCheckinInterval parameter for SettingsUpdate --- api/swagger.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api/swagger.yaml b/api/swagger.yaml index 8213b4bbf..33b127ffd 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -3920,6 +3920,10 @@ definitions: type: "boolean" example: true description: "Whether non-administrator users should be able to use privileged mode when creating containers" + EdgeAgentCheckinInterval: + type: "integer" + example: "30" + description: "Polling interval for Edge agent (in seconds)" EndpointGroupCreateRequest: type: "object" required: From 2b48f1e49a1a791f29bba7e02687451e3eaaa4de Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 9 Sep 2019 12:40:22 +1200 Subject: [PATCH 12/49] refactor(build-system): clarify build system usage through yarn (#3140) * refactor(build-system): clarify build system usage through yarn * refactor(build-system): rename azure devops build scripts --- ...evops.ps1 => build_binary_azuredevops.ps1} | 0 ..._devops.sh => build_binary_azuredevops.sh} | 0 gruntfile.js | 145 +++++++++--------- package.json | 24 ++- 4 files changed, 87 insertions(+), 82 deletions(-) rename build/{build_binary_devops.ps1 => build_binary_azuredevops.ps1} (100%) rename build/{build_binary_devops.sh => build_binary_azuredevops.sh} (100%) diff --git a/build/build_binary_devops.ps1 b/build/build_binary_azuredevops.ps1 similarity index 100% rename from build/build_binary_devops.ps1 rename to build/build_binary_azuredevops.ps1 diff --git a/build/build_binary_devops.sh b/build/build_binary_azuredevops.sh similarity index 100% rename from build/build_binary_devops.sh rename to build/build_binary_azuredevops.sh diff --git a/gruntfile.js b/gruntfile.js index 834ee852c..d12992dae 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -1,38 +1,67 @@ const webpackDevConfig = require('./webpack/webpack.develop'); const webpackProdConfig = require('./webpack/webpack.production'); -var gruntfile_cfg = {}; var loadGruntTasks = require('load-grunt-tasks'); + var os = require('os'); var arch = os.arch(); if (arch === 'x64') arch = 'amd64'; +var portainer_data = '/tmp/portainer'; + module.exports = function(grunt) { loadGruntTasks(grunt, { pattern: ['grunt-*', 'gruntify-*'] }); - grunt.registerTask('default', ['eslint', 'build']); + grunt.initConfig({ + root: 'dist', + distdir: 'dist/public', + shippedDockerVersion: '18.09.3', + shippedDockerVersionWindows: '17.09.0-ce', + config: gruntfile_cfg.config, + env: gruntfile_cfg.env, + src: gruntfile_cfg.src, + clean: gruntfile_cfg.clean, + eslint: gruntfile_cfg.eslint, + shell: gruntfile_cfg.shell, + copy: gruntfile_cfg.copy, + webpack: gruntfile_cfg.webpack + }); - grunt.registerTask('build-webapp', [ - 'config:prod', - 'env:prod', - 'clean:all', - 'copy:templates', - 'webpack:prod']); + grunt.registerTask('lint', ['eslint']); - grunt.registerTask('build', [ + grunt.registerTask('build:server', [ + 'shell:build_binary:linux:' + arch, + 'shell:download_docker_binary:linux:' + arch, + ]); + + grunt.registerTask('build:client', [ 'config:dev', - 'shell:buildBinary:linux:' + arch, - 'shell:downloadDockerBinary:linux:' + arch, - 'copy:templates', + 'env:dev', 'webpack:dev' ]); - grunt.registerTask('build-server', [ - 'shell:buildBinary:linux:' + arch, - 'shell:downloadDockerBinary:linux:' + arch, + grunt.registerTask('build', [ + 'build:server', + 'build:client', + 'copy:templates' + ]); + + grunt.registerTask('start:server', [ + 'build:server', 'copy:templates', - 'shell:run:' + arch + 'shell:run_container' + ]); + + grunt.registerTask('start:client', [ + 'config:dev', + 'env:dev', + 'webpack:devWatch' + ]); + + grunt.registerTask('start', [ + 'start:server', + 'start:client' ]); grunt.task.registerTask('release', 'release::', @@ -42,8 +71,8 @@ module.exports = function(grunt) { 'env:prod', 'clean:all', 'copy:templates', - 'shell:buildBinary:' + p + ':' + a, - 'shell:downloadDockerBinary:' + p + ':' + a, + 'shell:build_binary:' + p + ':' + a, + 'shell:download_docker_binary:' + p + ':' + a, 'webpack:prod' ]); }); @@ -55,41 +84,15 @@ module.exports = function(grunt) { 'env:prod', 'clean:all', 'copy:templates', - 'shell:buildBinaryOnDevOps:' + p + ':' + a, - 'shell:downloadDockerBinary:' + p + ':' + a, + 'shell:build_binary_azuredevops:' + p + ':' + a, + 'shell:download_docker_binary:' + p + ':' + a, 'webpack:prod' ]); }); - - grunt.registerTask('lint', ['eslint']); - grunt.registerTask('run-dev', ['build', 'shell:run', 'watch:build']); - grunt.registerTask('clear', ['clean:app']); - - grunt.registerTask('run-dev', [ - 'config:dev', - 'build-server', - 'webpack:devWatch' - ]); - grunt.registerTask('clear', ['clean:app']); - - // Project configuration. - grunt.initConfig({ - root: 'dist', - distdir: 'dist/public', - shippedDockerVersion: '18.09.3', - shippedDockerVersionWindows: '17.09.0-ce', - config: gruntfile_cfg.config, - src: gruntfile_cfg.src, - clean: gruntfile_cfg.clean, - eslint: gruntfile_cfg.eslint, - shell: gruntfile_cfg.shell, - copy: gruntfile_cfg.copy, - webpack: gruntfile_cfg.webpack, - env: gruntfile_cfg.env - }); }; /***/ +var gruntfile_cfg = {}; gruntfile_cfg.env = { dev: { @@ -102,8 +105,8 @@ gruntfile_cfg.env = { gruntfile_cfg.webpack = { dev: webpackDevConfig, - prod: webpackProdConfig, - devWatch: Object.assign({ watch: true }, webpackDevConfig) + devWatch: Object.assign({ watch: true }, webpackDevConfig), + prod: webpackProdConfig }; gruntfile_cfg.config = { @@ -120,19 +123,10 @@ gruntfile_cfg.src = { }; gruntfile_cfg.clean = { + server: ['<%= root %>/portainer'], + client: ['<%= distdir %>/*'], + docker: ['<%= root %>/docker'], all: ['<%= root %>/*'], - app: [ - '<%= distdir %>/*', - '!<%= distdir %>/../portainer*', - '!<%= distdir %>/../docker*' - ], - tmpl: ['<%= distdir %>/templates'], - tmp: [ - '<%= distdir %>/js/*', - '!<%= distdir %>/js/app.*.js', - '<%= distdir %>/css/*', - '!<%= distdir %>/css/app.*.css' - ] }; gruntfile_cfg.eslint = { @@ -140,7 +134,6 @@ gruntfile_cfg.eslint = { options: { configFile: '.eslintrc.yml' } }; - gruntfile_cfg.copy = { templates: { files: [ @@ -153,7 +146,14 @@ gruntfile_cfg.copy = { } }; -function shell_buildBinary(p, a) { +gruntfile_cfg.shell = { + build_binary: { command: shell_build_binary }, + build_binary_azuredevops: { command: shell_build_binary_azuredevops }, + download_docker_binary: { command: shell_download_docker_binary }, + run_container: { command: shell_run_container } +}; + +function shell_build_binary(p, a) { var binfile = 'dist/portainer'; if (p === 'linux') { return [ @@ -174,22 +174,22 @@ function shell_buildBinary(p, a) { } } -function shell_buildBinaryOnDevOps(p, a) { +function shell_build_binary_azuredevops(p, a) { if (p === 'linux') { - return 'build/build_binary_devops.sh ' + p + ' ' + a + ';'; + return 'build/build_binary_azuredevops.sh ' + p + ' ' + a + ';'; } else { - return 'powershell -Command ".\\build\\build_binary_devops.ps1 -platform ' + p + ' -arch ' + a + '"'; + return 'powershell -Command ".\\build\\build_binary_azuredevops.ps1 -platform ' + p + ' -arch ' + a + '"'; } } -function shell_run() { +function shell_run_container() { return [ 'docker rm -f portainer', - 'docker run -d -p 8000:8000 -p 9000:9000 -v $(pwd)/dist:/app -v /tmp/portainer:/data -v /var/run/docker.sock:/var/run/docker.sock:z --name portainer portainer/base /app/portainer --no-analytics --template-file /app/templates.json' + 'docker run -d -p 8000:8000 -p 9000:9000 -v $(pwd)/dist:/app -v ' + portainer_data + ':/data -v /var/run/docker.sock:/var/run/docker.sock:z --name portainer portainer/base /app/portainer --no-analytics --template-file /app/templates.json' ].join(';'); } -function shell_downloadDockerBinary(p, a) { +function shell_download_docker_binary(p, a) { var ps = { 'windows': 'win', 'darwin': 'mac' }; var as = { 'amd64': 'x86_64', 'arm': 'armhf', 'arm64': 'aarch64' }; var ip = ((ps[p] === undefined) ? p : ps[p]); @@ -213,10 +213,3 @@ function shell_downloadDockerBinary(p, a) { ].join(' '); } } - -gruntfile_cfg.shell = { - buildBinary: { command: shell_buildBinary }, - buildBinaryOnDevOps: { command: shell_buildBinaryOnDevOps }, - run: { command: shell_run }, - downloadDockerBinary: { command: shell_downloadDockerBinary } -}; diff --git a/package.json b/package.json index e2f341333..74a2bd14a 100644 --- a/package.json +++ b/package.json @@ -20,13 +20,25 @@ } ], "scripts": { - "start": "grunt run-dev", - "start:client": "webpack-dev-server", - "start:server": "yarn build:server:offline && grunt shell:run:amd64", - "clean:all": "grunt clean:all", - "build": "NODE_ENV=production grunt build", + "build": "grunt clean:all && grunt build", + "build:server": "grunt clean:server && grunt build:server", + "build:client": "grunt clean:client && grunt build:client", + "clean": "grunt clean:all", + "start": "grunt clean:all && grunt start", + "start:server": "grunt clean:server && grunt start:server", + "start:client": "grunt clean:client && grunt start:client", "build:server:offline": "cd ./api/cmd/portainer && CGO_ENABLED=0 go build -a --installsuffix cgo --ldflags '-s' && mv -f portainer ../../../dist/portainer", - "build:client": "NODE_ENV=production grunt build-webapp" + "clean:all": "grunt clean:all" + }, + "scriptsComments": { + "build": "Build the entire app (backend/frontend) in development mode", + "build:server": "Build the backend", + "build:client": "Build the frontend (development mode)", + "clean": "Clean the entire dist folder", + "start": "Build the entire app (backend/frontend) in development mode, run it inside a container locally and start a watch process for the frontend files", + "start:server": "Build the backend and run it inside a container", + "clean:all": "Deprecated. Use the clean script instead", + "build:server:offline": "Deprecated. Use the build:server script instead" }, "engines": { "node": ">= 0.8.4" From 628d4960cc6c8cad1bc4e349570c4e106b5a947a Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 10 Sep 2019 10:55:27 +1200 Subject: [PATCH 13/49] fix(api): fix an issue with RegistryUpdate operation (#3137) --- .../handler/registries/registry_update.go | 63 ++++++++++--------- .../views/registries/edit/registry.html | 2 +- 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/api/http/handler/registries/registry_update.go b/api/http/handler/registries/registry_update.go index 14c11cbba..15dd2d9dc 100644 --- a/api/http/handler/registries/registry_update.go +++ b/api/http/handler/registries/registry_update.go @@ -3,7 +3,6 @@ package registries import ( "net/http" - "github.com/asaskevich/govalidator" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" @@ -11,19 +10,16 @@ import ( ) type registryUpdatePayload struct { - Name string - URL string - Authentication bool - Username string - Password string + Name *string + URL *string + Authentication *bool + Username *string + Password *string UserAccessPolicies portainer.UserAccessPolicies TeamAccessPolicies portainer.TeamAccessPolicies } func (payload *registryUpdatePayload) Validate(r *http.Request) error { - if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) { - return portainer.Error("Invalid credentials. Username and password must be specified when authentication is enabled") - } return nil } @@ -47,32 +43,41 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} } - registries, err := handler.RegistryService.Registries() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} + if payload.Name != nil { + registry.Name = *payload.Name } - for _, r := range registries { - if r.URL == payload.URL && r.ID != registry.ID { - return &httperror.HandlerError{http.StatusConflict, "Another registry with the same URL already exists", portainer.ErrRegistryAlreadyExists} + + if payload.URL != nil { + registries, err := handler.RegistryService.Registries() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} } + for _, r := range registries { + if r.URL == *payload.URL && r.ID != registry.ID { + return &httperror.HandlerError{http.StatusConflict, "Another registry with the same URL already exists", portainer.ErrRegistryAlreadyExists} + } + } + + registry.URL = *payload.URL } - if payload.Name != "" { - registry.Name = payload.Name - } + if payload.Authentication != nil { + if *payload.Authentication { + registry.Authentication = true - if payload.URL != "" { - registry.URL = payload.URL - } + if payload.Username != nil { + registry.Username = *payload.Username + } - if payload.Authentication { - registry.Authentication = true - registry.Username = payload.Username - registry.Password = payload.Password - } else { - registry.Authentication = false - registry.Username = "" - registry.Password = "" + if payload.Password != nil { + registry.Password = *payload.Password + } + + } else { + registry.Authentication = false + registry.Username = "" + registry.Password = "" + } } if payload.UserAccessPolicies != nil { diff --git a/app/portainer/views/registries/edit/registry.html b/app/portainer/views/registries/edit/registry.html index c4d8ba9df..b1f31f90f 100644 --- a/app/portainer/views/registries/edit/registry.html +++ b/app/portainer/views/registries/edit/registry.html @@ -64,7 +64,7 @@
- From ec19faaa243dfc04e1f97dd5389f56cbd621208c Mon Sep 17 00:00:00 2001 From: Steven Kang Date: Tue, 10 Sep 2019 10:56:16 +1200 Subject: [PATCH 14/49] fix(stack): Skip SSL Verification (#3064) * fix(stack): Skip SSL Verification * fix(stack): Skip SSL Verification * fix(stack): move httpsCli into service * fix(stack): clean-up * fix(stack): move httpsCli back into the function * fix(stack): move httpsCli and InstallProtocol back into service * fix(stack): clean-up debugging * fix(stack): parameter cleanup Co-Authored-By: Anthony Lapenna --- api/cmd/portainer/main.go | 2 +- api/git/git.go | 26 +++++++++++++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 961039738..7f4d36635 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -102,7 +102,7 @@ func initLDAPService() portainer.LDAPService { } func initGitService() portainer.GitService { - return &git.Service{} + return git.NewService() } func initClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService) *docker.ClientFactory { diff --git a/api/git/git.go b/api/git/git.go index add688b01..116a5f113 100644 --- a/api/git/git.go +++ b/api/git/git.go @@ -1,21 +1,37 @@ package git import ( + "crypto/tls" + "net/http" "net/url" "strings" + "time" "gopkg.in/src-d/go-git.v4" "gopkg.in/src-d/go-git.v4/plumbing" + "gopkg.in/src-d/go-git.v4/plumbing/transport/client" + githttp "gopkg.in/src-d/go-git.v4/plumbing/transport/http" ) // Service represents a service for managing Git. -type Service struct{} +type Service struct { + httpsCli *http.Client +} // NewService initializes a new service. -func NewService(dataStorePath string) (*Service, error) { - service := &Service{} +func NewService() *Service { + httpsCli := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + Timeout: 300 * time.Second, + } - return service, nil + client.InstallProtocol("https", githttp.NewClient(httpsCli)) + + return &Service{ + httpsCli: httpsCli, + } } // ClonePublicRepository clones a public git repository using the specified URL in the specified @@ -32,7 +48,7 @@ func (service *Service) ClonePrivateRepositoryWithBasicAuth(repositoryURL, refer return cloneRepository(repositoryURL, referenceName, destination) } -func cloneRepository(repositoryURL, referenceName string, destination string) error { +func cloneRepository(repositoryURL, referenceName, destination string) error { options := &git.CloneOptions{ URL: repositoryURL, } From 52704e681b1a6f7f59ac7d8eb245fe7eecf90939 Mon Sep 17 00:00:00 2001 From: xAt0mZ Date: Tue, 10 Sep 2019 00:56:57 +0200 Subject: [PATCH 15/49] feat(services): rollback service capability (#3057) * feat(services): rollback service capability * refactor(services): notification reword Co-Authored-By: William * refactor(services): remove TODO comment + add note on rollback capability * fix(services): service update rpc error version out of sync * feat(services): confirmation modal on rollback * feat(services): rpc error no previous spec message --- app/docker/rest/service.js | 8 +-- app/docker/services/serviceService.js | 13 ++++- app/docker/views/services/edit/service.html | 7 +++ .../views/services/edit/serviceController.js | 54 +++++++++++++++++-- 4 files changed, 70 insertions(+), 12 deletions(-) diff --git a/app/docker/rest/service.js b/app/docker/rest/service.js index dca148145..81a8eca7e 100644 --- a/app/docker/rest/service.js +++ b/app/docker/rest/service.js @@ -14,19 +14,13 @@ function ServiceFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, Htt method: 'POST', params: {action: 'create'}, headers: { 'X-Registry-Auth': HttpRequestHelper.registryAuthenticationHeader, - // TODO: This is a temporary work-around that allows us to leverage digest pinning on - // the Docker daemon side. It has been moved client-side since Docker API version > 1.29. - // We should introduce digest pinning in Portainer as well. 'version': '1.29' }, ignoreLoadingBar: true }, update: { - method: 'POST', params: { id: '@id', action: 'update', version: '@version' }, + method: 'POST', params: { id: '@id', action: 'update', version: '@version', rollback: '@rollback' }, headers: { - // TODO: This is a temporary work-around that allows us to leverage digest pinning on - // the Docker daemon side. It has been moved client-side since Docker API version > 1.29. - // We should introduce digest pinning in Portainer as well. 'version': '1.29' } }, diff --git a/app/docker/services/serviceService.js b/app/docker/services/serviceService.js index 0696f09ff..d3db5f048 100644 --- a/app/docker/services/serviceService.js +++ b/app/docker/services/serviceService.js @@ -58,8 +58,17 @@ function ServiceServiceFactory($q, Service, ServiceHelper, TaskService, Resource return deferred.promise; }; - service.update = function(service, config) { - return Service.update({ id: service.Id, version: service.Version }, config).$promise; + service.update = function(serv, config, rollback) { + return service.service(serv.Id).then((data) => { + const params = { + id: serv.Id, + version: data.Version + }; + if (rollback) { + params.rollback = rollback + } + return Service.update(params, config).$promise; + }); }; service.logs = function(id, stdout, stderr, timestamps, since, tail) { diff --git a/app/docker/views/services/edit/service.html b/app/docker/views/services/edit/service.html index e4f7ef22a..13ef42d57 100644 --- a/app/docker/views/services/edit/service.html +++ b/app/docker/views/services/edit/service.html @@ -91,11 +91,18 @@ +

+ Note: you can only rollback one level of changes. Clicking the rollback button without making a new change will undo your previous rollback +

Service logs + - + - {{ state.AuthenticationError }} + {{ ctrl.state.AuthenticationError }}

@@ -61,10 +61,10 @@
-
+
- OAuth authentication in progress... + Authentication in progress...
diff --git a/app/portainer/views/auth/authController.js b/app/portainer/views/auth/authController.js index d79889ec2..76d6b00f1 100644 --- a/app/portainer/views/auth/authController.js +++ b/app/portainer/views/auth/authController.js @@ -1,123 +1,71 @@ +import angular from 'angular'; import uuidv4 from 'uuid/v4'; -angular.module('portainer.app') -.controller('AuthenticationController', ['$async', '$q', '$scope', '$state', '$stateParams', '$sanitize', 'Authentication', 'UserService', 'EndpointService', 'ExtensionService', 'StateManager', 'Notifications', 'SettingsService', 'URLHelper', 'LocalStorage', -function($async, $q, $scope, $state, $stateParams, $sanitize, Authentication, UserService, EndpointService, ExtensionService, StateManager, Notifications, SettingsService, URLHelper, LocalStorage) { - $scope.logo = StateManager.getState().application.logo; +class AuthenticationController { + /* @ngInject */ + constructor($async, $scope, $state, $stateParams, $sanitize, Authentication, UserService, EndpointService, ExtensionService, StateManager, Notifications, SettingsService, URLHelper, LocalStorage) { + this.$async = $async; + this.$scope = $scope; + this.$state = $state; + this.$stateParams = $stateParams; + this.$sanitize = $sanitize; + this.Authentication = Authentication; + this.UserService = UserService; + this.EndpointService = EndpointService; + this.ExtensionService = ExtensionService; + this.StateManager = StateManager; + this.Notifications = Notifications; + this.SettingsService = SettingsService; + this.URLHelper = URLHelper; + this.LocalStorage = LocalStorage; - $scope.formValues = { - Username: '', - Password: '' - }; + this.logo = this.StateManager.getState().application.logo; + this.formValues = { + Username: '', + Password: '' + }; + this.state = { + AuthenticationError: '', + loginInProgress: true, + OAuthProvider: '' + }; - $scope.state = { - AuthenticationError: '', - isInOAuthProcess: true, - OAuthProvider: '' - }; + this.retrieveAndSaveEnabledExtensionsAsync = this.retrieveAndSaveEnabledExtensionsAsync.bind(this); + this.retrievePermissionsAsync = this.retrievePermissionsAsync.bind(this); + this.checkForEndpointsAsync = this.checkForEndpointsAsync.bind(this); + this.postLoginSteps = this.postLoginSteps.bind(this); - function retrieveAndSaveEnabledExtensions() { - return $async(retrieveAndSaveEnabledExtensionsAsync); + this.oAuthLoginAsync = this.oAuthLoginAsync.bind(this); + this.retryLoginSanitizeAsync = this.retryLoginSanitizeAsync.bind(this); + this.internalLoginAsync = this.internalLoginAsync.bind(this); + + this.authenticateUserAsync = this.authenticateUserAsync.bind(this); + + this.manageOauthCodeReturn = this.manageOauthCodeReturn.bind(this); + this.authEnabledFlowAsync = this.authEnabledFlowAsync.bind(this); + this.onInit = this.onInit.bind(this); } - async function retrieveAndSaveEnabledExtensionsAsync() { - try { - await ExtensionService.retrieveAndSaveEnabledExtensions(); - } catch (err) { - Notifications.error('Failure', err, 'Unable to retrieve enabled extensions'); - $scope.state.loginInProgress = false; + /** + * UTILS FUNCTIONS SECTION + */ + + logout() { + this.Authentication.logout(); + this.state.loginInProgress = false; + this.generateOAuthLoginURI(); + } + + error(err, message) { + this.state.AuthenticationError = message; + if (!err) { + err = {}; } + this.Notifications.error('Failure', err, message); + this.state.loginInProgress = false; } - function permissionsError() { - $scope.state.permissionsError = true; - Authentication.logout(); - $scope.state.AuthenticationError = 'Unable to retrieve permissions.' - $scope.state.loginInProgress = false; - return Promise.reject(); - } - - $scope.authenticateUser = function() { - var username = $scope.formValues.Username; - var password = $scope.formValues.Password; - $scope.state.loginInProgress = true; - - Authentication.login(username, password) - .then(() => Authentication.retrievePermissions().catch(permissionsError)) - .then(function success() { - return retrieveAndSaveEnabledExtensions(); - }) - .then(function () { - checkForEndpoints(); - }) - .catch(function error() { - if ($scope.state.permissionsError) { - return; - } - SettingsService.publicSettings() - .then(function success(settings) { - if (settings.AuthenticationMethod === 1) { - return Authentication.login($sanitize(username), $sanitize(password)); - } - return $q.reject(); - }) - .then(function success() { - return retrieveAndSaveEnabledExtensions(); - }) - .then(function() { - $state.go('portainer.updatePassword'); - }) - .catch(function error() { - $scope.state.AuthenticationError = 'Invalid credentials'; - $scope.state.loginInProgress = false; - }); - }); - }; - - function unauthenticatedFlow() { - EndpointService.endpoints(0, 100) - .then(function success(endpoints) { - if (endpoints.value.length === 0) { - $state.go('portainer.init.endpoint'); - } else { - $state.go('portainer.home'); - } - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve endpoints'); - }); - } - - function authenticatedFlow() { - UserService.administratorExists() - .then(function success(exists) { - if (!exists) { - $state.go('portainer.init.admin'); - } - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to verify administrator account existence'); - }); - } - - function checkForEndpoints() { - EndpointService.endpoints(0, 100) - .then(function success(data) { - var endpoints = data.value; - - if (endpoints.length === 0 && Authentication.isAdmin()) { - $state.go('portainer.init.endpoint'); - } else { - $state.go('portainer.home'); - } - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve endpoints'); - $scope.state.loginInProgress = false; - }); - } - - function determineOauthProvider(LoginURI) { + determineOauthProvider(LoginURI) { if (LoginURI.indexOf('login.microsoftonline.com') !== -1) { return 'Microsoft'; } @@ -130,70 +78,199 @@ function($async, $q, $scope, $state, $stateParams, $sanitize, Authentication, Us return 'OAuth'; } - function generateState() { + generateState() { const uuid = uuidv4(); - LocalStorage.storeLoginStateUUID(uuid); + this.LocalStorage.storeLoginStateUUID(uuid); return '&state=' + uuid; } - function hasValidState(state) { - const savedUUID = LocalStorage.getLoginStateUUID(); - return savedUUID === state; + generateOAuthLoginURI() { + this.OAuthLoginURI = this.state.OAuthLoginURI + this.generateState(); } - function initView() { - SettingsService.publicSettings() - .then(function success(settings) { - $scope.AuthenticationMethod = settings.AuthenticationMethod; - $scope.state.OAuthProvider = determineOauthProvider(settings.OAuthLoginURI); - $scope.OAuthLoginURI = settings.OAuthLoginURI + generateState(); - }); + hasValidState(state) { + const savedUUID = this.LocalStorage.getLoginStateUUID(); + return savedUUID && state && savedUUID === state; + } - if ($stateParams.logout || $stateParams.error) { - Authentication.logout(); - $scope.state.AuthenticationError = $stateParams.error; - $scope.state.isInOAuthProcess = false; - return; - } + /** + * END UTILS FUNCTIONS SECTION + */ - if (Authentication.isAuthenticated()) { - $state.go('portainer.home'); - } + /** + * POST LOGIN STEPS SECTION + */ - var authenticationEnabled = $scope.applicationState.application.authentication; - if (!authenticationEnabled) { - unauthenticatedFlow(); - } else { - authenticatedFlow(); - } - - const code = URLHelper.getParameter('code'); - const state = URLHelper.getParameter('state'); - if (code && hasValidState(state)) { - oAuthLogin(code); - } else { - $scope.state.isInOAuthProcess = false; + async retrievePermissionsAsync() { + try { + await this.Authentication.retrievePermissions(); + } catch (err) { + this.state.permissionsError = true; + this.logout(); + this.error(err, 'Unable to retrieve permissions.'); } } - function oAuthLogin(code) { - return Authentication.OAuthLogin(code) - .then(() => Authentication.retrievePermissions().catch(permissionsError)) - .then(function success() { - return retrieveAndSaveEnabledExtensions(); - }) - .then(function() { - URLHelper.cleanParameters(); - }) - .catch(function error() { - if ($scope.state.permissionsError) { + async retrieveAndSaveEnabledExtensionsAsync() { + try { + await this.ExtensionService.retrieveAndSaveEnabledExtensions(); + } catch (err) { + this.error(err, 'Unable to retrieve enabled extensions'); + } + } + + async checkForEndpointsAsync(noAuth) { + try { + const endpoints = await this.EndpointService.endpoints(0, 1); + const isAdmin = noAuth || this.Authentication.isAdmin(); + + if (endpoints.value.length === 0 && isAdmin) { + return this.$state.go('portainer.init.endpoint'); + } else { + return this.$state.go('portainer.home'); + } + } catch (err) { + this.error(err, 'Unable to retrieve endpoints'); + } + } + + async postLoginSteps() { + await this.retrievePermissionsAsync(); + await this.retrieveAndSaveEnabledExtensionsAsync(); + await this.checkForEndpointsAsync(false); + } + /** + * END POST LOGIN STEPS SECTION + */ + + /** + * LOGIN METHODS SECTION + */ + + async oAuthLoginAsync(code) { + try { + await this.Authentication.OAuthLogin(code); + this.URLHelper.cleanParameters(); + } catch (err) { + this.error(err, 'Unable to login via OAuth'); + } + } + + async retryLoginSanitizeAsync(username, password) { + try { + await this.internalLoginAsync(this.$sanitize(username), this.$sanitize(password)); + this.$state.go('portainer.updatePassword'); + } catch (err) { + this.error(err, 'Invalid credentials'); + } + } + + async internalLoginAsync(username, password) { + await this.Authentication.login(username, password); + await this.postLoginSteps(); + } + + /** + * END LOGIN METHODS SECTION + */ + + /** + * AUTHENTICATE USER SECTION + */ + + async authenticateUserAsync() { + try { + var username = this.formValues.Username; + var password = this.formValues.Password; + this.state.loginInProgress = true; + await this.internalLoginAsync(username, password); + } catch (err) { + if (this.state.permissionsError) { return; } - $scope.state.AuthenticationError = 'Unable to login via OAuth'; - $scope.state.isInOAuthProcess = false; - }); + // This login retry is necessary to avoid conflicts with databases + // containing users created before Portainer 1.19.2 + // See https://github.com/portainer/portainer/issues/2199 for more info + await this.retryLoginSanitizeAsync(username, password); + } } + authenticateUser() { + return this.$async(this.authenticateUserAsync) + } - initView(); -}]); + /** + * END AUTHENTICATE USER SECTION + */ + + /** + * ON INIT SECTION + */ + async manageOauthCodeReturn(code, state) { + if (this.hasValidState(state)) { + await this.oAuthLoginAsync(code); + } else { + this.error(null, 'Invalid OAuth state, try again.'); + } + } + + async authEnabledFlowAsync() { + try { + const exists = await this.UserService.administratorExists(); + if (!exists) { + this.$state.go('portainer.init.admin'); + } + } catch (err) { + this.error(err, 'Unable to verify administrator account existence') + } + } + + async onInit() { + try { + const settings = await this.SettingsService.publicSettings(); + this.AuthenticationMethod = settings.AuthenticationMethod; + this.state.OAuthProvider = this.determineOauthProvider(settings.OAuthLoginURI); + this.state.OAuthLoginURI = settings.OAuthLoginURI; + + const code = this.URLHelper.getParameter('code'); + const state = this.URLHelper.getParameter('state'); + if (code && state) { + await this.manageOauthCodeReturn(code, state); + this.generateOAuthLoginURI(); + return; + } + this.generateOAuthLoginURI(); + + if (this.$stateParams.logout || this.$stateParams.error) { + this.logout(); + this.state.AuthenticationError = this.$stateParams.error; + return; + } + + if (this.Authentication.isAuthenticated()) { + await this.postLoginSteps(); + } + this.state.loginInProgress = false; + + const authenticationEnabled = this.$scope.applicationState.application.authentication; + if (!authenticationEnabled) { + await this.checkForEndpointsAsync(true); + } else { + await this.authEnabledFlowAsync(); + } + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve public settings'); + } + } + + $onInit() { + return this.$async(this.onInit); + } + + /** + * END ON INIT SECTION + */ +} + +export default AuthenticationController; +angular.module('portainer.app').controller('AuthenticationController', AuthenticationController); From ea05d96c7382eb7ddf94099a0c147db969ba0c00 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Thu, 26 Sep 2019 08:38:11 +1200 Subject: [PATCH 28/49] feat(sidebar): add update notification (#3196) * feat(sidebar): add update notification * style(sidebar): update notification color palette * refactor(api): rollback to latest version * feat(sidebar): update style * style(sidebar): fix color override --- api/http/handler/status/handler.go | 2 + .../handler/status/status_inspect_version.go | 51 +++++++++++++++++++ api/portainer.go | 2 + app/portainer/models/status.js | 5 ++ app/portainer/rest/status.js | 5 +- app/portainer/services/api/statusService.js | 17 ++++++- app/portainer/services/stateManager.js | 5 ++ app/portainer/views/auth/authController.js | 22 +++++++- app/portainer/views/sidebar/sidebar.html | 10 +++- assets/css/app.css | 8 +++ 10 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 api/http/handler/status/status_inspect_version.go diff --git a/api/http/handler/status/handler.go b/api/http/handler/status/handler.go index cb30c8479..00ed6b799 100644 --- a/api/http/handler/status/handler.go +++ b/api/http/handler/status/handler.go @@ -23,6 +23,8 @@ func NewHandler(bouncer *security.RequestBouncer, status *portainer.Status) *Han } h.Handle("/status", bouncer.PublicAccess(httperror.LoggerHandler(h.statusInspect))).Methods(http.MethodGet) + h.Handle("/status/version", + bouncer.RestrictedAccess(http.HandlerFunc(h.statusInspectVersion))).Methods(http.MethodGet) return h } diff --git a/api/http/handler/status/status_inspect_version.go b/api/http/handler/status/status_inspect_version.go new file mode 100644 index 000000000..054bd670e --- /dev/null +++ b/api/http/handler/status/status_inspect_version.go @@ -0,0 +1,51 @@ +package status + +import ( + "encoding/json" + "net/http" + + "github.com/coreos/go-semver/semver" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/client" + + "github.com/portainer/libhttp/response" +) + +type inspectVersionResponse struct { + UpdateAvailable bool `json:"UpdateAvailable"` + LatestVersion string `json:"LatestVersion"` +} + +type githubData struct { + TagName string `json:"tag_name"` +} + +// GET request on /api/status/version +func (handler *Handler) statusInspectVersion(w http.ResponseWriter, r *http.Request) { + motd, err := client.Get(portainer.VersionCheckURL, 5) + if err != nil { + response.JSON(w, &inspectVersionResponse{UpdateAvailable: false}) + return + } + + var data githubData + err = json.Unmarshal(motd, &data) + if err != nil { + response.JSON(w, &inspectVersionResponse{UpdateAvailable: false}) + return + } + + resp := inspectVersionResponse{ + UpdateAvailable: false, + } + + currentVersion := semver.New(portainer.APIVersion) + latestVersion := semver.New(data.TagName) + if currentVersion.LessThan(*latestVersion) { + resp.UpdateAvailable = true + resp.LatestVersion = data.TagName + } + + response.JSON(w, &resp) +} diff --git a/api/portainer.go b/api/portainer.go index 438002d23..d5d546bf5 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -908,6 +908,8 @@ const ( AssetsServerURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com" // MessageOfTheDayURL represents the URL where Portainer MOTD message can be retrieved MessageOfTheDayURL = AssetsServerURL + "/motd.json" + // VersionCheckURL represents the URL used to retrieve the latest version of Portainer + VersionCheckURL = "https://api.github.com/repos/portainer/portainer/releases/latest" // ExtensionDefinitionsURL represents the URL where Portainer extension definitions can be retrieved ExtensionDefinitionsURL = AssetsServerURL + "/extensions-1.22.0.json" // PortainerAgentHeader represents the name of the header available in any agent response diff --git a/app/portainer/models/status.js b/app/portainer/models/status.js index 713d4f1fe..d91ae9baf 100644 --- a/app/portainer/models/status.js +++ b/app/portainer/models/status.js @@ -5,3 +5,8 @@ export function StatusViewModel(data) { this.Analytics = data.Analytics; this.Version = data.Version; } + +export function StatusVersionViewModel(data) { + this.UpdateAvailable = data.UpdateAvailable; + this.LatestVersion = data.LatestVersion; +} \ No newline at end of file diff --git a/app/portainer/rest/status.js b/app/portainer/rest/status.js index 888a948bf..9a9e55123 100644 --- a/app/portainer/rest/status.js +++ b/app/portainer/rest/status.js @@ -1,7 +1,8 @@ angular.module('portainer.app') .factory('Status', ['$resource', 'API_ENDPOINT_STATUS', function StatusFactory($resource, API_ENDPOINT_STATUS) { 'use strict'; - return $resource(API_ENDPOINT_STATUS, {}, { - get: { method: 'GET' } + return $resource(API_ENDPOINT_STATUS + '/:action', {}, { + get: { method: 'GET' }, + version: { method: 'GET', params: { action: 'version' } } }); }]); diff --git a/app/portainer/services/api/statusService.js b/app/portainer/services/api/statusService.js index eb7c1e343..05b5f87a1 100644 --- a/app/portainer/services/api/statusService.js +++ b/app/portainer/services/api/statusService.js @@ -1,4 +1,4 @@ -import { StatusViewModel } from "../../models/status"; +import {StatusVersionViewModel, StatusViewModel} from '../../models/status'; angular.module('portainer.app') .factory('StatusService', ['$q', 'Status', function StatusServiceFactory($q, Status) { @@ -20,5 +20,20 @@ angular.module('portainer.app') return deferred.promise; }; + service.version = function() { + var deferred = $q.defer(); + + Status.version().$promise + .then(function success(data) { + var status = new StatusVersionViewModel(data); + deferred.resolve(status); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve application version info', err: err }); + }); + + return deferred.promise; + }; + return service; }]); diff --git a/app/portainer/services/stateManager.js b/app/portainer/services/stateManager.js index 69ceecb0c..070d32e72 100644 --- a/app/portainer/services/stateManager.js +++ b/app/portainer/services/stateManager.js @@ -19,6 +19,11 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin extensions: [] }; + manager.setVersionInfo = function(versionInfo) { + state.application.versionStatus = versionInfo; + LocalStorage.storeApplicationState(state.application); + }; + manager.dismissInformationPanel = function(id) { state.UI.dismissedInfoPanels[id] = true; LocalStorage.storeUIState(state.UI); diff --git a/app/portainer/views/auth/authController.js b/app/portainer/views/auth/authController.js index 76d6b00f1..27a625785 100644 --- a/app/portainer/views/auth/authController.js +++ b/app/portainer/views/auth/authController.js @@ -3,7 +3,7 @@ import uuidv4 from 'uuid/v4'; class AuthenticationController { /* @ngInject */ - constructor($async, $scope, $state, $stateParams, $sanitize, Authentication, UserService, EndpointService, ExtensionService, StateManager, Notifications, SettingsService, URLHelper, LocalStorage) { + constructor($async, $scope, $state, $stateParams, $sanitize, Authentication, UserService, EndpointService, ExtensionService, StateManager, Notifications, SettingsService, URLHelper, LocalStorage, StatusService) { this.$async = $async; this.$scope = $scope; this.$state = $state; @@ -18,6 +18,7 @@ class AuthenticationController { this.SettingsService = SettingsService; this.URLHelper = URLHelper; this.LocalStorage = LocalStorage; + this.StatusService = StatusService; this.logo = this.StateManager.getState().application.logo; this.formValues = { @@ -33,6 +34,7 @@ class AuthenticationController { this.retrieveAndSaveEnabledExtensionsAsync = this.retrieveAndSaveEnabledExtensionsAsync.bind(this); this.retrievePermissionsAsync = this.retrievePermissionsAsync.bind(this); this.checkForEndpointsAsync = this.checkForEndpointsAsync.bind(this); + this.checkForLatestVersionAsync = this.checkForLatestVersionAsync.bind(this); this.postLoginSteps = this.postLoginSteps.bind(this); this.oAuthLoginAsync = this.oAuthLoginAsync.bind(this); @@ -134,10 +136,28 @@ class AuthenticationController { } } + async checkForLatestVersionAsync() { + let versionInfo = { + UpdateAvailable: false, + LatestVersion: '' + }; + + try { + const versionStatus = await this.StatusService.version(); + if (versionStatus.UpdateAvailable) { + versionInfo.UpdateAvailable = true; + versionInfo.LatestVersion = versionStatus.LatestVersion; + } + } finally { + this.StateManager.setVersionInfo(versionInfo); + } + } + async postLoginSteps() { await this.retrievePermissionsAsync(); await this.retrieveAndSaveEnabledExtensionsAsync(); await this.checkForEndpointsAsync(false); + await this.checkForLatestVersionAsync(); } /** * END POST LOGIN STEPS SECTION diff --git a/app/portainer/views/sidebar/sidebar.html b/app/portainer/views/sidebar/sidebar.html index fcc624920..a31864305 100644 --- a/app/portainer/views/sidebar/sidebar.html +++ b/app/portainer/views/sidebar/sidebar.html @@ -82,8 +82,14 @@
diff --git a/assets/css/app.css b/assets/css/app.css index 44ce2625d..543eed1f7 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -396,6 +396,14 @@ ul.sidebar .sidebar-list a.active { margin: 2px 0 2px 20px; } +.sidebar-footer-content .update-notification { + font-size: 14px; + padding: 12px; + border-radius: 2px; + background-color: #FF851B; + margin-bottom: 5px; +} + .sidebar-footer-content .version { font-size: 11px; margin: 11px 20px 0 7px; From ed70d0fb2b8479fc480c59d3f1b9bed3a6da334d Mon Sep 17 00:00:00 2001 From: Pierre Kisters Date: Sun, 29 Sep 2019 23:22:04 +0200 Subject: [PATCH 29/49] feat(build-system): bump Docker binary version to 19.03.2 (#3202) --- gruntfile.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gruntfile.js b/gruntfile.js index d12992dae..abdbb22d1 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -16,7 +16,7 @@ module.exports = function(grunt) { grunt.initConfig({ root: 'dist', distdir: 'dist/public', - shippedDockerVersion: '18.09.3', + shippedDockerVersion: '19.03.2', shippedDockerVersionWindows: '17.09.0-ce', config: gruntfile_cfg.config, env: gruntfile_cfg.env, From d4fa4d8a52ac9e65f208e89b9e0d92b00a85c72d Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 30 Sep 2019 14:03:59 +1300 Subject: [PATCH 30/49] fix(api): always persist data after initial extension check --- api/cmd/portainer/main.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 726be6da2..28b4238d8 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -499,8 +499,13 @@ func initExtensionManager(fileService portainer.FileService, extensionService po log.Printf("Unable to enable extension: %s [extension: %s]", err.Error(), extension.Name) extension.Enabled = false extension.License.Valid = false - extensionService.Persist(&extension) } + + err = extensionService.Persist(&extension) + if err != nil { + return nil, err + } + } return extensionManager, nil From 3ab042236125bd6488a7ddef7aed1987f1430b19 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Thu, 3 Oct 2019 11:23:07 +1300 Subject: [PATCH 31/49] Revert "feat(build-system): bump Docker binary version to 19.03.2 (#3202)" (#3210) This reverts commit ed70d0fb2b8479fc480c59d3f1b9bed3a6da334d. --- gruntfile.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gruntfile.js b/gruntfile.js index abdbb22d1..d12992dae 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -16,7 +16,7 @@ module.exports = function(grunt) { grunt.initConfig({ root: 'dist', distdir: 'dist/public', - shippedDockerVersion: '19.03.2', + shippedDockerVersion: '18.09.3', shippedDockerVersionWindows: '17.09.0-ce', config: gruntfile_cfg.config, env: gruntfile_cfg.env, From 81e9484dd3734eecd7021d5e9f45fb741890ee99 Mon Sep 17 00:00:00 2001 From: William Date: Thu, 3 Oct 2019 13:03:14 +1300 Subject: [PATCH 32/49] docs(project): add security info to readme (#3211) * docs(project): add security info to readme * docs(project): fix whitespace in previous commit --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index a2d2441d4..86363fab2 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,10 @@ Unlike the public demo, the playground sessions are deleted after 4 hours. Apart * Want to report a bug or request a feature? Please open [an issue](https://github.com/portainer/portainer/issues/new). * Want to help us build **_portainer_**? Follow our [contribution guidelines](https://portainer.readthedocs.io/en/latest/contribute.html) to build it locally and make a pull request. We need all the help we can get! +## Security + +* Here at Portainer, we believe in [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure) of security issues. If you have found a security issue, please report it to . + ## Limitations **_Portainer_** has full support for the following Docker versions: From 6c996377f5ef282e368114936513f759c33baf17 Mon Sep 17 00:00:00 2001 From: Frans-Jan van Steenbeek Date: Thu, 3 Oct 2019 04:37:34 +0200 Subject: [PATCH 33/49] fix(container-creation): prevent duplicate MAC addresses after edit (#1645) (#2993) --- .../views/containers/create/createContainerController.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/docker/views/containers/create/createContainerController.js b/app/docker/views/containers/create/createContainerController.js index c91e54523..73fb8e17b 100644 --- a/app/docker/views/containers/create/createContainerController.js +++ b/app/docker/views/containers/create/createContainerController.js @@ -402,16 +402,13 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai } } $scope.config.NetworkingConfig.EndpointsConfig[$scope.config.HostConfig.NetworkMode] = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode]; - // Mac Address - if(Object.keys(d.NetworkSettings.Networks).length) { + if(Object.keys(d.NetworkSettings.Networks).length > 1) { var firstNetwork = d.NetworkSettings.Networks[Object.keys(d.NetworkSettings.Networks)[0]]; - $scope.formValues.MacAddress = firstNetwork.MacAddress; $scope.config.NetworkingConfig.EndpointsConfig[$scope.config.HostConfig.NetworkMode] = firstNetwork; $scope.extraNetworks = angular.copy(d.NetworkSettings.Networks); delete $scope.extraNetworks[Object.keys(d.NetworkSettings.Networks)[0]]; - } else { - $scope.formValues.MacAddress = ''; } + $scope.formValues.MacAddress = d.Config.MacAddress; // ExtraHosts if ($scope.config.HostConfig.ExtraHosts) { From b7c38b9569b9611da9a2a59935257575608aa879 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 7 Oct 2019 15:42:01 +1300 Subject: [PATCH 34/49] feat(api): trigger user authorization update when required (#3213) * refactor(api): remove useless type cast * feat(api): trigger user authorization update when required * fix(api): fix missing RegistryService injection --- api/authorizations.go | 179 ++++++++++++++---- api/bolt/migrator/migrate_dbversion19.go | 16 +- .../endpointgroups/endpointgroup_delete.go | 9 + .../endpointgroups/endpointgroup_update.go | 7 +- api/http/handler/endpoints/endpoint_create.go | 30 ++- api/http/handler/endpoints/endpoint_delete.go | 7 + api/http/handler/endpoints/endpoint_update.go | 7 +- api/http/handler/extensions/upgrade.go | 21 +- api/http/handler/teammemberships/handler.go | 4 +- .../teammemberships/teammembership_create.go | 5 + .../teammemberships/teammembership_delete.go | 5 + api/http/handler/teams/handler.go | 6 +- api/http/handler/teams/team_delete.go | 5 + api/http/handler/users/handler.go | 1 + api/http/handler/users/user_delete.go | 9 +- api/http/server.go | 5 + 16 files changed, 238 insertions(+), 78 deletions(-) diff --git a/api/authorizations.go b/api/authorizations.go index a1e782123..c5dc5c967 100644 --- a/api/authorizations.go +++ b/api/authorizations.go @@ -5,6 +5,7 @@ package portainer type AuthorizationService struct { endpointService EndpointService endpointGroupService EndpointGroupService + registryService RegistryService roleService RoleService teamMembershipService TeamMembershipService userService UserService @@ -15,6 +16,7 @@ type AuthorizationService struct { type AuthorizationServiceParameters struct { EndpointService EndpointService EndpointGroupService EndpointGroupService + RegistryService RegistryService RoleService RoleService TeamMembershipService TeamMembershipService UserService UserService @@ -25,6 +27,7 @@ func NewAuthorizationService(parameters *AuthorizationServiceParameters) *Author return &AuthorizationService{ endpointService: parameters.EndpointService, endpointGroupService: parameters.EndpointGroupService, + registryService: parameters.RegistryService, roleService: parameters.RoleService, teamMembershipService: parameters.TeamMembershipService, userService: parameters.UserService, @@ -53,43 +56,145 @@ func DefaultPortainerAuthorizations() Authorizations { } } -// UpdateUserAuthorizationsFromPolicies will update users authorizations based on the specified access policies. -func (service *AuthorizationService) UpdateUserAuthorizationsFromPolicies(userPolicies *UserAccessPolicies, teamPolicies *TeamAccessPolicies) error { - - for userID, policy := range *userPolicies { - if policy.RoleID == 0 { - continue - } - - err := service.UpdateUserAuthorizations(userID) - if err != nil { - return err - } - } - - for teamID, policy := range *teamPolicies { - if policy.RoleID == 0 { - continue - } - - err := service.updateUserAuthorizationsInTeam(teamID) - if err != nil { - return err - } - } - - return nil -} - -func (service *AuthorizationService) updateUserAuthorizationsInTeam(teamID TeamID) error { - - memberships, err := service.teamMembershipService.TeamMembershipsByTeamID(teamID) +// RemoveTeamAccessPolicies will remove all existing access policies associated to the specified team +func (service *AuthorizationService) RemoveTeamAccessPolicies(teamID TeamID) error { + endpoints, err := service.endpointService.Endpoints() if err != nil { return err } - for _, membership := range memberships { - err := service.UpdateUserAuthorizations(membership.UserID) + for _, endpoint := range endpoints { + for policyTeamID := range endpoint.TeamAccessPolicies { + if policyTeamID == teamID { + delete(endpoint.TeamAccessPolicies, policyTeamID) + + err := service.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + + break + } + } + } + + endpointGroups, err := service.endpointGroupService.EndpointGroups() + if err != nil { + return err + } + + for _, endpointGroup := range endpointGroups { + for policyTeamID := range endpointGroup.TeamAccessPolicies { + if policyTeamID == teamID { + delete(endpointGroup.TeamAccessPolicies, policyTeamID) + + err := service.endpointGroupService.UpdateEndpointGroup(endpointGroup.ID, &endpointGroup) + if err != nil { + return err + } + + break + } + } + } + + registries, err := service.registryService.Registries() + if err != nil { + return err + } + + for _, registry := range registries { + for policyTeamID := range registry.TeamAccessPolicies { + if policyTeamID == teamID { + delete(registry.TeamAccessPolicies, policyTeamID) + + err := service.registryService.UpdateRegistry(registry.ID, ®istry) + if err != nil { + return err + } + + break + } + } + } + + return nil +} + +// RemoveUserAccessPolicies will remove all existing access policies associated to the specified user +func (service *AuthorizationService) RemoveUserAccessPolicies(userID UserID) error { + endpoints, err := service.endpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range endpoints { + for policyUserID := range endpoint.UserAccessPolicies { + if policyUserID == userID { + delete(endpoint.UserAccessPolicies, policyUserID) + + err := service.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + + break + } + } + } + + endpointGroups, err := service.endpointGroupService.EndpointGroups() + if err != nil { + return err + } + + for _, endpointGroup := range endpointGroups { + for policyUserID := range endpointGroup.UserAccessPolicies { + if policyUserID == userID { + delete(endpointGroup.UserAccessPolicies, policyUserID) + + err := service.endpointGroupService.UpdateEndpointGroup(endpointGroup.ID, &endpointGroup) + if err != nil { + return err + } + + break + } + } + } + + registries, err := service.registryService.Registries() + if err != nil { + return err + } + + for _, registry := range registries { + for policyUserID := range registry.UserAccessPolicies { + if policyUserID == userID { + delete(registry.UserAccessPolicies, policyUserID) + + err := service.registryService.UpdateRegistry(registry.ID, ®istry) + if err != nil { + return err + } + + break + } + } + } + + return nil +} + +// UpdateUsersAuthorizations will trigger an update of the authorizations for all the users. +func (service *AuthorizationService) UpdateUsersAuthorizations() error { + users, err := service.userService.Users() + if err != nil { + return err + } + + for _, user := range users { + err := service.updateUserAuthorizations(user.ID) if err != nil { return err } @@ -98,8 +203,7 @@ func (service *AuthorizationService) updateUserAuthorizationsInTeam(teamID TeamI return nil } -// UpdateUserAuthorizations will trigger an update of the authorizations for the specified user. -func (service *AuthorizationService) UpdateUserAuthorizations(userID UserID) error { +func (service *AuthorizationService) updateUserAuthorizations(userID UserID) error { user, err := service.userService.User(userID) if err != nil { return err @@ -175,7 +279,10 @@ func getUserEndpointAuthorizations(user *User, endpoints []Endpoint, endpointGro continue } - endpointAuthorizations[endpoint.ID] = getAuthorizationsFromTeamEndpointGroupPolicies(userMemberships, &endpoint, roles, groupTeamAccessPolicies) + authorizations = getAuthorizationsFromTeamEndpointGroupPolicies(userMemberships, &endpoint, roles, groupTeamAccessPolicies) + if len(authorizations) > 0 { + endpointAuthorizations[endpoint.ID] = authorizations + } } return endpointAuthorizations diff --git a/api/bolt/migrator/migrate_dbversion19.go b/api/bolt/migrator/migrate_dbversion19.go index c6020e658..569bc43a9 100644 --- a/api/bolt/migrator/migrate_dbversion19.go +++ b/api/bolt/migrator/migrate_dbversion19.go @@ -3,27 +3,15 @@ package migrator import portainer "github.com/portainer/portainer/api" func (m *Migrator) updateUsersToDBVersion20() error { - legacyUsers, err := m.userService.Users() - if err != nil { - return err - } - authorizationServiceParameters := &portainer.AuthorizationServiceParameters{ EndpointService: m.endpointService, EndpointGroupService: m.endpointGroupService, + RegistryService: m.registryService, RoleService: m.roleService, TeamMembershipService: m.teamMembershipService, UserService: m.userService, } authorizationService := portainer.NewAuthorizationService(authorizationServiceParameters) - - for _, user := range legacyUsers { - err := authorizationService.UpdateUserAuthorizations(user.ID) - if err != nil { - return err - } - } - - return nil + return authorizationService.UpdateUsersAuthorizations() } diff --git a/api/http/handler/endpointgroups/endpointgroup_delete.go b/api/http/handler/endpointgroups/endpointgroup_delete.go index a7a3c0a79..dbb634eff 100644 --- a/api/http/handler/endpointgroups/endpointgroup_delete.go +++ b/api/http/handler/endpointgroups/endpointgroup_delete.go @@ -37,8 +37,10 @@ func (handler *Handler) endpointGroupDelete(w http.ResponseWriter, r *http.Reque return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} } + updateAuthorizations := false for _, endpoint := range endpoints { if endpoint.GroupID == portainer.EndpointGroupID(endpointGroupID) { + updateAuthorizations = true endpoint.GroupID = portainer.EndpointGroupID(1) err = handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) if err != nil { @@ -47,5 +49,12 @@ func (handler *Handler) endpointGroupDelete(w http.ResponseWriter, r *http.Reque } } + if updateAuthorizations { + err = handler.AuthorizationService.UpdateUsersAuthorizations() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} + } + } + return response.Empty(w) } diff --git a/api/http/handler/endpointgroups/endpointgroup_update.go b/api/http/handler/endpointgroups/endpointgroup_update.go index c21c3a452..58ea605fc 100644 --- a/api/http/handler/endpointgroups/endpointgroup_update.go +++ b/api/http/handler/endpointgroups/endpointgroup_update.go @@ -2,6 +2,7 @@ package endpointgroups import ( "net/http" + "reflect" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" @@ -54,12 +55,12 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque } updateAuthorizations := false - if payload.UserAccessPolicies != nil { + if payload.UserAccessPolicies != nil && !reflect.DeepEqual(payload.UserAccessPolicies, endpointGroup.UserAccessPolicies) { endpointGroup.UserAccessPolicies = payload.UserAccessPolicies updateAuthorizations = true } - if payload.TeamAccessPolicies != nil { + if payload.TeamAccessPolicies != nil && !reflect.DeepEqual(payload.TeamAccessPolicies, endpointGroup.TeamAccessPolicies) { endpointGroup.TeamAccessPolicies = payload.TeamAccessPolicies updateAuthorizations = true } @@ -70,7 +71,7 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque } if updateAuthorizations { - err = handler.AuthorizationService.UpdateUserAuthorizationsFromPolicies(&payload.UserAccessPolicies, &payload.TeamAccessPolicies) + err = handler.AuthorizationService.UpdateUsersAuthorizations() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} } diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index 1ce454afa..865923149 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -192,9 +192,9 @@ func (handler *Handler) createAzureEndpoint(payload *endpointCreatePayload) (*po Snapshots: []portainer.Snapshot{}, } - err = handler.EndpointService.CreateEndpoint(endpoint) + err = handler.saveEndpointAndUpdateAuthorizations(endpoint) if err != nil { - return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint inside the database", err} + return nil, &httperror.HandlerError{http.StatusInternalServerError, "An error occured while trying to create the endpoint", err} } return endpoint, nil @@ -238,9 +238,9 @@ func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload) EdgeKey: edgeKey, } - err = handler.EndpointService.CreateEndpoint(endpoint) + err = handler.saveEndpointAndUpdateAuthorizations(endpoint) if err != nil { - return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint inside the database", err} + return nil, &httperror.HandlerError{http.StatusInternalServerError, "An error occured while trying to create the endpoint", err} } return endpoint, nil @@ -354,9 +354,27 @@ func (handler *Handler) snapshotAndPersistEndpoint(endpoint *portainer.Endpoint) endpoint.Snapshots = []portainer.Snapshot{*snapshot} } - err = handler.EndpointService.CreateEndpoint(endpoint) + err = handler.saveEndpointAndUpdateAuthorizations(endpoint) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "An error occured while trying to create the endpoint", err} + } + + return nil +} + +func (handler *Handler) saveEndpointAndUpdateAuthorizations(endpoint *portainer.Endpoint) error { + err := handler.EndpointService.CreateEndpoint(endpoint) + if err != nil { + return err + } + + group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) + if err != nil { + return err + } + + if len(group.UserAccessPolicies) > 0 || len(group.TeamAccessPolicies) > 0 { + return handler.AuthorizationService.UpdateUsersAuthorizations() } return nil diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go index ca7af3aa9..4d82f9c60 100644 --- a/api/http/handler/endpoints/endpoint_delete.go +++ b/api/http/handler/endpoints/endpoint_delete.go @@ -43,5 +43,12 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) * handler.ProxyManager.DeleteProxy(endpoint) + if len(endpoint.UserAccessPolicies) > 0 || len(endpoint.TeamAccessPolicies) > 0 { + err = handler.AuthorizationService.UpdateUsersAuthorizations() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} + } + } + return response.Empty(w) } diff --git a/api/http/handler/endpoints/endpoint_update.go b/api/http/handler/endpoints/endpoint_update.go index 9ff91113c..6816ec0d7 100644 --- a/api/http/handler/endpoints/endpoint_update.go +++ b/api/http/handler/endpoints/endpoint_update.go @@ -2,6 +2,7 @@ package endpoints import ( "net/http" + "reflect" "strconv" httperror "github.com/portainer/libhttp/error" @@ -77,12 +78,12 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * } updateAuthorizations := false - if payload.UserAccessPolicies != nil { + if payload.UserAccessPolicies != nil && !reflect.DeepEqual(payload.UserAccessPolicies, endpoint.UserAccessPolicies) { endpoint.UserAccessPolicies = payload.UserAccessPolicies updateAuthorizations = true } - if payload.TeamAccessPolicies != nil { + if payload.TeamAccessPolicies != nil && !reflect.DeepEqual(payload.TeamAccessPolicies, endpoint.TeamAccessPolicies) { endpoint.TeamAccessPolicies = payload.TeamAccessPolicies updateAuthorizations = true } @@ -177,7 +178,7 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * } if updateAuthorizations { - err = handler.AuthorizationService.UpdateUserAuthorizationsFromPolicies(&payload.UserAccessPolicies, &payload.TeamAccessPolicies) + err = handler.AuthorizationService.UpdateUsersAuthorizations() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} } diff --git a/api/http/handler/extensions/upgrade.go b/api/http/handler/extensions/upgrade.go index da1676df8..2d314638a 100644 --- a/api/http/handler/extensions/upgrade.go +++ b/api/http/handler/extensions/upgrade.go @@ -36,10 +36,10 @@ func (handler *Handler) upgradeRBACData() error { return err } - err = handler.AuthorizationService.UpdateUserAuthorizationsFromPolicies(&endpointGroup.UserAccessPolicies, &endpointGroup.TeamAccessPolicies) - if err != nil { - return err - } + //err = handler.AuthorizationService.UpdateUserAuthorizationsFromPolicies(&endpointGroup.UserAccessPolicies, &endpointGroup.TeamAccessPolicies) + //if err != nil { + // return err + //} } endpoints, err := handler.EndpointService.Endpoints() @@ -61,10 +61,13 @@ func (handler *Handler) upgradeRBACData() error { return err } - err = handler.AuthorizationService.UpdateUserAuthorizationsFromPolicies(&endpoint.UserAccessPolicies, &endpoint.TeamAccessPolicies) - if err != nil { - return err - } + //err = handler.AuthorizationService.UpdateUserAuthorizationsFromPolicies(&endpoint.UserAccessPolicies, &endpoint.TeamAccessPolicies) + //if err != nil { + // return err + //} } - return nil + + return handler.AuthorizationService.UpdateUsersAuthorizations() + + //return nil } diff --git a/api/http/handler/teammemberships/handler.go b/api/http/handler/teammemberships/handler.go index 3fd56bfc0..018f55007 100644 --- a/api/http/handler/teammemberships/handler.go +++ b/api/http/handler/teammemberships/handler.go @@ -13,8 +13,8 @@ import ( // Handler is the HTTP handler used to handle team membership operations. type Handler struct { *mux.Router - TeamMembershipService portainer.TeamMembershipService - ResourceControlService portainer.ResourceControlService + TeamMembershipService portainer.TeamMembershipService + AuthorizationService *portainer.AuthorizationService } // NewHandler creates a handler to manage team membership operations. diff --git a/api/http/handler/teammemberships/teammembership_create.go b/api/http/handler/teammemberships/teammembership_create.go index 0d8716de9..245b1fe67 100644 --- a/api/http/handler/teammemberships/teammembership_create.go +++ b/api/http/handler/teammemberships/teammembership_create.go @@ -70,5 +70,10 @@ func (handler *Handler) teamMembershipCreate(w http.ResponseWriter, r *http.Requ return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist team memberships inside the database", err} } + err = handler.AuthorizationService.UpdateUsersAuthorizations() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} + } + return response.JSON(w, membership) } diff --git a/api/http/handler/teammemberships/teammembership_delete.go b/api/http/handler/teammemberships/teammembership_delete.go index dd8fe6d0b..179bbe800 100644 --- a/api/http/handler/teammemberships/teammembership_delete.go +++ b/api/http/handler/teammemberships/teammembership_delete.go @@ -38,5 +38,10 @@ func (handler *Handler) teamMembershipDelete(w http.ResponseWriter, r *http.Requ return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the team membership from the database", err} } + err = handler.AuthorizationService.UpdateUsersAuthorizations() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} + } + return response.Empty(w) } diff --git a/api/http/handler/teams/handler.go b/api/http/handler/teams/handler.go index 1aad28ef0..076901531 100644 --- a/api/http/handler/teams/handler.go +++ b/api/http/handler/teams/handler.go @@ -12,9 +12,9 @@ import ( // Handler is the HTTP handler used to handle team operations. type Handler struct { *mux.Router - TeamService portainer.TeamService - TeamMembershipService portainer.TeamMembershipService - ResourceControlService portainer.ResourceControlService + TeamService portainer.TeamService + TeamMembershipService portainer.TeamMembershipService + AuthorizationService *portainer.AuthorizationService } // NewHandler creates a handler to manage team operations. diff --git a/api/http/handler/teams/team_delete.go b/api/http/handler/teams/team_delete.go index f38010725..2b96e9351 100644 --- a/api/http/handler/teams/team_delete.go +++ b/api/http/handler/teams/team_delete.go @@ -33,5 +33,10 @@ func (handler *Handler) teamDelete(w http.ResponseWriter, r *http.Request) *http return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete associated team memberships from the database", err} } + err = handler.AuthorizationService.RemoveTeamAccessPolicies(portainer.TeamID(teamID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clean-up team access policies", err} + } + return response.Empty(w) } diff --git a/api/http/handler/users/handler.go b/api/http/handler/users/handler.go index 8e7f5035b..8cb5629d4 100644 --- a/api/http/handler/users/handler.go +++ b/api/http/handler/users/handler.go @@ -23,6 +23,7 @@ type Handler struct { ResourceControlService portainer.ResourceControlService CryptoService portainer.CryptoService SettingsService portainer.SettingsService + AuthorizationService *portainer.AuthorizationService } // NewHandler creates a handler to manage user operations. diff --git a/api/http/handler/users/user_delete.go b/api/http/handler/users/user_delete.go index c600e1943..203a3be47 100644 --- a/api/http/handler/users/user_delete.go +++ b/api/http/handler/users/user_delete.go @@ -65,15 +65,20 @@ func (handler *Handler) deleteAdminUser(w http.ResponseWriter, user *portainer.U } func (handler *Handler) deleteUser(w http.ResponseWriter, user *portainer.User) *httperror.HandlerError { - err := handler.UserService.DeleteUser(portainer.UserID(user.ID)) + err := handler.UserService.DeleteUser(user.ID) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove user from the database", err} } - err = handler.TeamMembershipService.DeleteTeamMembershipByUserID(portainer.UserID(user.ID)) + err = handler.TeamMembershipService.DeleteTeamMembershipByUserID(user.ID) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove user memberships from the database", err} } + err = handler.AuthorizationService.RemoveUserAccessPolicies(user.ID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clean-up user access policies", err} + } + return response.Empty(w) } diff --git a/api/http/server.go b/api/http/server.go index 5810f4b2b..42d3751db 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -97,6 +97,7 @@ func (server *Server) Start() error { authorizationServiceParameters := &portainer.AuthorizationServiceParameters{ EndpointService: server.EndpointService, EndpointGroupService: server.EndpointGroupService, + RegistryService: server.RegistryService, RoleService: server.RoleService, TeamMembershipService: server.TeamMembershipService, UserService: server.UserService, @@ -213,9 +214,12 @@ func (server *Server) Start() error { var teamHandler = teams.NewHandler(requestBouncer) teamHandler.TeamService = server.TeamService teamHandler.TeamMembershipService = server.TeamMembershipService + teamHandler.AuthorizationService = authorizationService var teamMembershipHandler = teammemberships.NewHandler(requestBouncer) teamMembershipHandler.TeamMembershipService = server.TeamMembershipService + teamMembershipHandler.AuthorizationService = authorizationService + var statusHandler = status.NewHandler(requestBouncer, server.Status) var templatesHandler = templates.NewHandler(requestBouncer) @@ -232,6 +236,7 @@ func (server *Server) Start() error { userHandler.CryptoService = server.CryptoService userHandler.ResourceControlService = server.ResourceControlService userHandler.SettingsService = server.SettingsService + userHandler.AuthorizationService = authorizationService var websocketHandler = websocket.NewHandler(requestBouncer) websocketHandler.EndpointService = server.EndpointService From 1fbe6a12f1bd6ad1a74190efa76f31681c43ce4d Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 7 Oct 2019 16:09:35 +1300 Subject: [PATCH 35/49] fix(api): fix invalid resource control check (#3225) --- api/http/proxy/docker_transport.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/http/proxy/docker_transport.go b/api/http/proxy/docker_transport.go index 8c1a2e28c..c5eb96a64 100644 --- a/api/http/proxy/docker_transport.go +++ b/api/http/proxy/docker_transport.go @@ -361,7 +361,7 @@ func (p *proxyTransport) restrictedOperation(request *http.Request, resourceID s } resourceControl := getResourceControlByResourceID(resourceID, resourceControls) - if resourceControl != nil && !canUserAccessResource(tokenData.ID, userTeamIDs, resourceControl) { + if resourceControl == nil || !canUserAccessResource(tokenData.ID, userTeamIDs, resourceControl) { return writeAccessDeniedResponse() } } From f7480c4ad44ae853c9ea2fce75debf59cf1b4371 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 7 Oct 2019 16:10:51 +1300 Subject: [PATCH 36/49] feat(api): prevent non administrator users to use admin restricted API endpoints (#3227) --- api/http/handler/dockerhub/handler.go | 4 +- api/http/handler/endpointgroups/handler.go | 14 +++---- api/http/handler/endpointproxy/handler.go | 6 +-- api/http/handler/endpoints/handler.go | 20 +++++----- api/http/handler/extensions/handler.go | 10 ++--- api/http/handler/motd/handler.go | 2 +- api/http/handler/registries/handler.go | 14 +++---- api/http/handler/resourcecontrols/handler.go | 6 +-- api/http/handler/roles/handler.go | 2 +- api/http/handler/schedules/handler.go | 14 +++---- api/http/handler/settings/handler.go | 6 +-- api/http/handler/stacks/handler.go | 14 +++---- api/http/handler/tags/handler.go | 6 +-- api/http/handler/teammemberships/handler.go | 8 ++-- api/http/handler/teams/handler.go | 12 +++--- api/http/handler/templates/handler.go | 10 ++--- api/http/handler/upload/handler.go | 2 +- api/http/handler/users/handler.go | 12 +++--- api/http/handler/webhooks/handler.go | 6 +-- api/http/handler/websocket/handler.go | 4 +- api/http/security/bouncer.go | 39 ++++++++++++++++---- 21 files changed, 118 insertions(+), 93 deletions(-) diff --git a/api/http/handler/dockerhub/handler.go b/api/http/handler/dockerhub/handler.go index 7e2cb4bd0..ba4ed2c34 100644 --- a/api/http/handler/dockerhub/handler.go +++ b/api/http/handler/dockerhub/handler.go @@ -25,9 +25,9 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), } h.Handle("/dockerhub", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.dockerhubInspect))).Methods(http.MethodGet) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.dockerhubInspect))).Methods(http.MethodGet) h.Handle("/dockerhub", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.dockerhubUpdate))).Methods(http.MethodPut) + bouncer.AdminAccess(httperror.LoggerHandler(h.dockerhubUpdate))).Methods(http.MethodPut) return h } diff --git a/api/http/handler/endpointgroups/handler.go b/api/http/handler/endpointgroups/handler.go index 785a7adfc..d4a36d3f5 100644 --- a/api/http/handler/endpointgroups/handler.go +++ b/api/http/handler/endpointgroups/handler.go @@ -23,18 +23,18 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), } h.Handle("/endpoint_groups", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointGroupCreate))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointGroupCreate))).Methods(http.MethodPost) h.Handle("/endpoint_groups", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointGroupList))).Methods(http.MethodGet) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointGroupList))).Methods(http.MethodGet) h.Handle("/endpoint_groups/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointGroupInspect))).Methods(http.MethodGet) + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointGroupInspect))).Methods(http.MethodGet) h.Handle("/endpoint_groups/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointGroupUpdate))).Methods(http.MethodPut) + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointGroupUpdate))).Methods(http.MethodPut) h.Handle("/endpoint_groups/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointGroupDelete))).Methods(http.MethodDelete) + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointGroupDelete))).Methods(http.MethodDelete) h.Handle("/endpoint_groups/{id}/endpoints/{endpointId}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointGroupAddEndpoint))).Methods(http.MethodPut) + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointGroupAddEndpoint))).Methods(http.MethodPut) h.Handle("/endpoint_groups/{id}/endpoints/{endpointId}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointGroupDeleteEndpoint))).Methods(http.MethodDelete) + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointGroupDeleteEndpoint))).Methods(http.MethodDelete) return h } diff --git a/api/http/handler/endpointproxy/handler.go b/api/http/handler/endpointproxy/handler.go index 69db8f54d..be89bb750 100644 --- a/api/http/handler/endpointproxy/handler.go +++ b/api/http/handler/endpointproxy/handler.go @@ -25,10 +25,10 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { requestBouncer: bouncer, } h.PathPrefix("/{id}/azure").Handler( - bouncer.RestrictedAccess(httperror.LoggerHandler(h.proxyRequestsToAzureAPI))) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToAzureAPI))) h.PathPrefix("/{id}/docker").Handler( - bouncer.RestrictedAccess(httperror.LoggerHandler(h.proxyRequestsToDockerAPI))) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToDockerAPI))) h.PathPrefix("/{id}/storidge").Handler( - bouncer.RestrictedAccess(httperror.LoggerHandler(h.proxyRequestsToStoridgeAPI))) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToStoridgeAPI))) return h } diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index 908df24fa..c655a0eef 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -49,25 +49,25 @@ func NewHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bo } h.Handle("/endpoints", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointCreate))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointCreate))).Methods(http.MethodPost) h.Handle("/endpoints/snapshot", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointSnapshots))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSnapshots))).Methods(http.MethodPost) h.Handle("/endpoints", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointList))).Methods(http.MethodGet) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointList))).Methods(http.MethodGet) h.Handle("/endpoints/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointInspect))).Methods(http.MethodGet) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointInspect))).Methods(http.MethodGet) h.Handle("/endpoints/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointUpdate))).Methods(http.MethodPut) + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointUpdate))).Methods(http.MethodPut) h.Handle("/endpoints/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointDelete))).Methods(http.MethodDelete) + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDelete))).Methods(http.MethodDelete) h.Handle("/endpoints/{id}/extensions", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointExtensionAdd))).Methods(http.MethodPost) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointExtensionAdd))).Methods(http.MethodPost) h.Handle("/endpoints/{id}/extensions/{extensionType}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointExtensionRemove))).Methods(http.MethodDelete) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointExtensionRemove))).Methods(http.MethodDelete) h.Handle("/endpoints/{id}/job", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointJob))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointJob))).Methods(http.MethodPost) h.Handle("/endpoints/{id}/snapshot", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost) h.Handle("/endpoints/{id}/status", bouncer.PublicAccess(httperror.LoggerHandler(h.endpointStatusInspect))).Methods(http.MethodGet) diff --git a/api/http/handler/extensions/handler.go b/api/http/handler/extensions/handler.go index 9562d695c..d77347594 100644 --- a/api/http/handler/extensions/handler.go +++ b/api/http/handler/extensions/handler.go @@ -27,15 +27,15 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { } h.Handle("/extensions", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.extensionList))).Methods(http.MethodGet) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.extensionList))).Methods(http.MethodGet) h.Handle("/extensions", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.extensionCreate))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.extensionCreate))).Methods(http.MethodPost) h.Handle("/extensions/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.extensionInspect))).Methods(http.MethodGet) + bouncer.AdminAccess(httperror.LoggerHandler(h.extensionInspect))).Methods(http.MethodGet) h.Handle("/extensions/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.extensionDelete))).Methods(http.MethodDelete) + bouncer.AdminAccess(httperror.LoggerHandler(h.extensionDelete))).Methods(http.MethodDelete) h.Handle("/extensions/{id}/update", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.extensionUpdate))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.extensionUpdate))).Methods(http.MethodPost) return h } diff --git a/api/http/handler/motd/handler.go b/api/http/handler/motd/handler.go index aa2d1d002..f7aa79e84 100644 --- a/api/http/handler/motd/handler.go +++ b/api/http/handler/motd/handler.go @@ -18,7 +18,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), } h.Handle("/motd", - bouncer.AuthorizedAccess(http.HandlerFunc(h.motd))).Methods(http.MethodGet) + bouncer.RestrictedAccess(http.HandlerFunc(h.motd))).Methods(http.MethodGet) return h } diff --git a/api/http/handler/registries/handler.go b/api/http/handler/registries/handler.go index 3c90e6e67..202a81fdc 100644 --- a/api/http/handler/registries/handler.go +++ b/api/http/handler/registries/handler.go @@ -33,19 +33,19 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { } h.Handle("/registries", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.registryCreate))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.registryCreate))).Methods(http.MethodPost) h.Handle("/registries", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.registryList))).Methods(http.MethodGet) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.registryList))).Methods(http.MethodGet) h.Handle("/registries/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.registryInspect))).Methods(http.MethodGet) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.registryInspect))).Methods(http.MethodGet) h.Handle("/registries/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.registryUpdate))).Methods(http.MethodPut) + bouncer.AdminAccess(httperror.LoggerHandler(h.registryUpdate))).Methods(http.MethodPut) h.Handle("/registries/{id}/configure", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.registryConfigure))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.registryConfigure))).Methods(http.MethodPost) h.Handle("/registries/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.registryDelete))).Methods(http.MethodDelete) + bouncer.AdminAccess(httperror.LoggerHandler(h.registryDelete))).Methods(http.MethodDelete) h.PathPrefix("/registries/{id}/v2").Handler( - bouncer.RestrictedAccess(httperror.LoggerHandler(h.proxyRequestsToRegistryAPI))) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToRegistryAPI))) return h } diff --git a/api/http/handler/resourcecontrols/handler.go b/api/http/handler/resourcecontrols/handler.go index d187ba6c8..a2227f2c8 100644 --- a/api/http/handler/resourcecontrols/handler.go +++ b/api/http/handler/resourcecontrols/handler.go @@ -21,11 +21,11 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), } h.Handle("/resource_controls", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.resourceControlCreate))).Methods(http.MethodPost) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.resourceControlCreate))).Methods(http.MethodPost) h.Handle("/resource_controls/{id}", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.resourceControlUpdate))).Methods(http.MethodPut) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.resourceControlUpdate))).Methods(http.MethodPut) h.Handle("/resource_controls/{id}", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.resourceControlDelete))).Methods(http.MethodDelete) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.resourceControlDelete))).Methods(http.MethodDelete) return h } diff --git a/api/http/handler/roles/handler.go b/api/http/handler/roles/handler.go index e6bb7c4c7..89ec52452 100644 --- a/api/http/handler/roles/handler.go +++ b/api/http/handler/roles/handler.go @@ -21,7 +21,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), } h.Handle("/roles", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.roleList))).Methods(http.MethodGet) + bouncer.AdminAccess(httperror.LoggerHandler(h.roleList))).Methods(http.MethodGet) return h } diff --git a/api/http/handler/schedules/handler.go b/api/http/handler/schedules/handler.go index 303178c25..cc7d3dbf2 100644 --- a/api/http/handler/schedules/handler.go +++ b/api/http/handler/schedules/handler.go @@ -28,18 +28,18 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { } h.Handle("/schedules", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.scheduleList))).Methods(http.MethodGet) + bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleList))).Methods(http.MethodGet) h.Handle("/schedules", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.scheduleCreate))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleCreate))).Methods(http.MethodPost) h.Handle("/schedules/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.scheduleInspect))).Methods(http.MethodGet) + bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleInspect))).Methods(http.MethodGet) h.Handle("/schedules/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.scheduleUpdate))).Methods(http.MethodPut) + bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleUpdate))).Methods(http.MethodPut) h.Handle("/schedules/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.scheduleDelete))).Methods(http.MethodDelete) + bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleDelete))).Methods(http.MethodDelete) h.Handle("/schedules/{id}/file", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.scheduleFile))).Methods(http.MethodGet) + bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleFile))).Methods(http.MethodGet) h.Handle("/schedules/{id}/tasks", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.scheduleTasks))).Methods(http.MethodGet) + bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleTasks))).Methods(http.MethodGet) return h } diff --git a/api/http/handler/settings/handler.go b/api/http/handler/settings/handler.go index 2a5348b2e..db22c92ab 100644 --- a/api/http/handler/settings/handler.go +++ b/api/http/handler/settings/handler.go @@ -30,13 +30,13 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), } h.Handle("/settings", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.settingsInspect))).Methods(http.MethodGet) + bouncer.AdminAccess(httperror.LoggerHandler(h.settingsInspect))).Methods(http.MethodGet) h.Handle("/settings", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.settingsUpdate))).Methods(http.MethodPut) + bouncer.AdminAccess(httperror.LoggerHandler(h.settingsUpdate))).Methods(http.MethodPut) h.Handle("/settings/public", bouncer.PublicAccess(httperror.LoggerHandler(h.settingsPublic))).Methods(http.MethodGet) h.Handle("/settings/authentication/checkLDAP", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.settingsLDAPCheck))).Methods(http.MethodPut) + bouncer.AdminAccess(httperror.LoggerHandler(h.settingsLDAPCheck))).Methods(http.MethodPut) return h } diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go index caf181bef..cdfe9ea0d 100644 --- a/api/http/handler/stacks/handler.go +++ b/api/http/handler/stacks/handler.go @@ -36,18 +36,18 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { requestBouncer: bouncer, } h.Handle("/stacks", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackCreate))).Methods(http.MethodPost) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackCreate))).Methods(http.MethodPost) h.Handle("/stacks", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackList))).Methods(http.MethodGet) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackList))).Methods(http.MethodGet) h.Handle("/stacks/{id}", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackInspect))).Methods(http.MethodGet) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackInspect))).Methods(http.MethodGet) h.Handle("/stacks/{id}", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackDelete))).Methods(http.MethodDelete) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackDelete))).Methods(http.MethodDelete) h.Handle("/stacks/{id}", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackUpdate))).Methods(http.MethodPut) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackUpdate))).Methods(http.MethodPut) h.Handle("/stacks/{id}/file", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackFile))).Methods(http.MethodGet) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackFile))).Methods(http.MethodGet) h.Handle("/stacks/{id}/migrate", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackMigrate))).Methods(http.MethodPost) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackMigrate))).Methods(http.MethodPost) return h } diff --git a/api/http/handler/tags/handler.go b/api/http/handler/tags/handler.go index d6461e2dc..33cb59c9d 100644 --- a/api/http/handler/tags/handler.go +++ b/api/http/handler/tags/handler.go @@ -21,11 +21,11 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), } h.Handle("/tags", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.tagCreate))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.tagCreate))).Methods(http.MethodPost) h.Handle("/tags", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.tagList))).Methods(http.MethodGet) + bouncer.AdminAccess(httperror.LoggerHandler(h.tagList))).Methods(http.MethodGet) h.Handle("/tags/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.tagDelete))).Methods(http.MethodDelete) + bouncer.AdminAccess(httperror.LoggerHandler(h.tagDelete))).Methods(http.MethodDelete) return h } diff --git a/api/http/handler/teammemberships/handler.go b/api/http/handler/teammemberships/handler.go index 018f55007..0428241ec 100644 --- a/api/http/handler/teammemberships/handler.go +++ b/api/http/handler/teammemberships/handler.go @@ -23,13 +23,13 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), } h.Handle("/team_memberships", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.teamMembershipCreate))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.teamMembershipCreate))).Methods(http.MethodPost) h.Handle("/team_memberships", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.teamMembershipList))).Methods(http.MethodGet) + bouncer.AdminAccess(httperror.LoggerHandler(h.teamMembershipList))).Methods(http.MethodGet) h.Handle("/team_memberships/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.teamMembershipUpdate))).Methods(http.MethodPut) + bouncer.AdminAccess(httperror.LoggerHandler(h.teamMembershipUpdate))).Methods(http.MethodPut) h.Handle("/team_memberships/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.teamMembershipDelete))).Methods(http.MethodDelete) + bouncer.AdminAccess(httperror.LoggerHandler(h.teamMembershipDelete))).Methods(http.MethodDelete) return h } diff --git a/api/http/handler/teams/handler.go b/api/http/handler/teams/handler.go index 076901531..e5eea77fc 100644 --- a/api/http/handler/teams/handler.go +++ b/api/http/handler/teams/handler.go @@ -23,17 +23,17 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), } h.Handle("/teams", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.teamCreate))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.teamCreate))).Methods(http.MethodPost) h.Handle("/teams", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.teamList))).Methods(http.MethodGet) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.teamList))).Methods(http.MethodGet) h.Handle("/teams/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.teamInspect))).Methods(http.MethodGet) + bouncer.AdminAccess(httperror.LoggerHandler(h.teamInspect))).Methods(http.MethodGet) h.Handle("/teams/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.teamUpdate))).Methods(http.MethodPut) + bouncer.AdminAccess(httperror.LoggerHandler(h.teamUpdate))).Methods(http.MethodPut) h.Handle("/teams/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.teamDelete))).Methods(http.MethodDelete) + bouncer.AdminAccess(httperror.LoggerHandler(h.teamDelete))).Methods(http.MethodDelete) h.Handle("/teams/{id}/memberships", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.teamMemberships))).Methods(http.MethodGet) + bouncer.AdminAccess(httperror.LoggerHandler(h.teamMemberships))).Methods(http.MethodGet) return h } diff --git a/api/http/handler/templates/handler.go b/api/http/handler/templates/handler.go index 026b137ee..3eac57b4a 100644 --- a/api/http/handler/templates/handler.go +++ b/api/http/handler/templates/handler.go @@ -27,15 +27,15 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { } h.Handle("/templates", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.templateList))).Methods(http.MethodGet) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.templateList))).Methods(http.MethodGet) h.Handle("/templates", - bouncer.AuthorizedAccess(h.templateManagementCheck(httperror.LoggerHandler(h.templateCreate)))).Methods(http.MethodPost) + bouncer.AdminAccess(h.templateManagementCheck(httperror.LoggerHandler(h.templateCreate)))).Methods(http.MethodPost) h.Handle("/templates/{id}", - bouncer.AuthorizedAccess(h.templateManagementCheck(httperror.LoggerHandler(h.templateInspect)))).Methods(http.MethodGet) + bouncer.RestrictedAccess(h.templateManagementCheck(httperror.LoggerHandler(h.templateInspect)))).Methods(http.MethodGet) h.Handle("/templates/{id}", - bouncer.AuthorizedAccess(h.templateManagementCheck(httperror.LoggerHandler(h.templateUpdate)))).Methods(http.MethodPut) + bouncer.AdminAccess(h.templateManagementCheck(httperror.LoggerHandler(h.templateUpdate)))).Methods(http.MethodPut) h.Handle("/templates/{id}", - bouncer.AuthorizedAccess(h.templateManagementCheck(httperror.LoggerHandler(h.templateDelete)))).Methods(http.MethodDelete) + bouncer.AdminAccess(h.templateManagementCheck(httperror.LoggerHandler(h.templateDelete)))).Methods(http.MethodDelete) return h } diff --git a/api/http/handler/upload/handler.go b/api/http/handler/upload/handler.go index fe3060dac..dd6a459a1 100644 --- a/api/http/handler/upload/handler.go +++ b/api/http/handler/upload/handler.go @@ -22,6 +22,6 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), } h.Handle("/upload/tls/{certificate:(?:ca|cert|key)}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.uploadTLS))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.uploadTLS))).Methods(http.MethodPost) return h } diff --git a/api/http/handler/users/handler.go b/api/http/handler/users/handler.go index 8cb5629d4..646bf8ae5 100644 --- a/api/http/handler/users/handler.go +++ b/api/http/handler/users/handler.go @@ -32,19 +32,19 @@ func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimi Router: mux.NewRouter(), } h.Handle("/users", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.userCreate))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.userCreate))).Methods(http.MethodPost) h.Handle("/users", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.userList))).Methods(http.MethodGet) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.userList))).Methods(http.MethodGet) h.Handle("/users/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.userInspect))).Methods(http.MethodGet) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.userInspect))).Methods(http.MethodGet) h.Handle("/users/{id}", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.userUpdate))).Methods(http.MethodPut) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.userUpdate))).Methods(http.MethodPut) h.Handle("/users/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.userDelete))).Methods(http.MethodDelete) + bouncer.AdminAccess(httperror.LoggerHandler(h.userDelete))).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.RestrictedAccess(httperror.LoggerHandler(h.userUpdatePassword)))).Methods(http.MethodPut) + 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", diff --git a/api/http/handler/webhooks/handler.go b/api/http/handler/webhooks/handler.go index f2deb2e5c..2e342114e 100644 --- a/api/http/handler/webhooks/handler.go +++ b/api/http/handler/webhooks/handler.go @@ -24,11 +24,11 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), } h.Handle("/webhooks", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.webhookCreate))).Methods(http.MethodPost) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.webhookCreate))).Methods(http.MethodPost) h.Handle("/webhooks", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.webhookList))).Methods(http.MethodGet) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.webhookList))).Methods(http.MethodGet) h.Handle("/webhooks/{id}", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.webhookDelete))).Methods(http.MethodDelete) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.webhookDelete))).Methods(http.MethodDelete) h.Handle("/webhooks/{token}", bouncer.PublicAccess(httperror.LoggerHandler(h.webhookExecute))).Methods(http.MethodPost) return h diff --git a/api/http/handler/websocket/handler.go b/api/http/handler/websocket/handler.go index 79dc0502a..cc0165eb0 100644 --- a/api/http/handler/websocket/handler.go +++ b/api/http/handler/websocket/handler.go @@ -26,8 +26,8 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { requestBouncer: bouncer, } h.PathPrefix("/websocket/exec").Handler( - bouncer.RestrictedAccess(httperror.LoggerHandler(h.websocketExec))) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.websocketExec))) h.PathPrefix("/websocket/attach").Handler( - bouncer.RestrictedAccess(httperror.LoggerHandler(h.websocketAttach))) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.websocketAttach))) return h } diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index c4e6f5dcd..d52c98562 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -34,7 +34,7 @@ type ( } // RestrictedRequestContext is a data structure containing information - // used in RestrictedAccess + // used in AuthenticatedAccess RestrictedRequestContext struct { IsAdmin bool IsTeamLeader bool @@ -64,22 +64,40 @@ func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler { return h } -// AuthorizedAccess defines a security check for API endpoints that require an authorization check. +// AdminAccess defines a security check for API endpoints that require an authorization check. // Authentication is required to access these endpoints. // If the RBAC extension is enabled, authorizations are required to use these endpoints. // If the RBAC extension is not enabled, the administrator role is required to use these endpoints. -func (bouncer *RequestBouncer) AuthorizedAccess(h http.Handler) http.Handler { +// The request context will be enhanced with a RestrictedRequestContext object +// that might be used later to inside the API operation for extra authorization validation +// and resource filtering. +func (bouncer *RequestBouncer) AdminAccess(h http.Handler) http.Handler { h = bouncer.mwUpgradeToRestrictedRequest(h) - h = bouncer.mwCheckPortainerAuthorizations(h) + h = bouncer.mwCheckPortainerAuthorizations(h, true) h = bouncer.mwAuthenticatedUser(h) return h } // RestrictedAccess defines a security check for restricted API endpoints. // Authentication is required to access these endpoints. +// If the RBAC extension is enabled, authorizations are required to use these endpoints. +// If the RBAC extension is not enabled, access is granted to any authenticated user. // The request context will be enhanced with a RestrictedRequestContext object -// that might be used later to authorize/filter access to resources inside an endpoint. +// that might be used later to inside the API operation for extra authorization validation +// and resource filtering. func (bouncer *RequestBouncer) RestrictedAccess(h http.Handler) http.Handler { + h = bouncer.mwUpgradeToRestrictedRequest(h) + h = bouncer.mwCheckPortainerAuthorizations(h, false) + h = bouncer.mwAuthenticatedUser(h) + return h +} + +// AuthenticatedAccess defines a security check for restricted API endpoints. +// Authentication is required to access these endpoints. +// The request context will be enhanced with a RestrictedRequestContext object +// that might be used later to inside the API operation for extra authorization validation +// and resource filtering. +func (bouncer *RequestBouncer) AuthenticatedAccess(h http.Handler) http.Handler { h = bouncer.mwUpgradeToRestrictedRequest(h) h = bouncer.mwAuthenticatedUser(h) return h @@ -191,11 +209,13 @@ func (bouncer *RequestBouncer) mwAuthenticatedUser(h http.Handler) http.Handler // mwCheckPortainerAuthorizations will verify that the user has the required authorization to access // a specific API endpoint. It will leverage the RBAC extension authorization validation if the extension // is enabled. -func (bouncer *RequestBouncer) mwCheckPortainerAuthorizations(next http.Handler) http.Handler { +// If the administratorOnly flag is specified and the RBAC extension is not enabled, this will prevent non-admin +// users from accessing the endpoint. +func (bouncer *RequestBouncer) mwCheckPortainerAuthorizations(next http.Handler, administratorOnly bool) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tokenData, err := RetrieveTokenData(r) if err != nil { - httperror.WriteError(w, http.StatusForbidden, "Access denied", portainer.ErrResourceAccessDenied) + httperror.WriteError(w, http.StatusForbidden, "Access denied", portainer.ErrUnauthorized) return } @@ -206,6 +226,11 @@ func (bouncer *RequestBouncer) mwCheckPortainerAuthorizations(next http.Handler) extension, err := bouncer.extensionService.Extension(portainer.RBACExtension) if err == portainer.ErrObjectNotFound { + if administratorOnly { + httperror.WriteError(w, http.StatusForbidden, "Access denied", portainer.ErrUnauthorized) + return + } + next.ServeHTTP(w, r) return } else if err != nil { From fb6f6738d9b619e2516b1eccda6edcbf38efc766 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 7 Oct 2019 16:12:21 +1300 Subject: [PATCH 37/49] fix(api): prevent the use of bind mounts in stacks if setting enabled (#3232) --- .../handler/stacks/create_compose_stack.go | 30 ++++++++++++++- api/http/handler/stacks/create_swarm_stack.go | 28 +++++++++++++- api/http/handler/stacks/handler.go | 1 + api/http/handler/stacks/stack_create.go | 38 +++++++++++++++++++ api/http/server.go | 1 + .../stacks/create/createStackController.js | 4 +- 6 files changed, 97 insertions(+), 5 deletions(-) diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go index 66c9d91cb..7cae40cf9 100644 --- a/api/http/handler/stacks/create_compose_stack.go +++ b/api/http/handler/stacks/create_compose_stack.go @@ -1,7 +1,9 @@ package stacks import ( + "errors" "net/http" + "path" "strconv" "strings" @@ -238,7 +240,7 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, } stackFolder := strconv.Itoa(int(stack.ID)) - projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) + projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, payload.StackFileContent) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Compose file on disk", err} } @@ -271,6 +273,7 @@ type composeStackDeploymentConfig struct { endpoint *portainer.Endpoint dockerhub *portainer.DockerHub registries []portainer.Registry + isAdmin bool } func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) (*composeStackDeploymentConfig, *httperror.HandlerError) { @@ -295,6 +298,7 @@ func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portai endpoint: endpoint, dockerhub: dockerhub, registries: filteredRegistries, + isAdmin: securityContext.IsAdmin, } return config, nil @@ -306,12 +310,34 @@ func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portai // clean it. Hence the use of the mutex. // We should contribute to libcompose to support authentication without using the config.json file. func (handler *Handler) deployComposeStack(config *composeStackDeploymentConfig) error { + settings, err := handler.SettingsService.Settings() + if err != nil { + return err + } + + if !settings.AllowBindMountsForRegularUsers && !config.isAdmin { + composeFilePath := path.Join(config.stack.ProjectPath, config.stack.EntryPoint) + + stackContent, err := handler.FileService.GetFileContent(composeFilePath) + if err != nil { + return err + } + + valid, err := handler.isValidStackFile(stackContent) + if err != nil { + return err + } + if !valid { + return errors.New("bind-mount disabled for non administrator users") + } + } + handler.stackCreationMutex.Lock() defer handler.stackCreationMutex.Unlock() handler.SwarmStackManager.Login(config.dockerhub, config.registries, config.endpoint) - err := handler.ComposeStackManager.Up(config.stack, config.endpoint) + err = handler.ComposeStackManager.Up(config.stack, config.endpoint) if err != nil { return err } diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go index 0832111e0..4210cab0e 100644 --- a/api/http/handler/stacks/create_swarm_stack.go +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -1,7 +1,9 @@ package stacks import ( + "errors" "net/http" + "path" "strconv" "strings" @@ -290,6 +292,7 @@ type swarmStackDeploymentConfig struct { dockerhub *portainer.DockerHub registries []portainer.Registry prune bool + isAdmin bool } func (handler *Handler) createSwarmDeployConfig(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, prune bool) (*swarmStackDeploymentConfig, *httperror.HandlerError) { @@ -315,18 +318,41 @@ func (handler *Handler) createSwarmDeployConfig(r *http.Request, stack *portaine dockerhub: dockerhub, registries: filteredRegistries, prune: prune, + isAdmin: securityContext.IsAdmin, } return config, nil } func (handler *Handler) deploySwarmStack(config *swarmStackDeploymentConfig) error { + settings, err := handler.SettingsService.Settings() + if err != nil { + return err + } + + if !settings.AllowBindMountsForRegularUsers && !config.isAdmin { + composeFilePath := path.Join(config.stack.ProjectPath, config.stack.EntryPoint) + + stackContent, err := handler.FileService.GetFileContent(composeFilePath) + if err != nil { + return err + } + + valid, err := handler.isValidStackFile(stackContent) + if err != nil { + return err + } + if !valid { + return errors.New("bind-mount disabled for non administrator users") + } + } + handler.stackCreationMutex.Lock() defer handler.stackCreationMutex.Unlock() handler.SwarmStackManager.Login(config.dockerhub, config.registries, config.endpoint) - err := handler.SwarmStackManager.Deploy(config.stack, config.prune, config.endpoint) + err = handler.SwarmStackManager.Deploy(config.stack, config.prune, config.endpoint) if err != nil { return err } diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go index cdfe9ea0d..b81270543 100644 --- a/api/http/handler/stacks/handler.go +++ b/api/http/handler/stacks/handler.go @@ -25,6 +25,7 @@ type Handler struct { DockerHubService portainer.DockerHubService SwarmStackManager portainer.SwarmStackManager ComposeStackManager portainer.ComposeStackManager + SettingsService portainer.SettingsService } // NewHandler creates a handler to manage stack operations. diff --git a/api/http/handler/stacks/stack_create.go b/api/http/handler/stacks/stack_create.go index 0d1adae53..aba79482c 100644 --- a/api/http/handler/stacks/stack_create.go +++ b/api/http/handler/stacks/stack_create.go @@ -5,6 +5,9 @@ import ( "log" "net/http" + "github.com/docker/cli/cli/compose/types" + + "github.com/docker/cli/cli/compose/loader" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/portainer/api" @@ -87,3 +90,38 @@ func (handler *Handler) createSwarmStack(w http.ResponseWriter, r *http.Request, return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string, repository or file", errors.New(request.ErrInvalidQueryParameter)} } + +func (handler *Handler) isValidStackFile(stackFileContent []byte) (bool, error) { + composeConfigYAML, err := loader.ParseYAML(stackFileContent) + if err != nil { + return false, err + } + + composeConfigFile := types.ConfigFile{ + Config: composeConfigYAML, + } + + composeConfigDetails := types.ConfigDetails{ + ConfigFiles: []types.ConfigFile{composeConfigFile}, + Environment: map[string]string{}, + } + + composeConfig, err := loader.Load(composeConfigDetails, func(options *loader.Options) { + options.SkipValidation = true + options.SkipInterpolation = true + }) + if err != nil { + return false, err + } + + for key := range composeConfig.Services { + service := composeConfig.Services[key] + for _, volume := range service.Volumes { + if volume.Type == "bind" { + return false, nil + } + } + } + + return true, nil +} diff --git a/api/http/server.go b/api/http/server.go index 42d3751db..a2a48cfe8 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -207,6 +207,7 @@ func (server *Server) Start() error { stackHandler.GitService = server.GitService stackHandler.RegistryService = server.RegistryService stackHandler.DockerHubService = server.DockerHubService + stackHandler.SettingsService = server.SettingsService var tagHandler = tags.NewHandler(requestBouncer) tagHandler.TagService = server.TagService diff --git a/app/portainer/views/stacks/create/createStackController.js b/app/portainer/views/stacks/create/createStackController.js index 6ecd6519a..337edaf58 100644 --- a/app/portainer/views/stacks/create/createStackController.js +++ b/app/portainer/views/stacks/create/createStackController.js @@ -1,4 +1,4 @@ -import { AccessControlFormData } from '../../../components/accessControlForm/porAccessControlFormModel'; +import {AccessControlFormData} from '../../../components/accessControlForm/porAccessControlFormModel'; angular.module('portainer.app') .controller('CreateStackController', ['$scope', '$state', 'StackService', 'Authentication', 'Notifications', 'FormValidator', 'ResourceControlService', 'FormHelper', 'EndpointProvider', @@ -124,7 +124,7 @@ function ($scope, $state, StackService, Authentication, Notifications, FormValid $state.go('portainer.stacks'); }) .catch(function error(err) { - Notifications.warning('Deployment error', type === 1 ? err.err.data.err : err.data.err); + Notifications.error('Deployment error', err, 'Unable to deploy stack'); }) .finally(function final() { $scope.state.actionInProgress = false; From 2912e78f688d5082979441502570c8a44ef1a3e4 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 7 Oct 2019 16:24:08 +1300 Subject: [PATCH 38/49] fix(api): add access validation for agent browse requests (#3235) * fix(api): add access validation for agent browse requests * fix(api): review query parameter retrieval * refactor(api): remove useless else case --- api/http/proxy/docker_transport.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/api/http/proxy/docker_transport.go b/api/http/proxy/docker_transport.go index c5eb96a64..6443a8268 100644 --- a/api/http/proxy/docker_transport.go +++ b/api/http/proxy/docker_transport.go @@ -113,11 +113,28 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon return p.proxyBuildRequest(request) case strings.HasPrefix(path, "/images"): return p.proxyImageRequest(request) + case strings.HasPrefix(path, "/v2"): + return p.proxyAgentRequest(request) default: return p.executeDockerRequest(request) } } +func (p *proxyTransport) proxyAgentRequest(r *http.Request) (*http.Response, error) { + requestPath := strings.TrimPrefix(r.URL.Path, "/v2") + + switch { + case strings.HasPrefix(requestPath, "/browse"): + volumeIDParameter, found := r.URL.Query()["volumeID"] + if !found || len(volumeIDParameter) < 1 { + return p.administratorOperation(r) + } + return p.restrictedOperation(r, volumeIDParameter[0]) + } + + return p.executeDockerRequest(r) +} + func (p *proxyTransport) proxyConfigRequest(request *http.Request) (*http.Response, error) { switch requestPath := request.URL.Path; requestPath { case "/configs/create": From b0f48ee3ad63a99834a3b4bf18a48cf6714f3c0a Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 7 Oct 2019 16:24:48 +1300 Subject: [PATCH 39/49] feat(app): fix XSS vulnerabilities (#3230) --- app/__module.js | 1 + .../isteven-angular-multiselect/.npmignore | 8 + .../isteven-angular-multiselect/LICENSE.txt | 21 + .../isteven-angular-multiselect/README.md | 50 + .../isteven-multi-select.css | 299 +++++ .../isteven-multi-select.js | 1127 +++++++++++++++++ .../isteven-angular-multiselect/package.json | 22 + app/portainer/services/modalService.js | 24 +- app/vendors.js | 2 - package.json | 1 - yarn.lock | 5 - 11 files changed, 1541 insertions(+), 19 deletions(-) create mode 100644 app/libraries/isteven-angular-multiselect/.npmignore create mode 100644 app/libraries/isteven-angular-multiselect/LICENSE.txt create mode 100644 app/libraries/isteven-angular-multiselect/README.md create mode 100644 app/libraries/isteven-angular-multiselect/isteven-multi-select.css create mode 100644 app/libraries/isteven-angular-multiselect/isteven-multi-select.js create mode 100644 app/libraries/isteven-angular-multiselect/package.json diff --git a/app/__module.js b/app/__module.js index 881453c9a..9eaa6248d 100644 --- a/app/__module.js +++ b/app/__module.js @@ -1,4 +1,5 @@ import '../assets/css/app.css'; +import './libraries/isteven-angular-multiselect/isteven-multi-select.css'; import angular from 'angular'; import './agent/_module'; diff --git a/app/libraries/isteven-angular-multiselect/.npmignore b/app/libraries/isteven-angular-multiselect/.npmignore new file mode 100644 index 000000000..84a23b94d --- /dev/null +++ b/app/libraries/isteven-angular-multiselect/.npmignore @@ -0,0 +1,8 @@ +.git +.gitignore +bower.json +CHANGELOG.md +package.json +README.md +screenshot.png +/doc diff --git a/app/libraries/isteven-angular-multiselect/LICENSE.txt b/app/libraries/isteven-angular-multiselect/LICENSE.txt new file mode 100644 index 000000000..6e524fa92 --- /dev/null +++ b/app/libraries/isteven-angular-multiselect/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014-2015 Ignatius Steven (https://github.com/isteven) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app/libraries/isteven-angular-multiselect/README.md b/app/libraries/isteven-angular-multiselect/README.md new file mode 100644 index 000000000..9c6255bcf --- /dev/null +++ b/app/libraries/isteven-angular-multiselect/README.md @@ -0,0 +1,50 @@ +# AngularJS MultiSelect +Pure AngularJS directive which creates a dropdown button with multiple or single selections. +Doesn't require jQuery and works well with other Javascript libraries. + +![Screenshot](https://raw.githubusercontent.com/isteven/angular-multi-select/master/screenshot.png) + +### Demo & How To +Go to http://isteven.github.io/angular-multi-select + +### Current Version +4.0.0 + +### Change Log +See CHANGELOG.md. +For those who's upgrading from version 2.x.x, do note that this version is not backward-compatible. Please read the manual +thoroughly and update your code accordingly. + +### Bug Reporting +Please follow these steps: + +1. **READ THE MANUAL AGAIN**. You might have missed something. This includes the MINIMUM ANGULARJS VERSION and the SUPPORTED BROWSERS. +2. The next step is to search in Github's issue section first. There might already be an answer for similar issue. Do check both open and closed issues. +3. If there's no previous issue found, then please create a new issue in https://github.com/isteven/angular-multi-select/issues. +4. Please **replicate the problem in JSFiddle or Plunker** (or any other online JS collaboration tool), and include the URL in the issue you are creating. +5. When you're done, please close the issue you've created. + +### Licence +Released under the MIT license: + +The MIT License (MIT) + +Copyright (c) 2014-2015 Ignatius Steven (https://github.com/isteven) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app/libraries/isteven-angular-multiselect/isteven-multi-select.css b/app/libraries/isteven-angular-multiselect/isteven-multi-select.css new file mode 100644 index 000000000..44dfc95f5 --- /dev/null +++ b/app/libraries/isteven-angular-multiselect/isteven-multi-select.css @@ -0,0 +1,299 @@ +/* + * Don't modify things marked with ! - unless you know what you're doing + */ + +/* ! vertical layout */ +.multiSelect .vertical { + float: none; +} + +/* ! horizontal layout */ +.multiSelect .horizontal:not(.multiSelectGroup) { + float: left; +} + +/* ! create a "row" */ +.multiSelect .line { + padding: 2px 0px 4px 0px; + max-height: 30px; + overflow: hidden; + box-sizing: content-box; +} + +/* ! create a "column" */ +.multiSelect .acol { + display: inline-block; + min-width: 12px; +} + +/* ! */ +.multiSelect .inlineBlock { + display: inline-block; +} + +/* the multiselect button */ +.multiSelect > button { + display: inline-block; + position: relative; + text-align: center; + cursor: pointer; + border: 1px solid #c6c6c6; + padding: 1px 8px 1px 8px; + font-size: 14px; + min-height : 38px !important; + border-radius: 4px; + color: #555; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + -o-user-select: none; + user-select: none; + white-space:normal; + background-color: #fff; + background-image: linear-gradient(#fff, #f7f7f7); +} + +/* button: hover */ +.multiSelect > button:hover { + background-image: linear-gradient(#fff, #e9e9e9); +} + +/* button: disabled */ +.multiSelect > button:disabled { + background-image: linear-gradient(#fff, #fff); + border: 1px solid #ddd; + color: #999; +} + +/* button: clicked */ +.multiSelect .buttonClicked { + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15) inset, 0 1px 2px rgba(0, 0, 0, 0.05); +} + +/* labels on the button */ +.multiSelect .buttonLabel { + display: inline-block; + padding: 5px 0px 5px 0px; +} + +/* downward pointing arrow */ +.multiSelect .caret { + display: inline-block; + width: 0; + height: 0; + margin: 0px 0px 1px 12px !important; + vertical-align: middle; + border-top: 4px solid #333; + border-right: 4px solid transparent; + border-left: 4px solid transparent; + border-bottom: 0 dotted; +} + +/* the main checkboxes and helper layer */ +.multiSelect .checkboxLayer { + background-color: #fff; + position: absolute; + z-index: 999; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 4px; + -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); + min-width:278px; + display: none !important; +} + +/* container of helper elements */ +.multiSelect .helperContainer { + border-bottom: 1px solid #ddd; + padding: 8px 8px 0px 8px; +} + +/* helper buttons (select all, none, reset); */ +.multiSelect .helperButton { + display: inline; + text-align: center; + cursor: pointer; + border: 1px solid #ccc; + height: 26px; + font-size: 13px; + border-radius: 2px; + color: #666; + background-color: #f1f1f1; + line-height: 1.6; + margin: 0px 0px 8px 0px; +} + +.multiSelect .helperButton.reset{ + float: right; +} + +.multiSelect .helperButton:not( .reset ) { + margin-right: 4px; +} + +/* clear button */ +.multiSelect .clearButton { + position: absolute; + display: inline; + text-align: center; + cursor: pointer; + border: 1px solid #ccc; + height: 22px; + width: 22px; + font-size: 13px; + border-radius: 2px; + color: #666; + background-color: #f1f1f1; + line-height: 1.4; + right : 2px; + top: 4px; +} + +/* filter */ +.multiSelect .inputFilter { + border-radius: 2px; + border: 1px solid #ccc; + height: 26px; + font-size: 14px; + width:100%; + padding-left:7px; + -webkit-box-sizing: border-box; /* Safari/Chrome, other WebKit */ + -moz-box-sizing: border-box; /* Firefox, other Gecko */ + box-sizing: border-box; /* Opera/IE 8+ */ + color: #888; + margin: 0px 0px 8px 0px; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); +} + +/* helper elements on hover & focus */ +.multiSelect .clearButton:hover, +.multiSelect .helperButton:hover { + border: 1px solid #ccc; + color: #999; + background-color: #f4f4f4; +} +.multiSelect .helperButton:disabled { + color: #ccc; + border: 1px solid #ddd; +} + +.multiSelect .clearButton:focus, +.multiSelect .helperButton:focus, +.multiSelect .inputFilter:focus { + border: 1px solid #66AFE9 !important; + outline: 0; + -webkit-box-shadow: inset 0 0 1px rgba(0,0,0,.065), 0 0 5px rgba(102, 175, 233, .6) !important; + box-shadow: inset 0 0 1px rgba(0,0,0,.065), 0 0 5px rgba(102, 175, 233, .6) !important; +} + +/* container of multi select items */ +.multiSelect .checkBoxContainer { + display: block; + padding: 8px; + overflow: hidden; +} + +/* ! to show / hide the checkbox layer above */ +.multiSelect .show { + display: block !important; +} + +/* item labels */ +.multiSelect .multiSelectItem { + display: block; + padding: 3px; + color: #444; + white-space: nowrap; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + -o-user-select: none; + user-select: none; + border: 1px solid transparent; + position: relative; + min-width:278px; + min-height: 32px; +} + +/* Styling on selected items */ +.multiSelect .multiSelectItem:not(.multiSelectGroup).selected +{ + background-image: linear-gradient( #e9e9e9, #f1f1f1 ); + color: #555; + cursor: pointer; + border-top: 1px solid #e4e4e4; + border-left: 1px solid #e4e4e4; + border-right: 1px solid #d9d9d9; +} + +.multiSelect .multiSelectItem .acol label { + display: inline-block; + padding-right: 30px; + margin: 0px; + font-weight: normal; + line-height: normal; +} + +/* item labels focus on mouse hover */ +.multiSelect .multiSelectItem:hover, +.multiSelect .multiSelectGroup:hover { + background-image: linear-gradient( #c1c1c1, #999 ) !important; + color: #fff !important; + cursor: pointer; + border: 1px solid #ccc !important; +} + +/* item labels focus using keyboard */ +.multiSelect .multiSelectFocus { + background-image: linear-gradient( #c1c1c1, #999 ) !important; + color: #fff !important; + cursor: pointer; + border: 1px solid #ccc !important; +} + +/* change mouse pointer into the pointing finger */ +.multiSelect .multiSelectItem span:hover, +.multiSelect .multiSelectGroup span:hover +{ + cursor: pointer; +} + +/* ! group labels */ +.multiSelect .multiSelectGroup { + display: block; + clear: both; +} + +/* right-align the tick mark (✔) */ +.multiSelect .tickMark { + display:inline-block; + position: absolute; + right: 10px; + top: 7px; + font-size: 10px; +} + +/* hide the original HTML checkbox away */ +.multiSelect .checkbox { + color: #ddd !important; + position: absolute; + left: -9999px; + cursor: pointer; +} + +/* checkboxes currently disabled */ +.multiSelect .disabled, +.multiSelect .disabled:hover, +.multiSelect .disabled label input:hover ~ span { + color: #c4c4c4 !important; + cursor: not-allowed !important; +} + +/* If you use images in button / checkbox label, you might want to change the image style here. */ +.multiSelect img { + vertical-align: middle; + margin-bottom:0px; + max-height: 22px; + max-width:22px; +} diff --git a/app/libraries/isteven-angular-multiselect/isteven-multi-select.js b/app/libraries/isteven-angular-multiselect/isteven-multi-select.js new file mode 100644 index 000000000..02b136aa3 --- /dev/null +++ b/app/libraries/isteven-angular-multiselect/isteven-multi-select.js @@ -0,0 +1,1127 @@ +/* + * Angular JS Multi Select + * Creates a dropdown-like button with checkboxes. + * + * Project started on: Tue, 14 Jan 2014 - 5:18:02 PM + * Current version: 4.0.0 + * + * Released under the MIT License + * -------------------------------------------------------------------------------- + * The MIT License (MIT) + * + * Copyright (c) 2014 Ignatius Steven (https://github.com/isteven) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * -------------------------------------------------------------------------------- + */ + +'use strict' + +angular.module( 'isteven-multi-select', ['ng'] ).directive( 'istevenMultiSelect' , [ '$sce', '$timeout', '$sanitize', function ( $sce, $timeout, $sanitize) { + return { + restrict: + 'AE', + + scope: + { + // models + inputModel : '=', + outputModel : '=', + + // settings based on attribute + isDisabled : '=', + + // callbacks + onClear : '&', + onClose : '&', + onSearchChange : '&', + onItemClick : '&', + onOpen : '&', + onReset : '&', + onSelectAll : '&', + onSelectNone : '&', + + // i18n + translation : '=' + }, + + /* + * The rest are attributes. They don't need to be parsed / binded, so we can safely access them by value. + * - buttonLabel, directiveId, helperElements, itemLabel, maxLabels, orientation, selectionMode, minSearchLength, + * tickProperty, disableProperty, groupProperty, searchProperty, maxHeight, outputProperties + */ + + templateUrl: + 'isteven-multi-select.htm', + + link: function ( $scope, element, attrs ) { + + $scope.backUp = []; + $scope.varButtonLabel = ''; + $scope.spacingProperty = ''; + $scope.indexProperty = ''; + $scope.orientationH = false; + $scope.orientationV = true; + $scope.filteredModel = []; + $scope.inputLabel = { labelFilter: '' }; + $scope.tabIndex = 0; + $scope.lang = {}; + $scope.helperStatus = { + all : true, + none : true, + reset : true, + filter : true + }; + + var + prevTabIndex = 0, + helperItems = [], + helperItemsLength = 0, + checkBoxLayer = '', + // scrolled = false, + // selectedItems = [], + formElements = [], + vMinSearchLength = 0, + clickedItem = null + + // v3.0.0 + // clear button clicked + $scope.clearClicked = function( e ) { + $scope.inputLabel.labelFilter = ''; + $scope.updateFilter(); + $scope.select( 'clear', e ); + } + + // A little hack so that AngularJS ng-repeat can loop using start and end index like a normal loop + // http://stackoverflow.com/questions/16824853/way-to-ng-repeat-defined-number-of-times-instead-of-repeating-over-array + $scope.numberToArray = function( num ) { + return new Array( num ); + } + + // Call this function when user type on the filter field + $scope.searchChanged = function() { + if ( $scope.inputLabel.labelFilter.length < vMinSearchLength && $scope.inputLabel.labelFilter.length > 0 ) { + return false; + } + $scope.updateFilter(); + } + + $scope.updateFilter = function() + { + // we check by looping from end of input-model + $scope.filteredModel = []; + var i = 0; + + if ( typeof $scope.inputModel === 'undefined' ) { + return false; + } + + for( i = $scope.inputModel.length - 1; i >= 0; i-- ) { + + // if it's group end, we push it to filteredModel[]; + if ( typeof $scope.inputModel[ i ][ attrs.groupProperty ] !== 'undefined' && $scope.inputModel[ i ][ attrs.groupProperty ] === false ) { + $scope.filteredModel.push( $scope.inputModel[ i ] ); + } + + // if it's data + var gotData = false; + if ( typeof $scope.inputModel[ i ][ attrs.groupProperty ] === 'undefined' ) { + + // If we set the search-key attribute, we use this loop. + if ( typeof attrs.searchProperty !== 'undefined' && attrs.searchProperty !== '' ) { + + for (const key in $scope.inputModel[ i ] ) { + if ( + typeof $scope.inputModel[ i ][ key ] !== 'boolean' + && String( $scope.inputModel[ i ][ key ] ).toUpperCase().indexOf( $scope.inputLabel.labelFilter.toUpperCase() ) >= 0 + && attrs.searchProperty.indexOf( key ) > -1 + ) { + gotData = true; + break; + } + } + } + // if there's no search-key attribute, we use this one. Much better on performance. + else { + for ( const key in $scope.inputModel[ i ] ) { + if ( + typeof $scope.inputModel[ i ][ key ] !== 'boolean' + && String( $scope.inputModel[ i ][ key ] ).toUpperCase().indexOf( $scope.inputLabel.labelFilter.toUpperCase() ) >= 0 + ) { + gotData = true; + break; + } + } + } + + if ( gotData === true ) { + // push + $scope.filteredModel.push( $scope.inputModel[ i ] ); + } + } + + // if it's group start + if ( typeof $scope.inputModel[ i ][ attrs.groupProperty ] !== 'undefined' && $scope.inputModel[ i ][ attrs.groupProperty ] === true ) { + + if ( typeof $scope.filteredModel[ $scope.filteredModel.length - 1 ][ attrs.groupProperty ] !== 'undefined' + && $scope.filteredModel[ $scope.filteredModel.length - 1 ][ attrs.groupProperty ] === false ) { + $scope.filteredModel.pop(); + } + else { + $scope.filteredModel.push( $scope.inputModel[ i ] ); + } + } + } + + $scope.filteredModel.reverse(); + + $timeout( function() { + + $scope.getFormElements(); + + // Callback: on filter change + if ( $scope.inputLabel.labelFilter.length > vMinSearchLength ) { + + var filterObj = []; + + angular.forEach( $scope.filteredModel, function( value ) { + if ( typeof value !== 'undefined' ) { + if ( typeof value[ attrs.groupProperty ] === 'undefined' ) { + var tempObj = angular.copy( value ); + var index = filterObj.push( tempObj ); + delete filterObj[ index - 1 ][ $scope.indexProperty ]; + delete filterObj[ index - 1 ][ $scope.spacingProperty ]; + } + } + }); + + $scope.onSearchChange({ + data: + { + keyword: $scope.inputLabel.labelFilter, + result: filterObj + } + }); + } + },0); + }; + + // List all the input elements. We need this for our keyboard navigation. + // This function will be called everytime the filter is updated. + // Depending on the size of filtered mode, might not good for performance, but oh well.. + $scope.getFormElements = function() { + formElements = []; + + var + selectButtons = [], + inputField = [], + checkboxes = [], + clearButton = []; + + // If available, then get select all, select none, and reset buttons + if ( $scope.helperStatus.all || $scope.helperStatus.none || $scope.helperStatus.reset ) { + selectButtons = element.children().children().next().children().children()[ 0 ].getElementsByTagName( 'button' ); + // If available, then get the search box and the clear button + if ( $scope.helperStatus.filter ) { + // Get helper - search and clear button. + inputField = element.children().children().next().children().children().next()[ 0 ].getElementsByTagName( 'input' ); + clearButton = element.children().children().next().children().children().next()[ 0 ].getElementsByTagName( 'button' ); + } + } + else { + if ( $scope.helperStatus.filter ) { + // Get helper - search and clear button. + inputField = element.children().children().next().children().children()[ 0 ].getElementsByTagName( 'input' ); + clearButton = element.children().children().next().children().children()[ 0 ].getElementsByTagName( 'button' ); + } + } + + // Get checkboxes + if ( !$scope.helperStatus.all && !$scope.helperStatus.none && !$scope.helperStatus.reset && !$scope.helperStatus.filter ) { + checkboxes = element.children().children().next()[ 0 ].getElementsByTagName( 'input' ); + } + else { + checkboxes = element.children().children().next().children().next()[ 0 ].getElementsByTagName( 'input' ); + } + + // Push them into global array formElements[] + for ( let i = 0; i < selectButtons.length ; i++ ) { formElements.push( selectButtons[ i ] ); } + for ( let i = 0; i < inputField.length ; i++ ) { formElements.push( inputField[ i ] ); } + for ( let i = 0; i < clearButton.length ; i++ ) { formElements.push( clearButton[ i ] ); } + for ( let i = 0; i < checkboxes.length ; i++ ) { formElements.push( checkboxes[ i ] ); } + } + + // check if an item has attrs.groupProperty (be it true or false) + $scope.isGroupMarker = function( item , type ) { + if ( typeof item[ attrs.groupProperty ] !== 'undefined' && item[ attrs.groupProperty ] === type ) return true; + return false; + } + + $scope.removeGroupEndMarker = function( item ) { + if ( typeof item[ attrs.groupProperty ] !== 'undefined' && item[ attrs.groupProperty ] === false ) return false; + return true; + } + + // call this function when an item is clicked + $scope.syncItems = function( item, e, ng_repeat_index ) { + + e.preventDefault(); + e.stopPropagation(); + + // if the directive is globaly disabled, do nothing + if ( typeof attrs.disableProperty !== 'undefined' && item[ attrs.disableProperty ] === true ) { + return false; + } + + // if item is disabled, do nothing + if ( typeof attrs.isDisabled !== 'undefined' && $scope.isDisabled === true ) { + return false; + } + + // if end group marker is clicked, do nothing + if ( typeof item[ attrs.groupProperty ] !== 'undefined' && item[ attrs.groupProperty ] === false ) { + return false; + } + + var index = $scope.filteredModel.indexOf( item ); + + // if the start of group marker is clicked ( only for multiple selection! ) + // how it works: + // - if, in a group, there are items which are not selected, then they all will be selected + // - if, in a group, all items are selected, then they all will be de-selected + if ( typeof item[ attrs.groupProperty ] !== 'undefined' && item[ attrs.groupProperty ] === true ) { + + // this is only for multiple selection, so if selection mode is single, do nothing + if ( typeof attrs.selectionMode !== 'undefined' && attrs.selectionMode.toUpperCase() === 'SINGLE' ) { + return false; + } + + var i, j; + var startIndex = 0; + var endIndex = $scope.filteredModel.length - 1; + var tempArr = []; + + // nest level is to mark the depth of the group. + // when you get into a group (start group marker), nestLevel++ + // when you exit a group (end group marker), nextLevel-- + var nestLevel = 0; + + // we loop throughout the filtered model (not whole model) + for( i = index ; i < $scope.filteredModel.length ; i++) { + + // this break will be executed when we're done processing each group + if ( nestLevel === 0 && i > index ) + { + break; + } + + if ( typeof $scope.filteredModel[ i ][ attrs.groupProperty ] !== 'undefined' && $scope.filteredModel[ i ][ attrs.groupProperty ] === true ) { + + // To cater multi level grouping + if ( tempArr.length === 0 ) { + startIndex = i + 1; + } + nestLevel = nestLevel + 1; + } + + // if group end + else if ( typeof $scope.filteredModel[ i ][ attrs.groupProperty ] !== 'undefined' && $scope.filteredModel[ i ][ attrs.groupProperty ] === false ) { + + nestLevel = nestLevel - 1; + + // cek if all are ticked or not + if ( tempArr.length > 0 && nestLevel === 0 ) { + + var allTicked = true; + + endIndex = i; + + for ( j = 0; j < tempArr.length ; j++ ) { + if ( typeof tempArr[ j ][ $scope.tickProperty ] !== 'undefined' && tempArr[ j ][ $scope.tickProperty ] === false ) { + allTicked = false; + break; + } + } + + if ( allTicked === true ) { + for ( j = startIndex; j <= endIndex ; j++ ) { + if ( typeof $scope.filteredModel[ j ][ attrs.groupProperty ] === 'undefined' ) { + if ( typeof attrs.disableProperty === 'undefined' ) { + $scope.filteredModel[ j ][ $scope.tickProperty ] = false; + // we refresh input model as well + inputModelIndex = $scope.filteredModel[ j ][ $scope.indexProperty ]; + $scope.inputModel[ inputModelIndex ][ $scope.tickProperty ] = false; + } + else if ( $scope.filteredModel[ j ][ attrs.disableProperty ] !== true ) { + $scope.filteredModel[ j ][ $scope.tickProperty ] = false; + // we refresh input model as well + inputModelIndex = $scope.filteredModel[ j ][ $scope.indexProperty ]; + $scope.inputModel[ inputModelIndex ][ $scope.tickProperty ] = false; + } + } + } + } + + else { + for ( j = startIndex; j <= endIndex ; j++ ) { + if ( typeof $scope.filteredModel[ j ][ attrs.groupProperty ] === 'undefined' ) { + if ( typeof attrs.disableProperty === 'undefined' ) { + $scope.filteredModel[ j ][ $scope.tickProperty ] = true; + // we refresh input model as well + inputModelIndex = $scope.filteredModel[ j ][ $scope.indexProperty ]; + $scope.inputModel[ inputModelIndex ][ $scope.tickProperty ] = true; + + } + else if ( $scope.filteredModel[ j ][ attrs.disableProperty ] !== true ) { + $scope.filteredModel[ j ][ $scope.tickProperty ] = true; + // we refresh input model as well + inputModelIndex = $scope.filteredModel[ j ][ $scope.indexProperty ]; + $scope.inputModel[ inputModelIndex ][ $scope.tickProperty ] = true; + } + } + } + } + } + } + + // if data + else { + tempArr.push( $scope.filteredModel[ i ] ); + } + } + } + + // if an item (not group marker) is clicked + else { + + // If it's single selection mode + if ( typeof attrs.selectionMode !== 'undefined' && attrs.selectionMode.toUpperCase() === 'SINGLE' ) { + + // first, set everything to false + for( i=0 ; i < $scope.filteredModel.length ; i++) { + $scope.filteredModel[ i ][ $scope.tickProperty ] = false; + } + for( i=0 ; i < $scope.inputModel.length ; i++) { + $scope.inputModel[ i ][ $scope.tickProperty ] = false; + } + + // then set the clicked item to true + $scope.filteredModel[ index ][ $scope.tickProperty ] = true; + } + + // Multiple + else { + $scope.filteredModel[ index ][ $scope.tickProperty ] = !$scope.filteredModel[ index ][ $scope.tickProperty ]; + } + + // we refresh input model as well + var inputModelIndex = $scope.filteredModel[ index ][ $scope.indexProperty ]; + $scope.inputModel[ inputModelIndex ][ $scope.tickProperty ] = $scope.filteredModel[ index ][ $scope.tickProperty ]; + } + + // we execute the callback function here + clickedItem = angular.copy( item ); + if ( clickedItem !== null ) { + $timeout( function() { + delete clickedItem[ $scope.indexProperty ]; + delete clickedItem[ $scope.spacingProperty ]; + $scope.onItemClick( { data: clickedItem } ); + clickedItem = null; + }, 0 ); + } + + $scope.refreshOutputModel(); + $scope.refreshButton(); + + // We update the index here + prevTabIndex = $scope.tabIndex; + $scope.tabIndex = ng_repeat_index + helperItemsLength; + + // Set focus on the hidden checkbox + e.target.focus(); + + // set & remove CSS style + $scope.removeFocusStyle( prevTabIndex ); + $scope.setFocusStyle( $scope.tabIndex ); + + if ( typeof attrs.selectionMode !== 'undefined' && attrs.selectionMode.toUpperCase() === 'SINGLE' ) { + // on single selection mode, we then hide the checkbox layer + $scope.toggleCheckboxes( e ); + } + } + + // update $scope.outputModel + $scope.refreshOutputModel = function() { + + $scope.outputModel = []; + var + outputProps = [], + tempObj = {}; + + // v4.0.0 + if ( typeof attrs.outputProperties !== 'undefined' ) { + outputProps = attrs.outputProperties.split(' '); + angular.forEach( $scope.inputModel, function( value ) { + if ( + typeof value !== 'undefined' + && typeof value[ attrs.groupProperty ] === 'undefined' + && value[ $scope.tickProperty ] === true + ) { + tempObj = {}; + angular.forEach( value, function( value1, key1 ) { + if ( outputProps.indexOf( key1 ) > -1 ) { + tempObj[ key1 ] = value1; + } + }); + var index = $scope.outputModel.push( tempObj ); + delete $scope.outputModel[ index - 1 ][ $scope.indexProperty ]; + delete $scope.outputModel[ index - 1 ][ $scope.spacingProperty ]; + } + }); + } + else { + angular.forEach( $scope.inputModel, function( value ) { + if ( + typeof value !== 'undefined' + && typeof value[ attrs.groupProperty ] === 'undefined' + && value[ $scope.tickProperty ] === true + ) { + var temp = angular.copy( value ); + var index = $scope.outputModel.push( temp ); + delete $scope.outputModel[ index - 1 ][ $scope.indexProperty ]; + delete $scope.outputModel[ index - 1 ][ $scope.spacingProperty ]; + } + }); + } + } + + // refresh button label + $scope.refreshButton = function() { + + $scope.varButtonLabel = ''; + var ctr = 0; + + // refresh button label... + if ( $scope.outputModel.length === 0 ) { + // https://github.com/isteven/angular-multi-select/pull/19 + $scope.varButtonLabel = $scope.lang.nothingSelected; + } + else { + var tempMaxLabels = $scope.outputModel.length; + if ( typeof attrs.maxLabels !== 'undefined' && attrs.maxLabels !== '' ) { + tempMaxLabels = attrs.maxLabels; + } + + // if max amount of labels displayed.. + if ( $scope.outputModel.length > tempMaxLabels ) { + $scope.more = true; + } + else { + $scope.more = false; + } + + angular.forEach( $scope.inputModel, function( value ) { + if ( typeof value !== 'undefined' && value[ attrs.tickProperty ] === true ) { + if ( ctr < tempMaxLabels ) { + $scope.varButtonLabel += ( $scope.varButtonLabel.length > 0 ? ',
' : '
') + $scope.writeLabel( value, 'buttonLabel' ); + } + ctr++; + } + }); + + if ( $scope.more === true ) { + // https://github.com/isteven/angular-multi-select/pull/16 + if (tempMaxLabels > 0) { + $scope.varButtonLabel += ', ... '; + } + $scope.varButtonLabel += '(' + $scope.outputModel.length + ')'; + } + } + // $scope.varButtonLabel = $sce.trustAsHtml( $scope.varButtonLabel + '' ); + $scope.varButtonLabel = $sanitize($scope.varButtonLabel + ''); + } + + // Check if a checkbox is disabled or enabled. It will check the granular control (disableProperty) and global control (isDisabled) + // Take note that the granular control has higher priority. + $scope.itemIsDisabled = function( item ) { + + if ( typeof attrs.disableProperty !== 'undefined' && item[ attrs.disableProperty ] === true ) { + return true; + } + else { + if ( $scope.isDisabled === true ) { + return true; + } + else { + return false; + } + } + + } + + // A simple function to parse the item label settings. Used on the buttons and checkbox labels. + $scope.writeLabel = function( item, type ) { + // type is either 'itemLabel' or 'buttonLabel' + var temp = attrs[ type ].split( ' ' ); + var label = ''; + + angular.forEach( temp, function( value ) { + item[ value ] && ( label += ' ' + value.split( '.' ).reduce( function( prev, current ) { + return prev[ current ]; + }, item )); + }); + + if ( type.toUpperCase() === 'BUTTONLABEL' ) { + return label; + } + // return $sce.trustAsHtml( label ); + return $sanitize(label); + } + + // UI operations to show/hide checkboxes based on click event.. + $scope.toggleCheckboxes = function( ) { + + // We grab the button + var clickedEl = element.children()[0]; + + // Just to make sure.. had a bug where key events were recorded twice + angular.element( document ).off( 'click', $scope.externalClickListener ); + angular.element( document ).off( 'keydown', $scope.keyboardListener ); + + // The idea below was taken from another multi-select directive - https://github.com/amitava82/angular-multiselect + // His version is awesome if you need a more simple multi-select approach. + + // close + if ( angular.element( checkBoxLayer ).hasClass( 'show' )) { + + angular.element( checkBoxLayer ).removeClass( 'show' ); + angular.element( clickedEl ).removeClass( 'buttonClicked' ); + angular.element( document ).off( 'click', $scope.externalClickListener ); + angular.element( document ).off( 'keydown', $scope.keyboardListener ); + + // clear the focused element; + $scope.removeFocusStyle( $scope.tabIndex ); + if ( typeof formElements[ $scope.tabIndex ] !== 'undefined' ) { + formElements[ $scope.tabIndex ].blur(); + } + + // close callback + $timeout( function() { + $scope.onClose(); + }, 0 ); + + // set focus on button again + element.children().children()[ 0 ].focus(); + } + // open + else + { + // clear filter + $scope.inputLabel.labelFilter = ''; + $scope.updateFilter(); + + helperItems = []; + helperItemsLength = 0; + + angular.element( checkBoxLayer ).addClass( 'show' ); + angular.element( clickedEl ).addClass( 'buttonClicked' ); + + // Attach change event listener on the input filter. + // We need this because ng-change is apparently not an event listener. + angular.element( document ).on( 'click', $scope.externalClickListener ); + angular.element( document ).on( 'keydown', $scope.keyboardListener ); + + // to get the initial tab index, depending on how many helper elements we have. + // priority is to always focus it on the input filter + $scope.getFormElements(); + $scope.tabIndex = 0; + + var helperContainer = angular.element( element[ 0 ].querySelector( '.helperContainer' ) )[0]; + + if ( typeof helperContainer !== 'undefined' ) { + for ( var i = 0; i < helperContainer.getElementsByTagName( 'BUTTON' ).length ; i++ ) { + helperItems[ i ] = helperContainer.getElementsByTagName( 'BUTTON' )[ i ]; + } + helperItemsLength = helperItems.length + helperContainer.getElementsByTagName( 'INPUT' ).length; + } + + // focus on the filter element on open. + if ( element[ 0 ].querySelector( '.inputFilter' ) ) { + element[ 0 ].querySelector( '.inputFilter' ).focus(); + $scope.tabIndex = $scope.tabIndex + helperItemsLength - 2; + // blur button in vain + angular.element( element ).children()[ 0 ].blur(); + } + // if there's no filter then just focus on the first checkbox item + else { + if ( !$scope.isDisabled ) { + $scope.tabIndex = $scope.tabIndex + helperItemsLength; + if ( $scope.inputModel.length > 0 ) { + formElements[ $scope.tabIndex ].focus(); + $scope.setFocusStyle( $scope.tabIndex ); + // blur button in vain + angular.element( element ).children()[ 0 ].blur(); + } + } + } + + // open callback + $scope.onOpen(); + } + } + + // handle clicks outside the button / multi select layer + $scope.externalClickListener = function( e ) { + + var targetsArr = element.find( e.target.tagName ); + for (var i = 0; i < targetsArr.length; i++) { + if ( e.target == targetsArr[i] ) { + return; + } + } + + angular.element( checkBoxLayer.previousSibling ).removeClass( 'buttonClicked' ); + angular.element( checkBoxLayer ).removeClass( 'show' ); + angular.element( document ).off( 'click', $scope.externalClickListener ); + angular.element( document ).off( 'keydown', $scope.keyboardListener ); + + // close callback + $timeout( function() { + $scope.onClose(); + }, 0 ); + + // set focus on button again + element.children().children()[ 0 ].focus(); + } + + // select All / select None / reset buttons + $scope.select = function( type, e ) { + + var helperIndex = helperItems.indexOf( e.target ); + $scope.tabIndex = helperIndex; + + switch( type.toUpperCase() ) { + case 'ALL': + angular.forEach( $scope.filteredModel, function( value ) { + if ( typeof value !== 'undefined' && value[ attrs.disableProperty ] !== true ) { + if ( typeof value[ attrs.groupProperty ] === 'undefined' ) { + value[ $scope.tickProperty ] = true; + } + } + }); + $scope.refreshOutputModel(); + $scope.refreshButton(); + $scope.onSelectAll(); + break; + case 'NONE': + angular.forEach( $scope.filteredModel, function( value ) { + if ( typeof value !== 'undefined' && value[ attrs.disableProperty ] !== true ) { + if ( typeof value[ attrs.groupProperty ] === 'undefined' ) { + value[ $scope.tickProperty ] = false; + } + } + }); + $scope.refreshOutputModel(); + $scope.refreshButton(); + $scope.onSelectNone(); + break; + case 'RESET': + angular.forEach( $scope.filteredModel, function( value ) { + if ( typeof value[ attrs.groupProperty ] === 'undefined' && typeof value !== 'undefined' && value[ attrs.disableProperty ] !== true ) { + var temp = value[ $scope.indexProperty ]; + value[ $scope.tickProperty ] = $scope.backUp[ temp ][ $scope.tickProperty ]; + } + }); + $scope.refreshOutputModel(); + $scope.refreshButton(); + $scope.onReset(); + break; + case 'CLEAR': + $scope.tabIndex = $scope.tabIndex + 1; + $scope.onClear(); + break; + case 'FILTER': + $scope.tabIndex = helperItems.length - 1; + break; + default: + } + } + + // just to create a random variable name + function genRandomString( length ) { + var possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + var temp = ''; + for( var i=0; i < length; i++ ) { + temp += possible.charAt( Math.floor( Math.random() * possible.length )); + } + return temp; + } + + // count leading spaces + $scope.prepareGrouping = function() { + var spacing = 0; + angular.forEach( $scope.filteredModel, function( value ) { + value[ $scope.spacingProperty ] = spacing; + if ( value[ attrs.groupProperty ] === true ) { + spacing+=2; + } + else if ( value[ attrs.groupProperty ] === false ) { + spacing-=2; + } + }); + } + + // prepare original index + $scope.prepareIndex = function() { + var ctr = 0; + angular.forEach( $scope.filteredModel, function( value ) { + value[ $scope.indexProperty ] = ctr; + ctr++; + }); + } + + // navigate using up and down arrow + $scope.keyboardListener = function( e ) { + + var key = e.keyCode ? e.keyCode : e.which; + var isNavigationKey = false; + + // ESC key (close) + if ( key === 27 ) { + e.preventDefault(); + e.stopPropagation(); + $scope.toggleCheckboxes( e ); + } + + + // next element ( tab, down & right key ) + else if ( key === 40 || key === 39 || ( !e.shiftKey && key == 9 ) ) { + + isNavigationKey = true; + prevTabIndex = $scope.tabIndex; + $scope.tabIndex++; + if ( $scope.tabIndex > formElements.length - 1 ) { + $scope.tabIndex = 0; + prevTabIndex = formElements.length - 1; + } + while ( formElements[ $scope.tabIndex ].disabled === true ) { + $scope.tabIndex++; + if ( $scope.tabIndex > formElements.length - 1 ) { + $scope.tabIndex = 0; + } + if ( $scope.tabIndex === prevTabIndex ) { + break; + } + } + } + + // prev element ( shift+tab, up & left key ) + else if ( key === 38 || key === 37 || ( e.shiftKey && key == 9 ) ) { + isNavigationKey = true; + prevTabIndex = $scope.tabIndex; + $scope.tabIndex--; + if ( $scope.tabIndex < 0 ) { + $scope.tabIndex = formElements.length - 1; + prevTabIndex = 0; + } + while ( formElements[ $scope.tabIndex ].disabled === true ) { + $scope.tabIndex--; + if ( $scope.tabIndex === prevTabIndex ) { + break; + } + if ( $scope.tabIndex < 0 ) { + $scope.tabIndex = formElements.length - 1; + } + } + } + + if ( isNavigationKey === true ) { + + e.preventDefault(); + + // set focus on the checkbox + formElements[ $scope.tabIndex ].focus(); + var actEl = document.activeElement; + + if ( actEl.type.toUpperCase() === 'CHECKBOX' ) { + $scope.setFocusStyle( $scope.tabIndex ); + $scope.removeFocusStyle( prevTabIndex ); + } + else { + $scope.removeFocusStyle( prevTabIndex ); + $scope.removeFocusStyle( helperItemsLength ); + $scope.removeFocusStyle( formElements.length - 1 ); + } + } + + isNavigationKey = false; + } + + // set (add) CSS style on selected row + $scope.setFocusStyle = function( tabIndex ) { + angular.element( formElements[ tabIndex ] ).parent().parent().parent().addClass( 'multiSelectFocus' ); + } + + // remove CSS style on selected row + $scope.removeFocusStyle = function( tabIndex ) { + angular.element( formElements[ tabIndex ] ).parent().parent().parent().removeClass( 'multiSelectFocus' ); + } + + /********************* + ********************* + * + * 1) Initializations + * + ********************* + *********************/ + + // attrs to $scope - attrs-$scope - attrs - $scope + // Copy some properties that will be used on the template. They need to be in the $scope. + $scope.groupProperty = attrs.groupProperty; + $scope.tickProperty = attrs.tickProperty; + $scope.directiveId = attrs.directiveId; + + // Unfortunately I need to add these grouping properties into the input model + var tempStr = genRandomString( 5 ); + $scope.indexProperty = 'idx_' + tempStr; + $scope.spacingProperty = 'spc_' + tempStr; + + // set orientation css + if ( typeof attrs.orientation !== 'undefined' ) { + + if ( attrs.orientation.toUpperCase() === 'HORIZONTAL' ) { + $scope.orientationH = true; + $scope.orientationV = false; + } + else + { + $scope.orientationH = false; + $scope.orientationV = true; + } + } + + // get elements required for DOM operation + checkBoxLayer = element.children().children().next()[0]; + + // set max-height property if provided + if ( typeof attrs.maxHeight !== 'undefined' ) { + var layer = element.children().children().children()[0]; + angular.element( layer ).attr( "style", "height:" + attrs.maxHeight + "; overflow-y:scroll;" ); + } + + // some flags for easier checking + for ( var property in $scope.helperStatus ) { + if ( $scope.helperStatus.hasOwnProperty( property )) { + if ( + typeof attrs.helperElements !== 'undefined' + && attrs.helperElements.toUpperCase().indexOf( property.toUpperCase() ) === -1 + ) { + $scope.helperStatus[ property ] = false; + } + } + } + if ( typeof attrs.selectionMode !== 'undefined' && attrs.selectionMode.toUpperCase() === 'SINGLE' ) { + $scope.helperStatus[ 'all' ] = false; + $scope.helperStatus[ 'none' ] = false; + } + + // helper button icons.. I guess you can use html tag here if you want to. + $scope.icon = {}; + $scope.icon.selectAll = '✓'; // a tick icon + $scope.icon.selectNone = '×'; // x icon + $scope.icon.reset = '↶'; // undo icon + // this one is for the selected items + $scope.icon.tickMark = '✓'; // a tick icon + + // configurable button labels + // if ( typeof attrs.translation !== 'undefined' ) { + // $scope.lang.selectAll = $sce.trustAsHtml( $scope.icon.selectAll + '  ' + $scope.translation.selectAll ); + // $scope.lang.selectNone = $sce.trustAsHtml( $scope.icon.selectNone + '  ' + $scope.translation.selectNone ); + // $scope.lang.reset = $sce.trustAsHtml( $scope.icon.reset + '  ' + $scope.translation.reset ); + // $scope.lang.search = $scope.translation.search; + // $scope.lang.nothingSelected = $sce.trustAsHtml( $scope.translation.nothingSelected ); + // } + // else { + // $scope.lang.selectAll = $sce.trustAsHtml( $scope.icon.selectAll + '  Select All' ); + // $scope.lang.selectNone = $sce.trustAsHtml( $scope.icon.selectNone + '  Select None' ); + // $scope.lang.reset = $sce.trustAsHtml( $scope.icon.reset + '  Reset' ); + // $scope.lang.search = 'Search...'; + // $scope.lang.nothingSelected = 'None Selected'; + // } + // $scope.icon.tickMark = $sce.trustAsHtml( $scope.icon.tickMark ); + if ( typeof attrs.translation !== 'undefined' ) { + $scope.lang.selectAll = $sanitize( $scope.icon.selectAll + '  ' + $scope.translation.selectAll ); + $scope.lang.selectNone = $sanitize( $scope.icon.selectNone + '  ' + $scope.translation.selectNone ); + $scope.lang.reset = $sanitize( $scope.icon.reset + '  ' + $scope.translation.reset ); + $scope.lang.search = $scope.translation.search; + $scope.lang.nothingSelected = $sanitize( $scope.translation.nothingSelected ); + } + else { + $scope.lang.selectAll = $sanitize( $scope.icon.selectAll + '  Select All' ); + $scope.lang.selectNone = $sanitize( $scope.icon.selectNone + '  Select None' ); + $scope.lang.reset = $sanitize( $scope.icon.reset + '  Reset' ); + $scope.lang.search = 'Search...'; + $scope.lang.nothingSelected = 'None Selected'; + } + $scope.icon.tickMark = $sanitize( $scope.icon.tickMark ); + + // min length of keyword to trigger the filter function + if ( typeof attrs.MinSearchLength !== 'undefined' && parseInt( attrs.MinSearchLength ) > 0 ) { + vMinSearchLength = Math.floor( parseInt( attrs.MinSearchLength ) ); + } + + /******************************************************* + ******************************************************* + * + * 2) Logic starts here, initiated by watch 1 & watch 2 + * + ******************************************************* + *******************************************************/ + + // watch1, for changes in input model property + // updates multi-select when user select/deselect a single checkbox programatically + // https://github.com/isteven/angular-multi-select/issues/8 + $scope.$watch( 'inputModel' , function( newVal ) { + if ( newVal ) { + $scope.refreshOutputModel(); + $scope.refreshButton(); + } + }, true ); + + // watch2 for changes in input model as a whole + // this on updates the multi-select when a user load a whole new input-model. We also update the $scope.backUp variable + $scope.$watch( 'inputModel' , function( newVal ) { + if ( newVal ) { + $scope.backUp = angular.copy( $scope.inputModel ); + $scope.updateFilter(); + $scope.prepareGrouping(); + $scope.prepareIndex(); + $scope.refreshOutputModel(); + $scope.refreshButton(); + } + }); + + // watch for changes in directive state (disabled or enabled) + $scope.$watch( 'isDisabled' , function( newVal ) { + $scope.isDisabled = newVal; + }); + + // this is for touch enabled devices. We don't want to hide checkboxes on scroll. + var onTouchStart = function() { + $scope.$apply( function() { + $scope.scrolled = false; + }); + }; + angular.element( document ).bind( 'touchstart', onTouchStart); + var onTouchMove = function() { + $scope.$apply( function() { + $scope.scrolled = true; + }); + }; + angular.element( document ).bind( 'touchmove', onTouchMove); + + // unbind document events to prevent memory leaks + $scope.$on( '$destroy', function () { + angular.element( document ).unbind( 'touchstart', onTouchStart); + angular.element( document ).unbind( 'touchmove', onTouchMove); + }); + } + } +}]).run( [ '$templateCache' , function( $templateCache ) { + var template = + '' + + // main button + '' + + // overlay layer + '
' + + // container of the helper elements + '
' + + // container of the first 3 buttons, select all, none and reset + '
' + + // select all + ''+ + // select none + ''+ + // reset + '' + + '
' + + // the search box + '
'+ + // textfield + ''+ + // clear button + ' '+ + '
'+ + '
'+ + // selection items + '
'+ + '
'+ + // this is the spacing for grouped items + '
'+ + '
'+ + '
'+ + ''+ + '
'+ + // the tick/check mark + ''+ + '
'+ + '
'+ + '
'+ + '
'; + $templateCache.put( 'isteven-multi-select.htm' , template ); +}]); diff --git a/app/libraries/isteven-angular-multiselect/package.json b/app/libraries/isteven-angular-multiselect/package.json new file mode 100644 index 000000000..9aa2e3960 --- /dev/null +++ b/app/libraries/isteven-angular-multiselect/package.json @@ -0,0 +1,22 @@ +{ + "name": "isteven-angular-multiselect", + "version": "v4.0.0", + "description": "A multi select dropdown directive for AngularJS", + "main": [ + "isteven-multi-select.js", + "isteven-multi-select.css" + ], + "repository": { + "type": "git", + "url": "https://github.com/isteven/angular-multi-select.git" + }, + "keywords": [ + "angular" + ], + "author": "Ignatius Steven (https://github.com/isteven)", + "license": "MIT", + "bugs": { + "url": "https://github.com/isteven/angular-multi-select/issues" + }, + "homepage": "https://github.com/isteven/angular-multi-select" +} diff --git a/app/portainer/services/modalService.js b/app/portainer/services/modalService.js index dceffadca..4b896859d 100644 --- a/app/portainer/services/modalService.js +++ b/app/portainer/services/modalService.js @@ -1,7 +1,7 @@ import bootbox from 'bootbox'; angular.module('portainer.app') -.factory('ModalService', [function ModalServiceFactory() { +.factory('ModalService', [ '$sanitize', function ModalServiceFactory($sanitize) { 'use strict'; var service = {}; @@ -17,17 +17,18 @@ angular.module('portainer.app') var confirmButtons = function(options) { var buttons = { confirm: { - label: options.buttons.confirm.label, - className: options.buttons.confirm.className + label: $sanitize(options.buttons.confirm.label), + className: $sanitize(options.buttons.confirm.className) }, cancel: { - label: options.buttons.cancel && options.buttons.cancel.label ? options.buttons.cancel.label : 'Cancel' + label: options.buttons.cancel && options.buttons.cancel.label ? $sanitize(options.buttons.cancel.label) : 'Cancel' } }; return buttons; }; service.enlargeImage = function(image) { + image = $sanitize(image); bootbox.dialog({ message: '', className: 'image-zoom-modal', @@ -45,7 +46,7 @@ angular.module('portainer.app') applyBoxCSS(box); }; - service.prompt = function(options){ + function prompt(options){ var box = bootbox.prompt({ title: options.title, inputType: options.inputType, @@ -54,9 +55,9 @@ angular.module('portainer.app') callback: options.callback }); applyBoxCSS(box); - }; + } - service.customPrompt = function(options, optionToggled) { + function customPrompt(options, optionToggled) { var box = bootbox.prompt({ title: options.title, inputType: options.inputType, @@ -67,7 +68,7 @@ angular.module('portainer.app') applyBoxCSS(box); box.find('.bootbox-body').prepend('

' + options.message + '

'); box.find('.bootbox-input-checkbox').prop('checked', optionToggled); - }; + } service.confirmAccessControlUpdate = function(callback) { service.confirm({ @@ -98,6 +99,7 @@ angular.module('portainer.app') }; service.confirmDeletion = function(message, callback) { + message = $sanitize(message); service.confirm({ title: 'Are you sure ?', message: message, @@ -112,7 +114,7 @@ angular.module('portainer.app') }; service.confirmContainerDeletion = function(title, callback) { - service.prompt({ + prompt({ title: title, inputType: 'checkbox', inputOptions: [ @@ -132,7 +134,7 @@ angular.module('portainer.app') }; service.confirmContainerRecreation = function(callback) { - service.customPrompt({ + customPrompt({ title: 'Are you sure?', message: 'You\'re about to re-create this container, any non-persisted data will be lost. This container will be removed and another one will be created using the same configuration.', inputType: 'checkbox', @@ -181,7 +183,7 @@ angular.module('portainer.app') }; service.confirmServiceForceUpdate = function(message, callback) { - service.customPrompt({ + customPrompt({ title: 'Are you sure ?', message: message, inputType: 'checkbox', diff --git a/app/vendors.js b/app/vendors.js index c42341184..b1ff41f4e 100644 --- a/app/vendors.js +++ b/app/vendors.js @@ -1,6 +1,5 @@ import 'ui-select/dist/select.css'; import 'bootstrap/dist/css/bootstrap.css'; -import 'isteven-angular-multiselect/isteven-multi-select.css'; import '@fortawesome/fontawesome-free-webfonts/css/fa-brands.css'; import '@fortawesome/fontawesome-free-webfonts/css/fa-solid.css'; import '@fortawesome/fontawesome-free-webfonts/css/fontawesome.css'; @@ -19,7 +18,6 @@ window.angular = angular; import 'moment'; import '@uirouter/angularjs'; import 'ui-select'; -import 'isteven-angular-multiselect/isteven-multi-select.js'; import 'angular-cookies'; import 'angular-sanitize'; import 'ng-file-upload'; diff --git a/package.json b/package.json index 74a2bd14a..c2ee71be8 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,6 @@ "chart.js": "~2.6.0", "codemirror": "~5.30.0", "filesize": "~3.3.0", - "isteven-angular-multiselect": "~4.0.0", "jquery": "3.4.0", "js-yaml": "~3.13.1", "lodash-es": "^4.17.15", diff --git a/yarn.lock b/yarn.lock index 681a1389e..8ef836d1b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6492,11 +6492,6 @@ istanbul@~0.1.40: which "1.0.x" wordwrap "0.0.x" -isteven-angular-multiselect@~4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/isteven-angular-multiselect/-/isteven-angular-multiselect-4.0.0.tgz#70276da5ff3bc4d9a0887dc585ee26a1a26a8ed6" - integrity sha1-cCdtpf87xNmgiH3Fhe4moaJqjtY= - isurl@^1.0.0-alpha5: version "1.0.0" resolved "https://registry.yarnpkg.com/isurl/-/isurl-1.0.0.tgz#b27f4f49f3cdaa3ea44a0a5b7f3462e6edc39d67" From 68fe5d69061de3726d879372c0c0e6e9c27e6506 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 8 Oct 2019 11:45:16 +1300 Subject: [PATCH 40/49] fix(api): fix invalid restriction on StatusInspectVersion --- api/http/handler/status/handler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/http/handler/status/handler.go b/api/http/handler/status/handler.go index 00ed6b799..af11d25a3 100644 --- a/api/http/handler/status/handler.go +++ b/api/http/handler/status/handler.go @@ -24,7 +24,7 @@ func NewHandler(bouncer *security.RequestBouncer, status *portainer.Status) *Han h.Handle("/status", bouncer.PublicAccess(httperror.LoggerHandler(h.statusInspect))).Methods(http.MethodGet) h.Handle("/status/version", - bouncer.RestrictedAccess(http.HandlerFunc(h.statusInspectVersion))).Methods(http.MethodGet) + bouncer.AuthenticatedAccess(http.HandlerFunc(h.statusInspectVersion))).Methods(http.MethodGet) return h } From ef4c138e03bd6fbfb62fb63da54f93822fd81d34 Mon Sep 17 00:00:00 2001 From: firecyberice <10451478+firecyberice@users.noreply.github.com> Date: Tue, 8 Oct 2019 00:52:37 +0200 Subject: [PATCH 41/49] fix(authentication): trim the newline character from the password string (#3091) --- api/cmd/portainer/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 28b4238d8..278ab0faa 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -623,7 +623,7 @@ func main() { if err != nil { log.Fatal(err) } - adminPasswordHash, err = cryptoService.Hash(string(content)) + adminPasswordHash, err = cryptoService.Hash(strings.TrimSuffix(string(content), "\n")) if err != nil { log.Fatal(err) } From 9aa52a69753e76ee1725eaff03850a93117d0433 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 8 Oct 2019 13:17:58 +1300 Subject: [PATCH 42/49] feat(settings): add new settings to disable volume browser (#3239) * feat(settings): add new settings to disable volume browser * feat(api): update setting to be compliant with RBAC * refactor(api): update method comment * fix(api): remove volume browsing authorizations by default * feat(settings): rewrite volume management setting description * feat(settings): rewrite volume management setting tooltip * Update app/portainer/views/settings/settings.html Co-Authored-By: William --- api/authorizations.go | 46 ++++++++++++++ api/bolt/init.go | 9 --- api/bolt/migrator/migrate_dbversion19.go | 11 ++++ api/bolt/migrator/migrator.go | 5 ++ api/bolt/role/role.go | 14 +++-- api/cmd/portainer/main.go | 13 ++-- api/http/handler/extensions/upgrade.go | 12 ---- api/http/handler/settings/handler.go | 13 ++-- api/http/handler/settings/settings_public.go | 2 + api/http/handler/settings/settings_update.go | 35 +++++++++++ api/http/proxy/docker_transport.go | 60 ++++++++++++++++++- api/http/proxy/factory.go | 2 + api/http/proxy/factory_local.go | 1 + api/http/proxy/factory_local_windows.go | 1 + api/http/proxy/manager.go | 2 + api/http/server.go | 4 ++ api/portainer.go | 6 +- .../volumes-datatable/volumesDatatable.html | 8 ++- app/docker/views/volumes/volumes.html | 2 +- app/docker/views/volumes/volumesController.js | 16 ++++- app/portainer/models/settings.js | 2 + app/portainer/services/stateManager.js | 6 ++ app/portainer/views/settings/settings.html | 11 ++++ .../views/settings/settingsController.js | 6 +- 24 files changed, 239 insertions(+), 48 deletions(-) diff --git a/api/authorizations.go b/api/authorizations.go index c5dc5c967..e2101bdae 100644 --- a/api/authorizations.go +++ b/api/authorizations.go @@ -56,6 +56,52 @@ func DefaultPortainerAuthorizations() Authorizations { } } +// UpdateVolumeBrowsingAuthorizations will update all the volume browsing authorizations for each role (except endpoint administrator) +// based on the specified removeAuthorizations parameter. If removeAuthorizations is set to true, all +// the authorizations will be dropped for the each role. If removeAuthorizations is set to false, the authorizations +// will be reset based for each role. +func (service AuthorizationService) UpdateVolumeBrowsingAuthorizations(remove bool) error { + roles, err := service.roleService.Roles() + if err != nil { + return err + } + + for _, role := range roles { + // all roles except endpoint administrator + if role.ID != RoleID(1) { + updateRoleVolumeBrowsingAuthorizations(&role, remove) + + err := service.roleService.UpdateRole(role.ID, &role) + if err != nil { + return err + } + } + } + + return nil +} + +func updateRoleVolumeBrowsingAuthorizations(role *Role, removeAuthorizations bool) { + if !removeAuthorizations { + delete(role.Authorizations, OperationDockerAgentBrowseDelete) + delete(role.Authorizations, OperationDockerAgentBrowseGet) + delete(role.Authorizations, OperationDockerAgentBrowseList) + delete(role.Authorizations, OperationDockerAgentBrowsePut) + delete(role.Authorizations, OperationDockerAgentBrowseRename) + return + } + + role.Authorizations[OperationDockerAgentBrowseGet] = true + role.Authorizations[OperationDockerAgentBrowseList] = true + + // Standard-user + if role.ID == RoleID(3) { + role.Authorizations[OperationDockerAgentBrowseDelete] = true + role.Authorizations[OperationDockerAgentBrowsePut] = true + role.Authorizations[OperationDockerAgentBrowseRename] = true + } +} + // RemoveTeamAccessPolicies will remove all existing access policies associated to the specified team func (service *AuthorizationService) RemoveTeamAccessPolicies(teamID TeamID) error { endpoints, err := service.endpointService.Endpoints() diff --git a/api/bolt/init.go b/api/bolt/init.go index b8b731bf2..a9b941179 100644 --- a/api/bolt/init.go +++ b/api/bolt/init.go @@ -218,8 +218,6 @@ func (store *Store) Init() error { portainer.OperationDockerAgentPing: true, portainer.OperationDockerAgentList: true, portainer.OperationDockerAgentHostInfo: true, - portainer.OperationDockerAgentBrowseGet: true, - portainer.OperationDockerAgentBrowseList: true, portainer.OperationPortainerStackList: true, portainer.OperationPortainerStackInspect: true, portainer.OperationPortainerStackFile: true, @@ -342,11 +340,6 @@ func (store *Store) Init() error { portainer.OperationDockerAgentPing: true, portainer.OperationDockerAgentList: true, portainer.OperationDockerAgentHostInfo: true, - portainer.OperationDockerAgentBrowseDelete: true, - portainer.OperationDockerAgentBrowseGet: true, - portainer.OperationDockerAgentBrowseList: true, - portainer.OperationDockerAgentBrowsePut: true, - portainer.OperationDockerAgentBrowseRename: true, portainer.OperationDockerAgentUndefined: true, portainer.OperationPortainerResourceControlCreate: true, portainer.OperationPortainerResourceControlUpdate: true, @@ -413,8 +406,6 @@ func (store *Store) Init() error { portainer.OperationDockerAgentPing: true, portainer.OperationDockerAgentList: true, portainer.OperationDockerAgentHostInfo: true, - portainer.OperationDockerAgentBrowseGet: true, - portainer.OperationDockerAgentBrowseList: true, portainer.OperationPortainerStackList: true, portainer.OperationPortainerStackInspect: true, portainer.OperationPortainerStackFile: true, diff --git a/api/bolt/migrator/migrate_dbversion19.go b/api/bolt/migrator/migrate_dbversion19.go index 569bc43a9..f98d78ccc 100644 --- a/api/bolt/migrator/migrate_dbversion19.go +++ b/api/bolt/migrator/migrate_dbversion19.go @@ -15,3 +15,14 @@ func (m *Migrator) updateUsersToDBVersion20() error { authorizationService := portainer.NewAuthorizationService(authorizationServiceParameters) return authorizationService.UpdateUsersAuthorizations() } + +func (m *Migrator) updateSettingsToDBVersion20() error { + legacySettings, err := m.settingsService.Settings() + if err != nil { + return err + } + + legacySettings.AllowVolumeBrowserForRegularUsers = false + + return m.settingsService.UpdateSettings(legacySettings) +} diff --git a/api/bolt/migrator/migrator.go b/api/bolt/migrator/migrator.go index 409343cf1..52e4ee03f 100644 --- a/api/bolt/migrator/migrator.go +++ b/api/bolt/migrator/migrator.go @@ -271,6 +271,11 @@ func (m *Migrator) Migrate() error { if err != nil { return err } + + err = m.updateSettingsToDBVersion20() + if err != nil { + return err + } } return m.versionService.StoreDBVersion(portainer.DBVersion) diff --git a/api/bolt/role/role.go b/api/bolt/role/role.go index 8a4e3e975..36cd8e7d1 100644 --- a/api/bolt/role/role.go +++ b/api/bolt/role/role.go @@ -66,18 +66,24 @@ func (service *Service) Roles() ([]portainer.Role, error) { } // CreateRole creates a new Role. -func (service *Service) CreateRole(set *portainer.Role) error { +func (service *Service) CreateRole(role *portainer.Role) error { return service.db.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) id, _ := bucket.NextSequence() - set.ID = portainer.RoleID(id) + role.ID = portainer.RoleID(id) - data, err := internal.MarshalObject(set) + data, err := internal.MarshalObject(role) if err != nil { return err } - return bucket.Put(internal.Itob(int(set.ID)), data) + return bucket.Put(internal.Itob(int(role.ID)), data) }) } + +// UpdateRole updates a role. +func (service *Service) UpdateRole(ID portainer.RoleID, role *portainer.Role) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, role) +} diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 278ab0faa..2d83d10c1 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -259,18 +259,15 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL LogoURL: *flags.Logo, AuthenticationMethod: portainer.AuthenticationInternal, LDAPSettings: portainer.LDAPSettings{ - AutoCreateUsers: true, - TLSConfig: portainer.TLSConfiguration{}, - SearchSettings: []portainer.LDAPSearchSettings{ - portainer.LDAPSearchSettings{}, - }, - GroupSearchSettings: []portainer.LDAPGroupSearchSettings{ - portainer.LDAPGroupSearchSettings{}, - }, + AutoCreateUsers: true, + TLSConfig: portainer.TLSConfiguration{}, + SearchSettings: []portainer.LDAPSearchSettings{}, + GroupSearchSettings: []portainer.LDAPGroupSearchSettings{}, }, OAuthSettings: portainer.OAuthSettings{}, AllowBindMountsForRegularUsers: true, AllowPrivilegedModeForRegularUsers: true, + AllowVolumeBrowserForRegularUsers: false, EnableHostManagementFeatures: false, SnapshotInterval: *flags.SnapshotInterval, EdgeAgentCheckinInterval: portainer.DefaultEdgeAgentCheckinIntervalInSeconds, diff --git a/api/http/handler/extensions/upgrade.go b/api/http/handler/extensions/upgrade.go index 2d314638a..b0f37f5d7 100644 --- a/api/http/handler/extensions/upgrade.go +++ b/api/http/handler/extensions/upgrade.go @@ -35,11 +35,6 @@ func (handler *Handler) upgradeRBACData() error { if err != nil { return err } - - //err = handler.AuthorizationService.UpdateUserAuthorizationsFromPolicies(&endpointGroup.UserAccessPolicies, &endpointGroup.TeamAccessPolicies) - //if err != nil { - // return err - //} } endpoints, err := handler.EndpointService.Endpoints() @@ -60,14 +55,7 @@ func (handler *Handler) upgradeRBACData() error { if err != nil { return err } - - //err = handler.AuthorizationService.UpdateUserAuthorizationsFromPolicies(&endpoint.UserAccessPolicies, &endpoint.TeamAccessPolicies) - //if err != nil { - // return err - //} } return handler.AuthorizationService.UpdateUsersAuthorizations() - - //return nil } diff --git a/api/http/handler/settings/handler.go b/api/http/handler/settings/handler.go index db22c92ab..1f688f343 100644 --- a/api/http/handler/settings/handler.go +++ b/api/http/handler/settings/handler.go @@ -17,11 +17,14 @@ func hideFields(settings *portainer.Settings) { // Handler is the HTTP handler used to handle settings operations. type Handler struct { *mux.Router - SettingsService portainer.SettingsService - LDAPService portainer.LDAPService - FileService portainer.FileService - JobScheduler portainer.JobScheduler - ScheduleService portainer.ScheduleService + SettingsService portainer.SettingsService + LDAPService portainer.LDAPService + FileService portainer.FileService + JobScheduler portainer.JobScheduler + ScheduleService portainer.ScheduleService + RoleService portainer.RoleService + ExtensionService portainer.ExtensionService + AuthorizationService *portainer.AuthorizationService } // NewHandler creates a handler to manage settings operations. diff --git a/api/http/handler/settings/settings_public.go b/api/http/handler/settings/settings_public.go index 38eb8d4e3..afa8e85dd 100644 --- a/api/http/handler/settings/settings_public.go +++ b/api/http/handler/settings/settings_public.go @@ -14,6 +14,7 @@ type publicSettingsResponse struct { AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"` AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"` AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` + AllowVolumeBrowserForRegularUsers bool `json:"AllowVolumeBrowserForRegularUsers"` EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"` ExternalTemplates bool `json:"ExternalTemplates"` OAuthLoginURI string `json:"OAuthLoginURI"` @@ -31,6 +32,7 @@ func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) * AuthenticationMethod: settings.AuthenticationMethod, AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers, AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers, + AllowVolumeBrowserForRegularUsers: settings.AllowVolumeBrowserForRegularUsers, EnableHostManagementFeatures: settings.EnableHostManagementFeatures, ExternalTemplates: false, OAuthLoginURI: fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=%s&prompt=login", diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index 6de707c38..cf0994648 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -19,6 +19,7 @@ type settingsUpdatePayload struct { OAuthSettings *portainer.OAuthSettings AllowBindMountsForRegularUsers *bool AllowPrivilegedModeForRegularUsers *bool + AllowVolumeBrowserForRegularUsers *bool EnableHostManagementFeatures *bool SnapshotInterval *string TemplatesURL *string @@ -93,6 +94,12 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * settings.AllowPrivilegedModeForRegularUsers = *payload.AllowPrivilegedModeForRegularUsers } + updateAuthorizations := false + if payload.AllowVolumeBrowserForRegularUsers != nil { + settings.AllowVolumeBrowserForRegularUsers = *payload.AllowVolumeBrowserForRegularUsers + updateAuthorizations = true + } + if payload.EnableHostManagementFeatures != nil { settings.EnableHostManagementFeatures = *payload.EnableHostManagementFeatures } @@ -118,9 +125,37 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist settings changes inside the database", err} } + if updateAuthorizations { + err := handler.updateVolumeBrowserSetting(settings) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update RBAC authorizations", err} + } + } + return response.JSON(w, settings) } +func (handler *Handler) updateVolumeBrowserSetting(settings *portainer.Settings) error { + err := handler.AuthorizationService.UpdateVolumeBrowsingAuthorizations(settings.AllowVolumeBrowserForRegularUsers) + if err != nil { + return err + } + + extension, err := handler.ExtensionService.Extension(portainer.RBACExtension) + if err != nil && err != portainer.ErrObjectNotFound { + return err + } + + if extension != nil { + err = handler.AuthorizationService.UpdateUsersAuthorizations() + if err != nil { + return err + } + } + + return nil +} + func (handler *Handler) updateSnapshotInterval(settings *portainer.Settings, snapshotInterval string) error { settings.SnapshotInterval = snapshotInterval diff --git a/api/http/proxy/docker_transport.go b/api/http/proxy/docker_transport.go index 6443a8268..8abb21ce3 100644 --- a/api/http/proxy/docker_transport.go +++ b/api/http/proxy/docker_transport.go @@ -26,6 +26,7 @@ type ( SettingsService portainer.SettingsService SignatureService portainer.DigitalSignatureService ReverseTunnelService portainer.ReverseTunnelService + ExtensionService portainer.ExtensionService endpointIdentifier portainer.EndpointID endpointType portainer.EndpointType } @@ -129,7 +130,8 @@ func (p *proxyTransport) proxyAgentRequest(r *http.Request) (*http.Response, err if !found || len(volumeIDParameter) < 1 { return p.administratorOperation(r) } - return p.restrictedOperation(r, volumeIDParameter[0]) + + return p.restrictedVolumeBrowserOperation(r, volumeIDParameter[0]) } return p.executeDockerRequest(r) @@ -386,6 +388,62 @@ func (p *proxyTransport) restrictedOperation(request *http.Request, resourceID s return p.executeDockerRequest(request) } +// restrictedVolumeBrowserOperation is similar to restrictedOperation but adds an extra check on a specific setting +func (p *proxyTransport) restrictedVolumeBrowserOperation(request *http.Request, resourceID string) (*http.Response, error) { + var err error + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, err + } + + if tokenData.Role != portainer.AdministratorRole { + settings, err := p.SettingsService.Settings() + if err != nil { + return nil, err + } + + _, err = p.ExtensionService.Extension(portainer.RBACExtension) + if err == portainer.ErrObjectNotFound && !settings.AllowVolumeBrowserForRegularUsers { + return writeAccessDeniedResponse() + } else if err != nil && err != portainer.ErrObjectNotFound { + return nil, err + } + + user, err := p.UserService.User(tokenData.ID) + if err != nil { + return nil, err + } + + endpointResourceAccess := false + _, ok := user.EndpointAuthorizations[p.endpointIdentifier][portainer.EndpointResourcesAccess] + if ok { + endpointResourceAccess = true + } + + teamMemberships, err := p.TeamMembershipService.TeamMembershipsByUserID(tokenData.ID) + if err != nil { + return nil, err + } + + userTeamIDs := make([]portainer.TeamID, 0) + for _, membership := range teamMemberships { + userTeamIDs = append(userTeamIDs, membership.TeamID) + } + + resourceControls, err := p.ResourceControlService.ResourceControls() + if err != nil { + return nil, err + } + + resourceControl := getResourceControlByResourceID(resourceID, resourceControls) + if !endpointResourceAccess && (resourceControl == nil || !canUserAccessResource(tokenData.ID, userTeamIDs, resourceControl)) { + return writeAccessDeniedResponse() + } + } + + return p.executeDockerRequest(request) +} + // rewriteOperationWithLabelFiltering will create a new operation context with data that will be used // to decorate the original request's response as well as retrieve all the black listed labels // to filter the resources. diff --git a/api/http/proxy/factory.go b/api/http/proxy/factory.go index a1f4e1e32..b9416707d 100644 --- a/api/http/proxy/factory.go +++ b/api/http/proxy/factory.go @@ -23,6 +23,7 @@ type proxyFactory struct { DockerHubService portainer.DockerHubService SignatureService portainer.DigitalSignatureService ReverseTunnelService portainer.ReverseTunnelService + ExtensionService portainer.ExtensionService } func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler { @@ -77,6 +78,7 @@ func (factory *proxyFactory) createDockerReverseProxy(u *url.URL, endpoint *port RegistryService: factory.RegistryService, DockerHubService: factory.DockerHubService, ReverseTunnelService: factory.ReverseTunnelService, + ExtensionService: factory.ExtensionService, dockerTransport: &http.Transport{}, endpointIdentifier: endpoint.ID, endpointType: endpoint.Type, diff --git a/api/http/proxy/factory_local.go b/api/http/proxy/factory_local.go index 37b7f5401..c9ab44b81 100644 --- a/api/http/proxy/factory_local.go +++ b/api/http/proxy/factory_local.go @@ -18,6 +18,7 @@ func (factory *proxyFactory) newLocalProxy(path string, endpoint *portainer.Endp SettingsService: factory.SettingsService, RegistryService: factory.RegistryService, DockerHubService: factory.DockerHubService, + ExtensionService: factory.ExtensionService, dockerTransport: newSocketTransport(path), ReverseTunnelService: factory.ReverseTunnelService, endpointIdentifier: endpoint.ID, diff --git a/api/http/proxy/factory_local_windows.go b/api/http/proxy/factory_local_windows.go index 0c6726a8d..3f1d860d7 100644 --- a/api/http/proxy/factory_local_windows.go +++ b/api/http/proxy/factory_local_windows.go @@ -22,6 +22,7 @@ func (factory *proxyFactory) newLocalProxy(path string, endpoint *portainer.Endp RegistryService: factory.RegistryService, DockerHubService: factory.DockerHubService, ReverseTunnelService: factory.ReverseTunnelService, + ExtensionService: factory.ExtensionService, dockerTransport: newNamedPipeTransport(path), endpointIdentifier: endpoint.ID, endpointType: endpoint.Type, diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go index 5d87f2393..7a1f38580 100644 --- a/api/http/proxy/manager.go +++ b/api/http/proxy/manager.go @@ -38,6 +38,7 @@ type ( DockerHubService portainer.DockerHubService SignatureService portainer.DigitalSignatureService ReverseTunnelService portainer.ReverseTunnelService + ExtensionService portainer.ExtensionService } ) @@ -56,6 +57,7 @@ func NewManager(parameters *ManagerParams) *Manager { DockerHubService: parameters.DockerHubService, SignatureService: parameters.SignatureService, ReverseTunnelService: parameters.ReverseTunnelService, + ExtensionService: parameters.ExtensionService, }, reverseTunnelService: parameters.ReverseTunnelService, } diff --git a/api/http/server.go b/api/http/server.go index a2a48cfe8..664c1989f 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -91,6 +91,7 @@ func (server *Server) Start() error { DockerHubService: server.DockerHubService, SignatureService: server.SignatureService, ReverseTunnelService: server.ReverseTunnelService, + ExtensionService: server.ExtensionService, } proxyManager := proxy.NewManager(proxyManagerParameters) @@ -196,6 +197,9 @@ func (server *Server) Start() error { settingsHandler.FileService = server.FileService settingsHandler.JobScheduler = server.JobScheduler settingsHandler.ScheduleService = server.ScheduleService + settingsHandler.RoleService = server.RoleService + settingsHandler.ExtensionService = server.ExtensionService + settingsHandler.AuthorizationService = authorizationService var stackHandler = stacks.NewHandler(requestBouncer) stackHandler.FileService = server.FileService diff --git a/api/portainer.go b/api/portainer.go index d5d546bf5..be332f7e8 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -106,6 +106,7 @@ type ( OAuthSettings OAuthSettings `json:"OAuthSettings"` AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"` AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` + AllowVolumeBrowserForRegularUsers bool `json:"AllowVolumeBrowserForRegularUsers"` SnapshotInterval string `json:"SnapshotInterval"` TemplatesURL string `json:"TemplatesURL"` EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"` @@ -637,7 +638,8 @@ type ( RoleService interface { Role(ID RoleID) (*Role, error) Roles() ([]Role, error) - CreateRole(set *Role) error + CreateRole(role *Role) error + UpdateRole(ID RoleID, role *Role) error } // TeamService represents a service for managing user data @@ -903,7 +905,7 @@ const ( // APIVersion is the version number of the Portainer API APIVersion = "1.22.0" // DBVersion is the version number of the Portainer database - DBVersion = 20 + DBVersion = 21 // AssetsServerURL represents the URL of the Portainer asset server AssetsServerURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com" // MessageOfTheDayURL represents the URL where Portainer MOTD message can be retrieved diff --git a/app/docker/components/datatables/volumes-datatable/volumesDatatable.html b/app/docker/components/datatables/volumes-datatable/volumesDatatable.html index ca115011d..1266ea5be 100644 --- a/app/docker/components/datatables/volumes-datatable/volumesDatatable.html +++ b/app/docker/components/datatables/volumes-datatable/volumesDatatable.html @@ -149,9 +149,11 @@ {{ item.Id | truncate:40 }} {{ item.Id | truncate:40 }} - - browse - + + + browse + + Unused {{ item.StackName ? item.StackName : '-' }} diff --git a/app/docker/views/volumes/volumes.html b/app/docker/views/volumes/volumes.html index c31b7ac3b..425d93a75 100644 --- a/app/docker/views/volumes/volumes.html +++ b/app/docker/views/volumes/volumes.html @@ -16,7 +16,7 @@ remove-action="removeAction" show-ownership-column="applicationState.application.authentication" show-host-column="applicationState.endpoint.mode.agentProxy && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'" - show-browse-action="applicationState.endpoint.mode.agentProxy" + show-browse-action="showBrowseAction" offline-mode="offlineMode" refresh-callback="getVolumes" > diff --git a/app/docker/views/volumes/volumesController.js b/app/docker/views/volumes/volumesController.js index 0cad1b914..6cde69eb2 100644 --- a/app/docker/views/volumes/volumesController.js +++ b/app/docker/views/volumes/volumesController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('VolumesController', ['$q', '$scope', '$state', 'VolumeService', 'ServiceService', 'VolumeHelper', 'Notifications', 'HttpRequestHelper', 'EndpointProvider', -function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notifications, HttpRequestHelper, EndpointProvider) { +.controller('VolumesController', ['$q', '$scope', '$state', 'VolumeService', 'ServiceService', 'VolumeHelper', 'Notifications', 'HttpRequestHelper', 'EndpointProvider', 'Authentication', 'ExtensionService', +function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notifications, HttpRequestHelper, EndpointProvider, Authentication, ExtensionService) { $scope.removeAction = function (selectedItems) { var actionCount = selectedItems.length; @@ -56,6 +56,18 @@ function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notif function initView() { getVolumes(); + + $scope.showBrowseAction = $scope.applicationState.endpoint.mode.agentProxy; + + ExtensionService.extensionEnabled(ExtensionService.EXTENSIONS.RBAC) + .then(function success(extensionEnabled) { + if (!extensionEnabled) { + var isAdmin = Authentication.isAdmin(); + if (!$scope.applicationState.application.enableVolumeBrowserForNonAdminUsers && !isAdmin) { + $scope.showBrowseAction = false; + } + } + }); } initView(); diff --git a/app/portainer/models/settings.js b/app/portainer/models/settings.js index 4ac71e8a3..aa58dfd8e 100644 --- a/app/portainer/models/settings.js +++ b/app/portainer/models/settings.js @@ -6,6 +6,7 @@ export function SettingsViewModel(data) { this.OAuthSettings = new OAuthSettingsViewModel(data.OAuthSettings); this.AllowBindMountsForRegularUsers = data.AllowBindMountsForRegularUsers; this.AllowPrivilegedModeForRegularUsers = data.AllowPrivilegedModeForRegularUsers; + this.AllowVolumeBrowserForRegularUsers = data.AllowVolumeBrowserForRegularUsers; this.SnapshotInterval = data.SnapshotInterval; this.TemplatesURL = data.TemplatesURL; this.ExternalTemplates = data.ExternalTemplates; @@ -16,6 +17,7 @@ export function SettingsViewModel(data) { export function PublicSettingsViewModel(settings) { this.AllowBindMountsForRegularUsers = settings.AllowBindMountsForRegularUsers; this.AllowPrivilegedModeForRegularUsers = settings.AllowPrivilegedModeForRegularUsers; + this.AllowVolumeBrowserForRegularUsers = settings.AllowVolumeBrowserForRegularUsers; this.AuthenticationMethod = settings.AuthenticationMethod; this.EnableHostManagementFeatures = settings.EnableHostManagementFeatures; this.ExternalTemplates = settings.ExternalTemplates; diff --git a/app/portainer/services/stateManager.js b/app/portainer/services/stateManager.js index 070d32e72..65ee29476 100644 --- a/app/portainer/services/stateManager.js +++ b/app/portainer/services/stateManager.js @@ -58,6 +58,11 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin LocalStorage.storeApplicationState(state.application); }; + manager.updateEnableVolumeBrowserForNonAdminUsers = function(enableVolumeBrowserForNonAdminUsers) { + state.application.enableVolumeBrowserForNonAdminUsers = enableVolumeBrowserForNonAdminUsers; + LocalStorage.storeApplicationState(state.application); + }; + function assignStateFromStatusAndSettings(status, settings) { state.application.authentication = status.Authentication; state.application.analytics = status.Analytics; @@ -67,6 +72,7 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin state.application.logo = settings.LogoURL; state.application.snapshotInterval = settings.SnapshotInterval; state.application.enableHostManagementFeatures = settings.EnableHostManagementFeatures; + state.application.enableVolumeBrowserForNonAdminUsers = settings.AllowVolumeBrowserForRegularUsers; state.application.validity = moment().unix(); } diff --git a/app/portainer/views/settings/settings.html b/app/portainer/views/settings/settings.html index 6dc4a7750..c80f69aab 100644 --- a/app/portainer/views/settings/settings.html +++ b/app/portainer/views/settings/settings.html @@ -101,6 +101,17 @@
+
+
+ + +
+