diff --git a/api/http/handler/resource_control.go b/api/http/handler/resource_control.go index d84824cb5..623e595e9 100644 --- a/api/http/handler/resource_control.go +++ b/api/http/handler/resource_control.go @@ -80,6 +80,8 @@ func (handler *ResourceHandler) handlePostResources(w http.ResponseWriter, r *ht resourceControlType = portainer.VolumeResourceControl case "network": resourceControlType = portainer.NetworkResourceControl + case "secret": + resourceControlType = portainer.SecretResourceControl 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 7881f697a..ff075cc69 100644 --- a/api/http/proxy/decorator.go +++ b/api/http/proxy/decorator.go @@ -106,6 +106,30 @@ func decorateNetworkList(networkData []interface{}, resourceControls []portainer return decoratedNetworkData, nil } +// decorateSecretList loops through all secrets and will decorate any secret with an existing resource control. +// Secret object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/SecretList +func decorateSecretList(secretData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { + decoratedSecretData := make([]interface{}, 0) + + for _, secret := range secretData { + + secretObject := secret.(map[string]interface{}) + if secretObject[secretIdentifier] == nil { + return nil, ErrDockerSecretIdentifierNotFound + } + + secretID := secretObject[secretIdentifier].(string) + resourceControl := getResourceControlByResourceID(secretID, resourceControls) + if resourceControl != nil { + secretObject = decorateObject(secretObject, resourceControl) + } + + decoratedSecretData = append(decoratedSecretData, secretObject) + } + + return decoratedSecretData, 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 3f555f0be..bc72987d0 100644 --- a/api/http/proxy/filter.go +++ b/api/http/proxy/filter.go @@ -135,3 +135,28 @@ func filterNetworkList(networkData []interface{}, resourceControls []portainer.R return filteredNetworkData, nil } + +// filterSecretList loops through all secrets, filters secrets without any resource control (public resources) or with +// any resource control giving access to the user (these secrets will be decorated). +// Secret object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/SecretList +func filterSecretList(secretData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) { + filteredSecretData := make([]interface{}, 0) + + for _, secret := range secretData { + secretObject := secret.(map[string]interface{}) + if secretObject[secretIdentifier] == nil { + return nil, ErrDockerSecretIdentifierNotFound + } + + secretID := secretObject[secretIdentifier].(string) + resourceControl := getResourceControlByResourceID(secretID, resourceControls) + if resourceControl == nil { + filteredSecretData = append(filteredSecretData, secretObject) + } else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) { + secretObject = decorateObject(secretObject, resourceControl) + filteredSecretData = append(filteredSecretData, secretObject) + } + } + + return filteredSecretData, nil +} diff --git a/api/http/proxy/secrets.go b/api/http/proxy/secrets.go new file mode 100644 index 000000000..d0001d3b9 --- /dev/null +++ b/api/http/proxy/secrets.go @@ -0,0 +1,67 @@ +package proxy + +import ( + "net/http" + + "github.com/portainer/portainer" +) + +const ( + // ErrDockerSecretIdentifierNotFound defines an error raised when Portainer is unable to find a secret identifier + ErrDockerSecretIdentifierNotFound = portainer.Error("Docker secret identifier not found") + secretIdentifier = "ID" +) + +// secretListOperation extracts the response as a JSON object, loop through the secrets array +// decorate and/or filter the secrets based on resource controls before rewriting the response +func secretListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { + var err error + + // SecretList response is a JSON array + // https://docs.docker.com/engine/api/v1.28/#operation/SecretList + responseArray, err := getResponseAsJSONArray(response) + if err != nil { + return err + } + + if executor.operationContext.isAdmin { + responseArray, err = decorateSecretList(responseArray, executor.operationContext.resourceControls) + } else { + responseArray, err = filterSecretList(responseArray, executor.operationContext.resourceControls, + executor.operationContext.userID, executor.operationContext.userTeamIDs) + } + if err != nil { + return err + } + + return rewriteResponse(response, responseArray, http.StatusOK) +} + +// secretInspectOperation extracts the response as a JSON object, verify that the user +// has access to the secret based on resource control (check are done based on the secretID and optional Swarm service ID) +// and either rewrite an access denied response or a decorated secret. +func secretInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { + // SecretInspect response is a JSON object + // https://docs.docker.com/engine/api/v1.28/#operation/SecretInspect + responseObject, err := getResponseAsJSONOBject(response) + if err != nil { + return err + } + + if responseObject[secretIdentifier] == nil { + return ErrDockerSecretIdentifierNotFound + } + secretID := responseObject[secretIdentifier].(string) + + resourceControl := getResourceControlByResourceID(secretID, 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 09f6418b2..35ee6cf2d 100644 --- a/api/http/proxy/transport.go +++ b/api/http/proxy/transport.go @@ -62,6 +62,8 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon return p.proxyVolumeRequest(request) case strings.HasPrefix(path, "/networks"): return p.proxyNetworkRequest(request) + case strings.HasPrefix(path, "/secrets"): + return p.proxySecretRequest(request) case strings.HasPrefix(path, "/swarm"): return p.proxySwarmRequest(request) default: @@ -157,12 +159,30 @@ func (p *proxyTransport) proxyNetworkRequest(request *http.Request) (*http.Respo return p.rewriteOperation(request, networkListOperation) default: - // assume /networks/{name} + // assume /networks/{id} if request.Method == http.MethodGet { return p.rewriteOperation(request, networkInspectOperation) } - volumeID := path.Base(requestPath) - return p.restrictedOperation(request, volumeID) + networkID := path.Base(requestPath) + return p.restrictedOperation(request, networkID) + } +} + +func (p *proxyTransport) proxySecretRequest(request *http.Request) (*http.Response, error) { + switch requestPath := request.URL.Path; requestPath { + case "/secrets/create": + return p.executeDockerRequest(request) + + case "/secrets": + return p.rewriteOperation(request, secretListOperation) + + default: + // assume /secrets/{id} + if request.Method == http.MethodGet { + return p.rewriteOperation(request, secretInspectOperation) + } + secretID := path.Base(requestPath) + return p.restrictedOperation(request, secretID) } } diff --git a/api/portainer.go b/api/portainer.go index 024f8998a..78c303e37 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -401,4 +401,6 @@ const ( VolumeResourceControl // NetworkResourceControl represents a resource control associated to a Docker network NetworkResourceControl + // SecretResourceControl represents a resource control associated to a Docker secret + SecretResourceControl ) diff --git a/app/components/createSecret/createSecretController.js b/app/components/createSecret/createSecretController.js index 3f2533270..ce94e676b 100644 --- a/app/components/createSecret/createSecretController.js +++ b/app/components/createSecret/createSecretController.js @@ -1,11 +1,17 @@ angular.module('createSecret', []) -.controller('CreateSecretController', ['$scope', '$state', 'Notifications', 'SecretService', 'LabelHelper', -function ($scope, $state, Notifications, SecretService, LabelHelper) { +.controller('CreateSecretController', ['$scope', '$state', 'Notifications', 'SecretService', 'LabelHelper', 'Authentication', 'ResourceControlService', 'FormValidator', +function ($scope, $state, Notifications, SecretService, LabelHelper, Authentication, ResourceControlService, FormValidator) { + $scope.formValues = { Name: '', Data: '', Labels: [], - encodeSecret: true + encodeSecret: true, + AccessControlData: new AccessControlFormData() + }; + + $scope.state = { + formValidationError: '' }; $scope.addLabel = function() { @@ -36,10 +42,38 @@ function ($scope, $state, Notifications, SecretService, LabelHelper) { return config; } - function createSecret(config) { - $('#createSecretSpinner').show(); - SecretService.create(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 () { + $('#createResourceSpinner').show(); + + var accessControlData = $scope.formValues.AccessControlData; + var userDetails = Authentication.getUserDetails(); + var isAdmin = userDetails.role === 1 ? true : false; + + if (!validateForm(accessControlData, isAdmin)) { + $('#createResourceSpinner').hide(); + return; + } + + var secretConfiguration = prepareConfiguration(); + SecretService.create(secretConfiguration) .then(function success(data) { + var secretIdentifier = data.ID; + var userId = userDetails.ID; + return ResourceControlService.applyResourceControl('secret', secretIdentifier, userId, accessControlData, []); + }) + .then(function success() { Notifications.success('Secret successfully created'); $state.go('secrets', {}, {reload: true}); }) @@ -47,12 +81,7 @@ function ($scope, $state, Notifications, SecretService, LabelHelper) { Notifications.error('Failure', err, 'Unable to create secret'); }) .finally(function final() { - $('#createSecretSpinner').hide(); + $('#createResourceSpinner').hide(); }); - } - - $scope.create = function () { - var config = prepareConfiguration(); - createSecret(config); }; }]); diff --git a/app/components/createSecret/createsecret.html b/app/components/createSecret/createsecret.html index c918e8cd5..dbf4efe06 100644 --- a/app/components/createSecret/createsecret.html +++ b/app/components/createSecret/createsecret.html @@ -66,6 +66,9 @@ + + +
Actions @@ -74,7 +77,8 @@
Cancel - + + {{ state.formValidationError }}
diff --git a/app/components/secret/secret.html b/app/components/secret/secret.html index fb349ebbb..e90fd7420 100644 --- a/app/components/secret/secret.html +++ b/app/components/secret/secret.html @@ -53,3 +53,12 @@ + + + + + diff --git a/app/components/secrets/secrets.html b/app/components/secrets/secrets.html index abd9ba6eb..b274ae777 100644 --- a/app/components/secrets/secrets.html +++ b/app/components/secrets/secrets.html @@ -30,31 +30,44 @@ - + Name - + Created at + + + Ownership + + + + {{ secret.Name }} {{ secret.CreatedAt | getisodate }} + + + + {{ secret.ResourceControl.Ownership ? secret.ResourceControl.Ownership : secret.ResourceControl.Ownership = 'public' }} + + - Loading... + Loading... - No secrets available. + No secrets available. diff --git a/app/models/docker/secret.js b/app/models/docker/secret.js index 112419791..d6c54c22e 100644 --- a/app/models/docker/secret.js +++ b/app/models/docker/secret.js @@ -5,4 +5,10 @@ function SecretViewModel(data) { this.Version = data.Version.Index; this.Name = data.Spec.Name; this.Labels = data.Spec.Labels; + + if (data.Portainer) { + if (data.Portainer.ResourceControl) { + this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); + } + } }