From 98972dec0debb3c72c29d24854ca0bcf2e8886f8 Mon Sep 17 00:00:00 2001 From: cong meng Date: Tue, 7 Dec 2021 09:11:44 +1300 Subject: [PATCH] feat(webhook) EE-2125 send registry auth haeder when update swarms service via webhook (#6220) * feat(webhook) EE-2125 add some helpers to registry utils * feat(webhook) EE-2125 persist registryID when creating a webhook * feat(webhook) EE-2125 send registry auth header when executing a webhook * feat(webhook) EE-2125 send registryID to backend when creating a service with webhook * feat(webhook) EE-2125 use the initial registry ID to create webhook on editing service screen * feat(webhook) EE-2125 update webhook when update registry * feat(webhook) EE-2125 add endpoint of update webhook * feat(webhook) EE-2125 code cleanup * feat(webhook) EE-2125 fix a typo * feat(webhook) EE-2125 fix circle import issue with unit test Co-authored-by: Simon Meng --- api/bolt/webhook/webhook.go | 6 ++ api/http/handler/webhooks/handler.go | 2 + api/http/handler/webhooks/webhook_create.go | 20 ++++- api/http/handler/webhooks/webhook_execute.go | 33 +++++++- api/http/handler/webhooks/webhook_update.go | 76 +++++++++++++++++++ api/internal/registryutils/access/access.go | 58 ++++++++++++++ api/internal/registryutils/auth_header.go | 36 +++++++++ api/portainer.go | 2 + .../create/createServiceController.js | 3 +- .../views/services/edit/serviceController.js | 17 ++++- app/portainer/rest/webhooks.js | 1 + app/portainer/services/api/webhookService.js | 8 +- 12 files changed, 254 insertions(+), 8 deletions(-) create mode 100644 api/http/handler/webhooks/webhook_update.go create mode 100644 api/internal/registryutils/access/access.go create mode 100644 api/internal/registryutils/auth_header.go diff --git a/api/bolt/webhook/webhook.go b/api/bolt/webhook/webhook.go index d7514f3e7..48f781cb0 100644 --- a/api/bolt/webhook/webhook.go +++ b/api/bolt/webhook/webhook.go @@ -150,3 +150,9 @@ func (service *Service) CreateWebhook(webhook *portainer.Webhook) error { return bucket.Put(internal.Itob(int(webhook.ID)), data) }) } + +// UpdateWebhook update a webhook. +func (service *Service) UpdateWebhook(ID portainer.WebhookID, webhook *portainer.Webhook) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.connection, BucketName, identifier, webhook) +} diff --git a/api/http/handler/webhooks/handler.go b/api/http/handler/webhooks/handler.go index e928647d4..97ccd3143 100644 --- a/api/http/handler/webhooks/handler.go +++ b/api/http/handler/webhooks/handler.go @@ -24,6 +24,8 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { } h.Handle("/webhooks", bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.webhookCreate))).Methods(http.MethodPost) + h.Handle("/webhooks/{id}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.webhookUpdate))).Methods(http.MethodPut) h.Handle("/webhooks", bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.webhookList))).Methods(http.MethodGet) h.Handle("/webhooks/{id}", diff --git a/api/http/handler/webhooks/webhook_create.go b/api/http/handler/webhooks/webhook_create.go index ba3ba558c..41e1f8451 100644 --- a/api/http/handler/webhooks/webhook_create.go +++ b/api/http/handler/webhooks/webhook_create.go @@ -2,6 +2,8 @@ package webhooks import ( "errors" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/registryutils/access" "net/http" "github.com/asaskevich/govalidator" @@ -16,6 +18,7 @@ import ( type webhookCreatePayload struct { ResourceID string EndpointID int + RegistryID portainer.RegistryID WebhookType int } @@ -60,6 +63,20 @@ func (handler *Handler) webhookCreate(w http.ResponseWriter, r *http.Request) *h return &httperror.HandlerError{http.StatusConflict, "A webhook for this resource already exists", errors.New("A webhook for this resource already exists")} } + endpointID := portainer.EndpointID(payload.EndpointID) + + if payload.RegistryID != 0 { + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + + _, err = access.GetAccessibleRegistry(handler.DataStore, tokenData.ID, endpointID, payload.RegistryID) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission deny to access registry", err} + } + } + token, err := uuid.NewV4() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Error creating unique token", err} @@ -68,7 +85,8 @@ func (handler *Handler) webhookCreate(w http.ResponseWriter, r *http.Request) *h webhook = &portainer.Webhook{ Token: token.String(), ResourceID: payload.ResourceID, - EndpointID: portainer.EndpointID(payload.EndpointID), + EndpointID: endpointID, + RegistryID: payload.RegistryID, WebhookType: portainer.WebhookType(payload.WebhookType), } diff --git a/api/http/handler/webhooks/webhook_execute.go b/api/http/handler/webhooks/webhook_execute.go index fb1cbdfbd..8e9728989 100644 --- a/api/http/handler/webhooks/webhook_execute.go +++ b/api/http/handler/webhooks/webhook_execute.go @@ -3,6 +3,7 @@ package webhooks import ( "context" "errors" + "github.com/portainer/portainer/api/internal/registryutils" "net/http" "strings" @@ -41,6 +42,7 @@ func (handler *Handler) webhookExecute(w http.ResponseWriter, r *http.Request) * resourceID := webhook.ResourceID endpointID := webhook.EndpointID + registryID := webhook.RegistryID webhookType := webhook.WebhookType endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) @@ -54,13 +56,19 @@ func (handler *Handler) webhookExecute(w http.ResponseWriter, r *http.Request) * switch webhookType { case portainer.ServiceWebhook: - return handler.executeServiceWebhook(w, endpoint, resourceID, imageTag) + return handler.executeServiceWebhook(w, endpoint, resourceID, registryID, imageTag) default: return &httperror.HandlerError{http.StatusInternalServerError, "Unsupported webhook type", errors.New("Webhooks for this resource are not currently supported")} } } -func (handler *Handler) executeServiceWebhook(w http.ResponseWriter, endpoint *portainer.Endpoint, resourceID string, imageTag string) *httperror.HandlerError { +func (handler *Handler) executeServiceWebhook( + w http.ResponseWriter, + endpoint *portainer.Endpoint, + resourceID string, + registryID portainer.RegistryID, + imageTag string, +) *httperror.HandlerError { dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "") if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Error creating docker client", err} @@ -86,7 +94,26 @@ func (handler *Handler) executeServiceWebhook(w http.ResponseWriter, endpoint *p service.Spec.TaskTemplate.ContainerSpec.Image = imageName } - _, err = dockerClient.ServiceUpdate(context.Background(), resourceID, service.Version, service.Spec, dockertypes.ServiceUpdateOptions{QueryRegistry: true}) + serviceUpdateOptions := dockertypes.ServiceUpdateOptions{ + QueryRegistry: true, + } + + if registryID != 0 { + registry, err := handler.DataStore.Registry().Registry(registryID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Error getting registry", err} + } + + if registry.Authentication { + registryutils.EnsureRegTokenValid(handler.DataStore, registry) + serviceUpdateOptions.EncodedRegistryAuth, err = registryutils.GetRegistryAuthHeader(registry) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Error getting registry auth header", err} + } + } + } + + _, err = dockerClient.ServiceUpdate(context.Background(), resourceID, service.Version, service.Spec, serviceUpdateOptions) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Error updating service", err} diff --git a/api/http/handler/webhooks/webhook_update.go b/api/http/handler/webhooks/webhook_update.go new file mode 100644 index 000000000..e4eb52401 --- /dev/null +++ b/api/http/handler/webhooks/webhook_update.go @@ -0,0 +1,76 @@ +package webhooks + +import ( + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/registryutils/access" + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" +) + +type webhookUpdatePayload struct { + RegistryID portainer.RegistryID +} + +func (payload *webhookUpdatePayload) Validate(r *http.Request) error { + return nil +} + +// @summary Update a webhook +// @description **Access policy**: authenticated +// @security ApiKeyAuth +// @security jwt +// @tags webhooks +// @accept json +// @produce json +// @param body body webhookUpdatePayload true "Webhook data" +// @success 200 {object} portainer.Webhook +// @failure 400 +// @failure 409 +// @failure 500 +// @router /webhooks/{id} [put] +func (handler *Handler) webhookUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + id, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid webhook id", err} + } + webhookID := portainer.WebhookID(id) + + var payload webhookUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + webhook, err := handler.DataStore.Webhook().Webhook(webhookID) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a webhooks with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a webhooks with the specified identifier inside the database", err} + } + + if payload.RegistryID != 0 { + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + + _, err = access.GetAccessibleRegistry(handler.DataStore, tokenData.ID, webhook.EndpointID, payload.RegistryID) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission deny to access registry", err} + } + } + + webhook.RegistryID = payload.RegistryID + + err = handler.DataStore.Webhook().UpdateWebhook(portainer.WebhookID(id), webhook) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the webhook inside the database", err} + } + + return response.JSON(w, webhook) +} diff --git a/api/internal/registryutils/access/access.go b/api/internal/registryutils/access/access.go new file mode 100644 index 000000000..f1c5e46b4 --- /dev/null +++ b/api/internal/registryutils/access/access.go @@ -0,0 +1,58 @@ +package access + +import ( + "fmt" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" +) + +func hasPermission( + dataStore portainer.DataStore, + userID portainer.UserID, + endpointID portainer.EndpointID, + registry *portainer.Registry, +) (hasPermission bool, err error) { + user, err := dataStore.User().User(userID) + if err != nil { + return + } + + if user.Role == portainer.AdministratorRole { + return true, err + } + + teamMemberships, err := dataStore.TeamMembership().TeamMembershipsByUserID(userID) + if err != nil { + return + } + + hasPermission = security.AuthorizedRegistryAccess(registry, user, teamMemberships, endpointID) + + return +} + +// GetAccessibleRegistry get the registry if the user has permission +func GetAccessibleRegistry( + dataStore portainer.DataStore, + userID portainer.UserID, + endpointID portainer.EndpointID, + registryID portainer.RegistryID, +) (registry *portainer.Registry, err error) { + + registry, err = dataStore.Registry().Registry(registryID) + if err != nil { + return + } + + hasPermission, err := hasPermission(dataStore, userID, endpointID, registry) + if err != nil { + return + } + + if !hasPermission { + err = fmt.Errorf("user does not has permission to get the registry") + return nil, err + } + + return +} diff --git a/api/internal/registryutils/auth_header.go b/api/internal/registryutils/auth_header.go new file mode 100644 index 000000000..de5d3a999 --- /dev/null +++ b/api/internal/registryutils/auth_header.go @@ -0,0 +1,36 @@ +package registryutils + +import ( + "encoding/base64" + "encoding/json" + portainer "github.com/portainer/portainer/api" +) + +type ( + authHeader struct { + Username string `json:"username"` + Password string `json:"password"` + ServerAddress string `json:"serveraddress"` + } +) + +// GetRegistryAuthHeader generate the X-Registry-Auth header from registry +func GetRegistryAuthHeader(registry *portainer.Registry) (header string, err error) { + authHeader := authHeader{ + ServerAddress: registry.URL, + } + + authHeader.Username, authHeader.Password, err = GetRegEffectiveCredential(registry) + if err != nil { + return + } + + headerData, err := json.Marshal(authHeader) + if err != nil { + return + } + + header = base64.StdEncoding.EncodeToString(headerData) + + return +} diff --git a/api/portainer.go b/api/portainer.go index 62a114f1d..c2a33ead9 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1127,6 +1127,7 @@ type ( Token string `json:"Token"` ResourceID string `json:"ResourceId"` EndpointID EndpointID `json:"EndpointId"` + RegistryID RegistryID `json:"RegistryId"` WebhookType WebhookType `json:"Type"` } @@ -1541,6 +1542,7 @@ type ( Webhooks() ([]Webhook, error) Webhook(ID WebhookID) (*Webhook, error) CreateWebhook(portainer *Webhook) error + UpdateWebhook(ID WebhookID, webhook *Webhook) error WebhookByResourceID(resourceID string) (*Webhook, error) WebhookByToken(token string) (*Webhook, error) DeleteWebhook(serviceID WebhookID) error diff --git a/app/docker/views/services/create/createServiceController.js b/app/docker/views/services/create/createServiceController.js index 0c9d83ded..046d248e6 100644 --- a/app/docker/views/services/create/createServiceController.js +++ b/app/docker/views/services/create/createServiceController.js @@ -492,7 +492,8 @@ angular.module('portainer.docker').controller('CreateServiceController', [ const resourceControl = data.Portainer.ResourceControl; const userId = Authentication.getUserDetails().ID; const rcPromise = ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl); - const webhookPromise = $q.when(endpoint.Type !== 4 && $scope.formValues.Webhook && WebhookService.createServiceWebhook(serviceId, endpoint.Id)); + const registryID = $scope.formValues.RegistryModel.Registry.Id; + const webhookPromise = $q.when(endpoint.Type !== 4 && $scope.formValues.Webhook && WebhookService.createServiceWebhook(serviceId, endpoint.Id, registryID)); return $q.all([rcPromise, webhookPromise]); }) .then(function success() { diff --git a/app/docker/views/services/edit/serviceController.js b/app/docker/views/services/edit/serviceController.js index 64dccbcad..add67763e 100644 --- a/app/docker/views/services/edit/serviceController.js +++ b/app/docker/views/services/edit/serviceController.js @@ -336,7 +336,7 @@ angular.module('portainer.docker').controller('ServiceController', [ Notifications.error('Failure', err, 'Unable to delete webhook'); }); } else { - WebhookService.createServiceWebhook(service.Id, endpoint.Id) + WebhookService.createServiceWebhook(service.Id, endpoint.Id, $scope.initialRegistryID) .then(function success(data) { $scope.WebhookExists = true; $scope.webhookID = data.Id; @@ -348,6 +348,19 @@ angular.module('portainer.docker').controller('ServiceController', [ } }; + $scope.updateWebhookRegistryId = function () { + const newRegistryID = _.get($scope.formValues.RegistryModel, 'Registry.Id', 0); + const registryChanged = $scope.initialRegistryID != newRegistryID; + + if ($scope.WebhookExists && registryChanged) { + WebhookService.updateServiceWebhook($scope.webhookID, newRegistryID) + .then(function success() {}) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update webhook'); + }); + } + }; + $scope.copyWebhook = function copyWebhook() { clipboard.copyText($scope.webhookURL); $('#copyNotification').show(); @@ -556,6 +569,7 @@ angular.module('portainer.docker').controller('ServiceController', [ Notifications.error('Failure', data, 'Error'); } else { Notifications.success('Service successfully updated', 'Service updated'); + $scope.updateWebhookRegistryId(); } $scope.cancelChanges({}); initView(); @@ -769,6 +783,7 @@ angular.module('portainer.docker').controller('ServiceController', [ const image = $scope.service.Model.Spec.TaskTemplate.ContainerSpec.Image; RegistryService.retrievePorRegistryModelFromRepository(image, endpoint.Id).then((model) => { $scope.formValues.RegistryModel = model; + $scope.initialRegistryID = _.get(model, 'Registry.Id', 0); }); // Default values diff --git a/app/portainer/rest/webhooks.js b/app/portainer/rest/webhooks.js index e3a89e17d..8e32ff1b8 100644 --- a/app/portainer/rest/webhooks.js +++ b/app/portainer/rest/webhooks.js @@ -9,6 +9,7 @@ angular.module('portainer.app').factory('Webhooks', [ { query: { method: 'GET', isArray: true }, create: { method: 'POST' }, + update: { method: 'PUT', params: { id: '@id' } }, remove: { method: 'DELETE', params: { id: '@id' } }, } ); diff --git a/app/portainer/services/api/webhookService.js b/app/portainer/services/api/webhookService.js index baa8bd3d0..b615be7a3 100644 --- a/app/portainer/services/api/webhookService.js +++ b/app/portainer/services/api/webhookService.js @@ -25,8 +25,12 @@ angular.module('portainer.app').factory('WebhookService', [ return deferred.promise; }; - service.createServiceWebhook = function (serviceID, endpointID) { - return Webhooks.create({ ResourceID: serviceID, EndpointID: endpointID, WebhookType: 1 }).$promise; + service.createServiceWebhook = function (serviceID, endpointID, registryID) { + return Webhooks.create({ ResourceID: serviceID, EndpointID: endpointID, WebhookType: 1, registryID }).$promise; + }; + + service.updateServiceWebhook = function (id, registryID) { + return Webhooks.update({ id, registryID }).$promise; }; service.deleteWebhook = function (id) {