From a2b4cd8050c58928d2263a674acecca3a47acc60 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 19 Sep 2017 16:58:30 +0200 Subject: [PATCH] feat(networks): add UAC (#1196) --- api/http/handler/resource_control.go | 2 + api/http/proxy/decorator.go | 24 +++++++ api/http/proxy/filter.go | 25 +++++++ api/http/proxy/networks.go | 66 +++++++++++++++++ api/http/proxy/transport.go | 33 +++++++-- api/portainer.go | 2 + .../createNetwork/createNetworkController.js | 70 ++++++++++++------ .../createNetwork/createnetwork.html | 6 +- app/components/network/network.html | 9 +++ app/components/network/networkController.js | 6 +- app/components/networks/networks.html | 72 ++++++------------- app/components/networks/networksController.js | 54 ++++---------- app/models/docker/network.js | 16 +++++ app/services/docker/networkService.js | 31 ++++++++ app/services/notifications.js | 2 + 15 files changed, 295 insertions(+), 123 deletions(-) create mode 100644 api/http/proxy/networks.go create mode 100644 app/models/docker/network.js diff --git a/api/http/handler/resource_control.go b/api/http/handler/resource_control.go index 7c35dec39..d84824cb5 100644 --- a/api/http/handler/resource_control.go +++ b/api/http/handler/resource_control.go @@ -78,6 +78,8 @@ func (handler *ResourceHandler) handlePostResources(w http.ResponseWriter, r *ht resourceControlType = portainer.ServiceResourceControl case "volume": resourceControlType = portainer.VolumeResourceControl + case "network": + resourceControlType = portainer.NetworkResourceControl default: httperror.WriteErrorResponse(w, portainer.ErrInvalidResourceControlType, http.StatusBadRequest, handler.Logger) return diff --git a/api/http/proxy/decorator.go b/api/http/proxy/decorator.go index cc35fa7a3..7881f697a 100644 --- a/api/http/proxy/decorator.go +++ b/api/http/proxy/decorator.go @@ -82,6 +82,30 @@ func decorateServiceList(serviceData []interface{}, resourceControls []portainer return decoratedServiceData, nil } +// decorateNetworkList loops through all networks and will decorate any network with an existing resource control. +// Network object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkList +func decorateNetworkList(networkData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { + decoratedNetworkData := make([]interface{}, 0) + + for _, network := range networkData { + + networkObject := network.(map[string]interface{}) + if networkObject[networkIdentifier] == nil { + return nil, ErrDockerNetworkIdentifierNotFound + } + + networkID := networkObject[networkIdentifier].(string) + resourceControl := getResourceControlByResourceID(networkID, resourceControls) + if resourceControl != nil { + networkObject = decorateObject(networkObject, resourceControl) + } + + decoratedNetworkData = append(decoratedNetworkData, networkObject) + } + + return decoratedNetworkData, nil +} + func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} { metadata := make(map[string]interface{}) metadata["ResourceControl"] = resourceControl diff --git a/api/http/proxy/filter.go b/api/http/proxy/filter.go index 0e66ab4fd..3f555f0be 100644 --- a/api/http/proxy/filter.go +++ b/api/http/proxy/filter.go @@ -110,3 +110,28 @@ func filterServiceList(serviceData []interface{}, resourceControls []portainer.R return filteredServiceData, nil } + +// filterNetworkList loops through all networks, filters networks without any resource control (public resources) or with +// any resource control giving access to the user (these networks will be decorated). +// Network object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkList +func filterNetworkList(networkData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) { + filteredNetworkData := make([]interface{}, 0) + + for _, network := range networkData { + networkObject := network.(map[string]interface{}) + if networkObject[networkIdentifier] == nil { + return nil, ErrDockerNetworkIdentifierNotFound + } + + networkID := networkObject[networkIdentifier].(string) + resourceControl := getResourceControlByResourceID(networkID, resourceControls) + if resourceControl == nil { + filteredNetworkData = append(filteredNetworkData, networkObject) + } else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) { + networkObject = decorateObject(networkObject, resourceControl) + filteredNetworkData = append(filteredNetworkData, networkObject) + } + } + + return filteredNetworkData, nil +} diff --git a/api/http/proxy/networks.go b/api/http/proxy/networks.go new file mode 100644 index 000000000..2e2549408 --- /dev/null +++ b/api/http/proxy/networks.go @@ -0,0 +1,66 @@ +package proxy + +import ( + "net/http" + + "github.com/portainer/portainer" +) + +const ( + // ErrDockerNetworkIdentifierNotFound defines an error raised when Portainer is unable to find a network identifier + ErrDockerNetworkIdentifierNotFound = portainer.Error("Docker network identifier not found") + networkIdentifier = "Id" +) + +// networkListOperation extracts the response as a JSON object, loop through the networks array +// decorate and/or filter the networks based on resource controls before rewriting the response +func networkListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { + var err error + // NetworkList response is a JSON array + // https://docs.docker.com/engine/api/v1.28/#operation/NetworkList + responseArray, err := getResponseAsJSONArray(response) + if err != nil { + return err + } + + if executor.operationContext.isAdmin { + responseArray, err = decorateNetworkList(responseArray, executor.operationContext.resourceControls) + } else { + responseArray, err = filterNetworkList(responseArray, executor.operationContext.resourceControls, + executor.operationContext.userID, executor.operationContext.userTeamIDs) + } + if err != nil { + return err + } + + return rewriteResponse(response, responseArray, http.StatusOK) +} + +// networkInspectOperation extracts the response as a JSON object, verify that the user +// has access to the network based on resource control and either rewrite an access denied response +// or a decorated network. +func networkInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { + // NetworkInspect response is a JSON object + // https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect + responseObject, err := getResponseAsJSONOBject(response) + if err != nil { + return err + } + + if responseObject[networkIdentifier] == nil { + return ErrDockerNetworkIdentifierNotFound + } + networkID := responseObject[networkIdentifier].(string) + + resourceControl := getResourceControlByResourceID(networkID, executor.operationContext.resourceControls) + if resourceControl != nil { + if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, + executor.operationContext.userTeamIDs, resourceControl) { + responseObject = decorateObject(responseObject, resourceControl) + } else { + return rewriteAccessDeniedResponse(response) + } + } + + return rewriteResponse(response, responseObject, http.StatusOK) +} diff --git a/api/http/proxy/transport.go b/api/http/proxy/transport.go index 76c4f43f7..09f6418b2 100644 --- a/api/http/proxy/transport.go +++ b/api/http/proxy/transport.go @@ -53,17 +53,20 @@ func (p *proxyTransport) executeDockerRequest(request *http.Request) (*http.Resp func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Response, error) { path := request.URL.Path - if strings.HasPrefix(path, "/containers") { + switch { + case strings.HasPrefix(path, "/containers"): return p.proxyContainerRequest(request) - } else if strings.HasPrefix(path, "/services") { + case strings.HasPrefix(path, "/services"): return p.proxyServiceRequest(request) - } else if strings.HasPrefix(path, "/volumes") { + case strings.HasPrefix(path, "/volumes"): return p.proxyVolumeRequest(request) - } else if strings.HasPrefix(path, "/swarm") { + case strings.HasPrefix(path, "/networks"): + return p.proxyNetworkRequest(request) + case strings.HasPrefix(path, "/swarm"): return p.proxySwarmRequest(request) + default: + return p.executeDockerRequest(request) } - - return p.executeDockerRequest(request) } func (p *proxyTransport) proxyContainerRequest(request *http.Request) (*http.Response, error) { @@ -145,6 +148,24 @@ func (p *proxyTransport) proxyVolumeRequest(request *http.Request) (*http.Respon } } +func (p *proxyTransport) proxyNetworkRequest(request *http.Request) (*http.Response, error) { + switch requestPath := request.URL.Path; requestPath { + case "/networks/create": + return p.executeDockerRequest(request) + + case "/networks": + return p.rewriteOperation(request, networkListOperation) + + default: + // assume /networks/{name} + if request.Method == http.MethodGet { + return p.rewriteOperation(request, networkInspectOperation) + } + volumeID := path.Base(requestPath) + return p.restrictedOperation(request, volumeID) + } +} + func (p *proxyTransport) proxySwarmRequest(request *http.Request) (*http.Response, error) { return p.administratorOperation(request) } diff --git a/api/portainer.go b/api/portainer.go index 13d8b82e1..024f8998a 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -399,4 +399,6 @@ const ( ServiceResourceControl // VolumeResourceControl represents a resource control associated to a Docker volume VolumeResourceControl + // NetworkResourceControl represents a resource control associated to a Docker network + NetworkResourceControl ) diff --git a/app/components/createNetwork/createNetworkController.js b/app/components/createNetwork/createNetworkController.js index ee0a1e838..dc804b7a3 100644 --- a/app/components/createNetwork/createNetworkController.js +++ b/app/components/createNetwork/createNetworkController.js @@ -1,12 +1,17 @@ angular.module('createNetwork', []) -.controller('CreateNetworkController', ['$q', '$scope', '$state', 'PluginService', 'Notifications', 'Network', 'LabelHelper', -function ($q, $scope, $state, PluginService, Notifications, Network, LabelHelper) { +.controller('CreateNetworkController', ['$q', '$scope', '$state', 'PluginService', 'Notifications', 'NetworkService', 'LabelHelper', 'Authentication', 'ResourceControlService', 'FormValidator', +function ($q, $scope, $state, PluginService, Notifications, NetworkService, LabelHelper, Authentication, ResourceControlService, FormValidator) { $scope.formValues = { DriverOptions: [], Subnet: '', Gateway: '', - Labels: [] + Labels: [], + AccessControlData: new AccessControlFormData() + }; + + $scope.state = { + formValidationError: '' }; $scope.availableNetworkDrivers = []; @@ -40,23 +45,6 @@ function ($q, $scope, $state, PluginService, Notifications, Network, LabelHelper $scope.formValues.Labels.splice(index, 1); }; - function createNetwork(config) { - $('#createNetworkSpinner').show(); - Network.create(config, function (d) { - if (d.message) { - $('#createNetworkSpinner').hide(); - Notifications.error('Unable to create network', {}, d.message); - } else { - Notifications.success('Network created', d.Id); - $('#createNetworkSpinner').hide(); - $state.go('networks', {}, {reload: true}); - } - }, function (e) { - $('#createNetworkSpinner').hide(); - Notifications.error('Failure', e, 'Unable to create network'); - }); - } - function prepareIPAMConfiguration(config) { if ($scope.formValues.Subnet) { var ipamConfig = {}; @@ -88,9 +76,47 @@ function ($q, $scope, $state, PluginService, Notifications, Network, LabelHelper return config; } + function validateForm(accessControlData, isAdmin) { + $scope.state.formValidationError = ''; + var error = ''; + error = FormValidator.validateAccessControl(accessControlData, isAdmin); + + if (error) { + $scope.state.formValidationError = error; + return false; + } + return true; + } + $scope.create = function () { - var config = prepareConfiguration(); - createNetwork(config); + $('#createResourceSpinner').show(); + + var networkConfiguration = prepareConfiguration(); + var accessControlData = $scope.formValues.AccessControlData; + var userDetails = Authentication.getUserDetails(); + var isAdmin = userDetails.role === 1 ? true : false; + + if (!validateForm(accessControlData, isAdmin)) { + $('#createResourceSpinner').hide(); + return; + } + + NetworkService.create(networkConfiguration) + .then(function success(data) { + var networkIdentifier = data.Id; + var userId = userDetails.ID; + return ResourceControlService.applyResourceControl('network', networkIdentifier, userId, accessControlData, []); + }) + .then(function success() { + Notifications.success('Network successfully created'); + $state.go('networks', {}, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'An error occured during network creation'); + }) + .finally(function final() { + $('#createResourceSpinner').hide(); + }); }; function initView() { diff --git a/app/components/createNetwork/createnetwork.html b/app/components/createNetwork/createnetwork.html index c01da87e2..4d5636a10 100644 --- a/app/components/createNetwork/createnetwork.html +++ b/app/components/createNetwork/createnetwork.html @@ -121,6 +121,9 @@ + + +
Actions @@ -129,7 +132,8 @@
Cancel - + + {{ state.formValidationError }}
diff --git a/app/components/network/network.html b/app/components/network/network.html index d5eacd7f1..8439a7e9f 100644 --- a/app/components/network/network.html +++ b/app/components/network/network.html @@ -48,6 +48,15 @@ + + + + +
diff --git a/app/components/network/networkController.js b/app/components/network/networkController.js index 63f9cb4e8..0af567512 100644 --- a/app/components/network/networkController.js +++ b/app/components/network/networkController.js @@ -1,6 +1,6 @@ angular.module('network', []) -.controller('NetworkController', ['$scope', '$state', '$stateParams', '$filter', 'Network', 'Container', 'ContainerHelper', 'Notifications', -function ($scope, $state, $stateParams, $filter, Network, Container, ContainerHelper, Notifications) { +.controller('NetworkController', ['$scope', '$state', '$stateParams', '$filter', 'Network', 'NetworkService', 'Container', 'ContainerHelper', 'Notifications', +function ($scope, $state, $stateParams, $filter, Network, NetworkService, Container, ContainerHelper, Notifications) { $scope.removeNetwork = function removeNetwork(networkId) { $('#loadingViewSpinner').show(); @@ -82,7 +82,7 @@ function ($scope, $state, $stateParams, $filter, Network, Container, ContainerHe function initView() { $('#loadingViewSpinner').show(); - Network.get({id: $stateParams.id}).$promise + NetworkService.network($stateParams.id) .then(function success(data) { $scope.network = data; var endpointProvider = $scope.applicationState.endpoint.mode.provider; diff --git a/app/components/networks/networks.html b/app/components/networks/networks.html index 7e8442cff..4773aa779 100644 --- a/app/components/networks/networks.html +++ b/app/components/networks/networks.html @@ -8,46 +8,6 @@ Networks -
-
- - - - -
- -
- -
- -
-
- - -
-
- Note: The network will be created using the overlay driver and will allow containers to communicate across the hosts of your cluster. -
-
-
-
- Note: The network will be created using the bridge driver. -
-
- -
-
- - - -
-
-
-
-
-
-
-
@@ -66,6 +26,7 @@
+ Add network
@@ -80,54 +41,61 @@ - + Name - + Id - + Scope - + Driver - + IPAM Driver - + IPAM Subnet - + IPAM Gateway + + + Ownership + + + + @@ -140,12 +108,18 @@ {{ network.IPAM.Driver }} {{ network.IPAM.Config[0].Subnet ? network.IPAM.Config[0].Subnet : '-' }} {{ network.IPAM.Config[0].Gateway ? network.IPAM.Config[0].Gateway : '-' }} + + + + {{ network.ResourceControl.Ownership ? network.ResourceControl.Ownership : network.ResourceControl.Ownership = 'public' }} + + - Loading... + Loading... - No networks available. + No networks available. diff --git a/app/components/networks/networksController.js b/app/components/networks/networksController.js index 8468a1283..1909358ed 100644 --- a/app/components/networks/networksController.js +++ b/app/components/networks/networksController.js @@ -1,51 +1,17 @@ angular.module('networks', []) -.controller('NetworksController', ['$scope', '$state', 'Network', 'Notifications', 'Pagination', -function ($scope, $state, Network, Notifications, Pagination) { +.controller('NetworksController', ['$scope', '$state', 'Network', 'NetworkService', 'Notifications', 'Pagination', +function ($scope, $state, Network, NetworkService, Notifications, Pagination) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('networks'); $scope.state.selectedItemCount = 0; $scope.state.advancedSettings = false; $scope.sortType = 'Name'; $scope.sortReverse = false; - $scope.config = { - Name: '' - }; $scope.changePaginationCount = function() { Pagination.setPaginationCount('networks', $scope.state.pagination_count); }; - function prepareNetworkConfiguration() { - var config = angular.copy($scope.config); - if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM' || $scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') { - config.Driver = 'overlay'; - // Force IPAM Driver to 'default', should not be required. - // See: https://github.com/docker/docker/issues/25735 - config.IPAM = { - Driver: 'default' - }; - } - return config; - } - - $scope.createNetwork = function() { - $('#createNetworkSpinner').show(); - var config = prepareNetworkConfiguration(); - Network.create(config, function (d) { - if (d.message) { - $('#createNetworkSpinner').hide(); - Notifications.error('Unable to create network', {}, d.message); - } else { - Notifications.success('Network created', d.Id); - $('#createNetworkSpinner').hide(); - $state.reload(); - } - }, function (e) { - $('#createNetworkSpinner').hide(); - Notifications.error('Failure', e, 'Unable to create network'); - }); - }; - $scope.order = function(sortType) { $scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false; $scope.sortType = sortType; @@ -99,13 +65,17 @@ function ($scope, $state, Network, Notifications, Pagination) { function initView() { $('#loadNetworksSpinner').show(); - Network.query({}, function (d) { - $scope.networks = d; - $('#loadNetworksSpinner').hide(); - }, function (e) { - $('#loadNetworksSpinner').hide(); - Notifications.error('Failure', e, 'Unable to retrieve networks'); + + NetworkService.networks(true, true, true, true) + .then(function success(data) { + $scope.networks = data; + }) + .catch(function error(err) { $scope.networks = []; + Notifications.error('Failure', err, 'Unable to retrieve networks'); + }) + .finally(function final() { + $('#loadNetworksSpinner').hide(); }); } diff --git a/app/models/docker/network.js b/app/models/docker/network.js new file mode 100644 index 000000000..820b35ab6 --- /dev/null +++ b/app/models/docker/network.js @@ -0,0 +1,16 @@ +function NetworkViewModel(data) { + this.Id = data.Id; + this.Name = data.Name; + this.Scope = data.Scope; + this.Driver = data.Driver; + this.Attachable = data.Attachable; + this.IPAM = data.IPAM; + this.Containers = data.Containers; + this.Options = data.Options; + + if (data.Portainer) { + if (data.Portainer.ResourceControl) { + this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); + } + } +} diff --git a/app/services/docker/networkService.js b/app/services/docker/networkService.js index 013311cf0..41f6961bf 100644 --- a/app/services/docker/networkService.js +++ b/app/services/docker/networkService.js @@ -3,6 +3,35 @@ angular.module('portainer.services') 'use strict'; var service = {}; + service.create = function(networkConfiguration) { + var deferred = $q.defer(); + + Network.create(networkConfiguration).$promise + .then(function success(data) { + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to create network', err: err }); + }); + return deferred.promise; + }; + + service.network = function(id) { + var deferred = $q.defer(); + + Network.get({id: id}).$promise + .then(function success(data) { + var network = new NetworkViewModel(data); + deferred.resolve(network); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to retrieve network details', err: err}); + }); + + return deferred.promise; + }; + + service.networks = function(localNetworks, swarmNetworks, swarmAttachableNetworks, globalNetworks) { var deferred = $q.defer(); @@ -23,6 +52,8 @@ angular.module('portainer.services') if (globalNetworks && network.Scope === 'global') { return network; } + }).map(function (item) { + return new NetworkViewModel(item); }); deferred.resolve(filteredNetworks); diff --git a/app/services/notifications.js b/app/services/notifications.js index af2be044d..ddc9d0d7a 100644 --- a/app/services/notifications.js +++ b/app/services/notifications.js @@ -13,6 +13,8 @@ angular.module('portainer.services') msg = e.data.message; } else if (e.message) { msg = e.message; + } else if (e.err && e.err.data && e.err.data.message) { + msg = e.err.data.message; } else if (e.data && e.data.length > 0 && e.data[0].message) { msg = e.data[0].message; } else if (e.err && e.err.data && e.err.data.length > 0 && e.err.data[0].message) {