From 1162549209d4700b846b8e0082b417eb8816a939 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Thu, 26 Apr 2018 18:08:46 +0200 Subject: [PATCH] feat(endpoint-groups): add endpoint-groups (#1837) --- api/bolt/datastore.go | 28 +- api/bolt/endpoint_group_service.go | 114 ++++++ api/bolt/internal/internal.go | 10 + api/bolt/migrate_dbversion8.go | 20 + api/bolt/migrator.go | 8 + api/cmd/portainer/main.go | 6 + api/errors.go | 8 +- api/http/handler/docker.go | 15 +- api/http/handler/endpoint.go | 28 +- api/http/handler/endpoint_group.go | 364 ++++++++++++++++++ api/http/handler/extensions/storidge.go | 15 +- api/http/handler/handler.go | 3 + api/http/security/authorization.go | 35 +- api/http/security/filter.go | 35 +- api/http/server.go | 8 + api/portainer.go | 26 +- app/constants.js | 1 + app/portainer/__module.js | 48 +++ .../components/access-table/access-table.js | 21 + .../components/access-table/accessTable.html | 64 +++ .../accessManagement/por-access-management.js | 2 + .../accessManagement/porAccessManagement.html | 172 ++------- .../porAccessManagementController.js | 54 +-- .../endpointsDatatable.html | 8 + .../groups-datatable/groupsDatatable.html | 93 +++++ .../groups-datatable/groupsDatatable.js | 15 + .../endpoint-selector/endpoint-selector.js | 9 + .../endpoint-selector/endpointSelector.html | 27 ++ .../endpointSelectorController.js | 34 ++ .../components/forms/group-form/group-form.js | 31 ++ .../forms/group-form/groupForm.html | 120 ++++++ .../group-association-table.js | 21 + .../groupAssociationTable.html | 49 +++ app/portainer/helpers/endpointHelper.js | 23 ++ app/portainer/models/access.js | 2 + app/portainer/models/group.js | 29 ++ app/portainer/rest/group.js | 12 + app/portainer/services/api/accessService.js | 36 +- app/portainer/services/api/endpointService.js | 26 +- app/portainer/services/api/groupService.js | 45 +++ app/portainer/services/fileUpload.js | 3 +- .../endpoints/access/endpointAccess.html | 19 +- .../access/endpointAccessController.js | 11 +- .../views/endpoints/edit/endpoint.html | 13 + .../endpoints/edit/endpointController.js | 16 +- app/portainer/views/endpoints/endpoints.html | 10 + .../views/endpoints/endpointsController.js | 26 +- .../views/groups/access/groupAccess.html | 34 ++ .../groups/access/groupAccessController.js | 22 ++ .../groups/create/createGroupController.js | 54 +++ .../views/groups/create/creategroup.html | 25 ++ app/portainer/views/groups/edit/group.html | 25 ++ .../views/groups/edit/groupController.js | 70 ++++ app/portainer/views/groups/groups.html | 20 + .../views/groups/groupsController.js | 38 ++ .../init/endpoint/initEndpointController.js | 2 +- app/portainer/views/sidebar/sidebar.html | 15 +- .../views/sidebar/sidebarController.js | 35 +- 58 files changed, 1838 insertions(+), 265 deletions(-) create mode 100644 api/bolt/endpoint_group_service.go create mode 100644 api/bolt/migrate_dbversion8.go create mode 100644 api/http/handler/endpoint_group.go create mode 100644 app/portainer/components/access-table/access-table.js create mode 100644 app/portainer/components/access-table/accessTable.html create mode 100644 app/portainer/components/datatables/groups-datatable/groupsDatatable.html create mode 100644 app/portainer/components/datatables/groups-datatable/groupsDatatable.js create mode 100644 app/portainer/components/endpoint-selector/endpoint-selector.js create mode 100644 app/portainer/components/endpoint-selector/endpointSelector.html create mode 100644 app/portainer/components/endpoint-selector/endpointSelectorController.js create mode 100644 app/portainer/components/forms/group-form/group-form.js create mode 100644 app/portainer/components/forms/group-form/groupForm.html create mode 100644 app/portainer/components/group-association-table/group-association-table.js create mode 100644 app/portainer/components/group-association-table/groupAssociationTable.html create mode 100644 app/portainer/helpers/endpointHelper.js create mode 100644 app/portainer/models/group.js create mode 100644 app/portainer/rest/group.js create mode 100644 app/portainer/services/api/groupService.js create mode 100644 app/portainer/views/groups/access/groupAccess.html create mode 100644 app/portainer/views/groups/access/groupAccessController.js create mode 100644 app/portainer/views/groups/create/createGroupController.js create mode 100644 app/portainer/views/groups/create/creategroup.html create mode 100644 app/portainer/views/groups/edit/group.html create mode 100644 app/portainer/views/groups/edit/groupController.js create mode 100644 app/portainer/views/groups/groups.html create mode 100644 app/portainer/views/groups/groupsController.js diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index 9511bdbc3..8017d8add 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -20,6 +20,7 @@ type Store struct { TeamService *TeamService TeamMembershipService *TeamMembershipService EndpointService *EndpointService + EndpointGroupService *EndpointGroupService ResourceControlService *ResourceControlService VersionService *VersionService SettingsService *SettingsService @@ -38,6 +39,7 @@ const ( teamBucketName = "teams" teamMembershipBucketName = "team_membership" endpointBucketName = "endpoints" + endpointGroupBucketName = "endpoint_groups" resourceControlBucketName = "resource_control" settingsBucketName = "settings" registryBucketName = "registries" @@ -53,6 +55,7 @@ func NewStore(storePath string) (*Store, error) { TeamService: &TeamService{}, TeamMembershipService: &TeamMembershipService{}, EndpointService: &EndpointService{}, + EndpointGroupService: &EndpointGroupService{}, ResourceControlService: &ResourceControlService{}, VersionService: &VersionService{}, SettingsService: &SettingsService{}, @@ -64,6 +67,7 @@ func NewStore(storePath string) (*Store, error) { store.TeamService.store = store store.TeamMembershipService.store = store store.EndpointService.store = store + store.EndpointGroupService.store = store store.ResourceControlService.store = store store.VersionService.store = store store.SettingsService.store = store @@ -94,7 +98,7 @@ func (store *Store) Open() error { store.db = db bucketsToCreate := []string{versionBucketName, userBucketName, teamBucketName, endpointBucketName, - resourceControlBucketName, teamMembershipBucketName, settingsBucketName, + endpointGroupBucketName, resourceControlBucketName, teamMembershipBucketName, settingsBucketName, registryBucketName, dockerhubBucketName, stackBucketName} return db.Update(func(tx *bolt.Tx) error { @@ -110,6 +114,28 @@ func (store *Store) Open() error { }) } +// Init creates the default data set. +func (store *Store) Init() error { + groups, err := store.EndpointGroupService.EndpointGroups() + if err != nil { + return err + } + + if len(groups) == 0 { + unassignedGroup := &portainer.EndpointGroup{ + Name: "Unassigned", + Description: "Unassigned endpoints", + Labels: []portainer.Pair{}, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, + } + + return store.EndpointGroupService.CreateEndpointGroup(unassignedGroup) + } + + return nil +} + // Close closes the BoltDB database. func (store *Store) Close() error { if store.db != nil { diff --git a/api/bolt/endpoint_group_service.go b/api/bolt/endpoint_group_service.go new file mode 100644 index 000000000..c52e95dac --- /dev/null +++ b/api/bolt/endpoint_group_service.go @@ -0,0 +1,114 @@ +package bolt + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +// EndpointGroupService represents a service for managing endpoint groups. +type EndpointGroupService struct { + store *Store +} + +// EndpointGroup returns an endpoint group by ID. +func (service *EndpointGroupService) EndpointGroup(ID portainer.EndpointGroupID) (*portainer.EndpointGroup, error) { + var data []byte + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(endpointGroupBucketName)) + value := bucket.Get(internal.Itob(int(ID))) + if value == nil { + return portainer.ErrEndpointGroupNotFound + } + + data = make([]byte, len(value)) + copy(data, value) + return nil + }) + if err != nil { + return nil, err + } + + var endpointGroup portainer.EndpointGroup + err = internal.UnmarshalEndpointGroup(data, &endpointGroup) + if err != nil { + return nil, err + } + return &endpointGroup, nil +} + +// EndpointGroups return an array containing all the endpoint groups. +func (service *EndpointGroupService) EndpointGroups() ([]portainer.EndpointGroup, error) { + var endpointGroups = make([]portainer.EndpointGroup, 0) + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(endpointGroupBucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var endpointGroup portainer.EndpointGroup + err := internal.UnmarshalEndpointGroup(v, &endpointGroup) + if err != nil { + return err + } + endpointGroups = append(endpointGroups, endpointGroup) + } + + return nil + }) + if err != nil { + return nil, err + } + + return endpointGroups, nil +} + +// CreateEndpointGroup assign an ID to a new endpoint group and saves it. +func (service *EndpointGroupService) CreateEndpointGroup(endpointGroup *portainer.EndpointGroup) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(endpointGroupBucketName)) + + id, _ := bucket.NextSequence() + endpointGroup.ID = portainer.EndpointGroupID(id) + + data, err := internal.MarshalEndpointGroup(endpointGroup) + if err != nil { + return err + } + + err = bucket.Put(internal.Itob(int(endpointGroup.ID)), data) + if err != nil { + return err + } + return nil + }) +} + +// UpdateEndpointGroup updates an endpoint group. +func (service *EndpointGroupService) UpdateEndpointGroup(ID portainer.EndpointGroupID, endpointGroup *portainer.EndpointGroup) error { + data, err := internal.MarshalEndpointGroup(endpointGroup) + if err != nil { + return err + } + + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(endpointGroupBucketName)) + err = bucket.Put(internal.Itob(int(ID)), data) + if err != nil { + return err + } + return nil + }) +} + +// DeleteEndpointGroup deletes an endpoint group. +func (service *EndpointGroupService) DeleteEndpointGroup(ID portainer.EndpointGroupID) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(endpointGroupBucketName)) + err := bucket.Delete(internal.Itob(int(ID))) + if err != nil { + return err + } + return nil + }) +} diff --git a/api/bolt/internal/internal.go b/api/bolt/internal/internal.go index 2ee1027b5..b247268ee 100644 --- a/api/bolt/internal/internal.go +++ b/api/bolt/internal/internal.go @@ -47,6 +47,16 @@ func UnmarshalEndpoint(data []byte, endpoint *portainer.Endpoint) error { return json.Unmarshal(data, endpoint) } +// MarshalEndpointGroup encodes an endpoint group to binary format. +func MarshalEndpointGroup(group *portainer.EndpointGroup) ([]byte, error) { + return json.Marshal(group) +} + +// UnmarshalEndpointGroup decodes an endpoint group from a binary data. +func UnmarshalEndpointGroup(data []byte, group *portainer.EndpointGroup) error { + return json.Unmarshal(data, group) +} + // MarshalStack encodes a stack to binary format. func MarshalStack(stack *portainer.Stack) ([]byte, error) { return json.Marshal(stack) diff --git a/api/bolt/migrate_dbversion8.go b/api/bolt/migrate_dbversion8.go new file mode 100644 index 000000000..7ef77806d --- /dev/null +++ b/api/bolt/migrate_dbversion8.go @@ -0,0 +1,20 @@ +package bolt + +import "github.com/portainer/portainer" + +func (m *Migrator) updateEndpointsToVersion9() error { + legacyEndpoints, err := m.EndpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range legacyEndpoints { + endpoint.GroupID = portainer.EndpointGroupID(1) + err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + } + + return nil +} diff --git a/api/bolt/migrator.go b/api/bolt/migrator.go index 9b3aacdec..1975f1e67 100644 --- a/api/bolt/migrator.go +++ b/api/bolt/migrator.go @@ -96,6 +96,14 @@ func (m *Migrator) Migrate() error { } } + // https: //github.com/portainer/portainer/issues/1396 + if m.CurrentDBVersion < 9 { + err := m.updateEndpointsToVersion9() + if err != nil { + return err + } + } + err := m.VersionService.StoreDBVersion(portainer.DBVersion) if err != nil { return err diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 50b0591e4..03260b7db 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -49,6 +49,11 @@ func initStore(dataStorePath string) *bolt.Store { log.Fatal(err) } + err = store.Init() + if err != nil { + log.Fatal(err) + } + err = store.MigrateData() if err != nil { log.Fatal(err) @@ -275,6 +280,7 @@ func main() { TeamService: store.TeamService, TeamMembershipService: store.TeamMembershipService, EndpointService: store.EndpointService, + EndpointGroupService: store.EndpointGroupService, ResourceControlService: store.ResourceControlService, SettingsService: store.SettingsService, RegistryService: store.RegistryService, diff --git a/api/errors.go b/api/errors.go index 8e4c07d9e..7cd39f445 100644 --- a/api/errors.go +++ b/api/errors.go @@ -28,7 +28,7 @@ const ( // TeamMembership errors. const ( ErrTeamMembershipNotFound = Error("Team membership not found") - ErrTeamMembershipAlreadyExists = Error("Team membership already exists for this user and team.") + ErrTeamMembershipAlreadyExists = Error("Team membership already exists for this user and team") ) // ResourceControl errors. @@ -44,6 +44,12 @@ const ( ErrEndpointAccessDenied = Error("Access denied to endpoint") ) +// Endpoint group errors. +const ( + ErrEndpointGroupNotFound = Error("Endpoint group not found") + ErrCannotRemoveDefaultGroup = Error("Cannot remove the default endpoint group") +) + // Registry errors. const ( ErrRegistryNotFound = Error("Registry not found") diff --git a/api/http/handler/docker.go b/api/http/handler/docker.go index 5992d7065..24cf0831d 100644 --- a/api/http/handler/docker.go +++ b/api/http/handler/docker.go @@ -20,6 +20,7 @@ type DockerHandler struct { *mux.Router Logger *log.Logger EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService TeamMembershipService portainer.TeamMembershipService ProxyManager *proxy.Manager } @@ -64,9 +65,17 @@ func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r return } - if tokenData.Role != portainer.AdministratorRole && !security.AuthorizedEndpointAccess(endpoint, tokenData.ID, memberships) { - httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger) - return + if tokenData.Role != portainer.AdministratorRole { + group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if !security.AuthorizedEndpointAccess(endpoint, group, tokenData.ID, memberships) { + httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger) + return + } } var proxy http.Handler diff --git a/api/http/handler/endpoint.go b/api/http/handler/endpoint.go index e3bcdb558..0e67cc1b8 100644 --- a/api/http/handler/endpoint.go +++ b/api/http/handler/endpoint.go @@ -28,6 +28,7 @@ type EndpointHandler struct { Logger *log.Logger authorizeEndpointManagement bool EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService FileService portainer.FileService ProxyManager *proxy.Manager } @@ -75,6 +76,7 @@ type ( Name string `valid:"-"` URL string `valid:"-"` PublicURL string `valid:"-"` + GroupID int `valid:"-"` TLS bool `valid:"-"` TLSSkipVerify bool `valid:"-"` TLSSkipClientVerify bool `valid:"-"` @@ -84,6 +86,7 @@ type ( name string url string publicURL string + groupID int useTLS bool skipTLSServerVerification bool skipTLSClientVerification bool @@ -107,7 +110,13 @@ func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *htt return } - filteredEndpoints, err := security.FilterEndpoints(endpoints, securityContext) + groups, err := handler.EndpointGroupService.EndpointGroups() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + filteredEndpoints, err := security.FilterEndpoints(endpoints, groups, securityContext) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return @@ -154,6 +163,7 @@ func (handler *EndpointHandler) createTLSSecuredEndpoint(payload *postEndpointPa endpoint := &portainer.Endpoint{ Name: payload.name, URL: payload.url, + GroupID: portainer.EndpointGroupID(payload.groupID), PublicURL: payload.publicURL, TLSConfig: portainer.TLSConfiguration{ TLS: payload.useTLS, @@ -225,6 +235,7 @@ func (handler *EndpointHandler) createUnsecuredEndpoint(payload *postEndpointPay endpoint := &portainer.Endpoint{ Name: payload.name, URL: payload.url, + GroupID: portainer.EndpointGroupID(payload.groupID), PublicURL: payload.publicURL, TLSConfig: portainer.TLSConfiguration{ TLS: false, @@ -259,6 +270,17 @@ func convertPostEndpointRequestToPayload(r *http.Request) (*postEndpointPayload, return nil, ErrInvalidRequestFormat } + rawGroupID := r.FormValue("GroupID") + if rawGroupID == "" { + payload.groupID = 1 + } else { + groupID, err := strconv.Atoi(rawGroupID) + if err != nil { + return nil, err + } + payload.groupID = groupID + } + payload.useTLS = r.FormValue("TLS") == "true" if payload.useTLS { @@ -439,6 +461,10 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http endpoint.PublicURL = req.PublicURL } + if req.GroupID != 0 { + endpoint.GroupID = portainer.EndpointGroupID(req.GroupID) + } + folder := strconv.Itoa(int(endpoint.ID)) if req.TLS { endpoint.TLSConfig.TLS = true diff --git a/api/http/handler/endpoint_group.go b/api/http/handler/endpoint_group.go new file mode 100644 index 000000000..064f0dff1 --- /dev/null +++ b/api/http/handler/endpoint_group.go @@ -0,0 +1,364 @@ +package handler + +import ( + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" + + "encoding/json" + "log" + "net/http" + "os" + "strconv" + + "github.com/asaskevich/govalidator" + "github.com/gorilla/mux" +) + +// EndpointGroupHandler represents an HTTP API handler for managing endpoint groups. +type EndpointGroupHandler struct { + *mux.Router + Logger *log.Logger + EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService +} + +// NewEndpointGroupHandler returns a new instance of EndpointGroupHandler. +func NewEndpointGroupHandler(bouncer *security.RequestBouncer) *EndpointGroupHandler { + h := &EndpointGroupHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + } + h.Handle("/endpoint_groups", + bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostEndpointGroups))).Methods(http.MethodPost) + h.Handle("/endpoint_groups", + bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetEndpointGroups))).Methods(http.MethodGet) + h.Handle("/endpoint_groups/{id}", + bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetEndpointGroup))).Methods(http.MethodGet) + h.Handle("/endpoint_groups/{id}", + bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpointGroup))).Methods(http.MethodPut) + h.Handle("/endpoint_groups/{id}/access", + bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpointGroupAccess))).Methods(http.MethodPut) + h.Handle("/endpoint_groups/{id}", + bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteEndpointGroup))).Methods(http.MethodDelete) + + return h +} + +type ( + postEndpointGroupsResponse struct { + ID int `json:"Id"` + } + + postEndpointGroupsRequest struct { + Name string `valid:"required"` + Description string `valid:"-"` + Labels []portainer.Pair `valid:""` + AssociatedEndpoints []portainer.EndpointID `valid:""` + } + + putEndpointGroupAccessRequest struct { + AuthorizedUsers []int `valid:"-"` + AuthorizedTeams []int `valid:"-"` + } + + putEndpointGroupsRequest struct { + Name string `valid:"-"` + Description string `valid:"-"` + Labels []portainer.Pair `valid:""` + AssociatedEndpoints []portainer.EndpointID `valid:""` + } +) + +// handleGetEndpointGroups handles GET requests on /endpoint_groups +func (handler *EndpointGroupHandler) handleGetEndpointGroups(w http.ResponseWriter, r *http.Request) { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + endpointGroups, err := handler.EndpointGroupService.EndpointGroups() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + filteredEndpointGroups, err := security.FilterEndpointGroups(endpointGroups, securityContext) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, filteredEndpointGroups, handler.Logger) +} + +// handlePostEndpointGroups handles POST requests on /endpoint_groups +func (handler *EndpointGroupHandler) handlePostEndpointGroups(w http.ResponseWriter, r *http.Request) { + var req postEndpointGroupsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err := govalidator.ValidateStruct(req) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + endpointGroup := &portainer.EndpointGroup{ + Name: req.Name, + Description: req.Description, + Labels: req.Labels, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, + } + + err = handler.EndpointGroupService.CreateEndpointGroup(endpointGroup) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + for _, endpoint := range endpoints { + if endpoint.GroupID == portainer.EndpointGroupID(1) { + err = handler.checkForGroupAssignment(endpoint, endpointGroup.ID, req.AssociatedEndpoints) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + } + } + + encodeJSON(w, &postEndpointGroupsResponse{ID: int(endpointGroup.ID)}, handler.Logger) +} + +// handleGetEndpointGroup handles GET requests on /endpoint_groups/:id +func (handler *EndpointGroupHandler) handleGetEndpointGroup(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + endpointGroupID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + if err == portainer.ErrEndpointGroupNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, endpointGroup, handler.Logger) +} + +// handlePutEndpointGroupAccess handles PUT requests on /endpoint_groups/:id/access +func (handler *EndpointGroupHandler) handlePutEndpointGroupAccess(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + endpointGroupID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + var req putEndpointGroupAccessRequest + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err = govalidator.ValidateStruct(req) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + if err == portainer.ErrEndpointGroupNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if req.AuthorizedUsers != nil { + authorizedUserIDs := []portainer.UserID{} + for _, value := range req.AuthorizedUsers { + authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value)) + } + endpointGroup.AuthorizedUsers = authorizedUserIDs + } + + if req.AuthorizedTeams != nil { + authorizedTeamIDs := []portainer.TeamID{} + for _, value := range req.AuthorizedTeams { + authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value)) + } + endpointGroup.AuthorizedTeams = authorizedTeamIDs + } + + err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +// handlePutEndpointGroup handles PUT requests on /endpoint_groups/:id +func (handler *EndpointGroupHandler) handlePutEndpointGroup(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + endpointGroupID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + var req putEndpointGroupsRequest + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err = govalidator.ValidateStruct(req) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + groupID := portainer.EndpointGroupID(endpointGroupID) + endpointGroup, err := handler.EndpointGroupService.EndpointGroup(groupID) + if err == portainer.ErrEndpointGroupNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if req.Name != "" { + endpointGroup.Name = req.Name + } + + if req.Description != "" { + endpointGroup.Description = req.Description + } + + endpointGroup.Labels = req.Labels + + err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + for _, endpoint := range endpoints { + err = handler.updateEndpointGroup(endpoint, groupID, req.AssociatedEndpoints) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + } +} + +func (handler *EndpointGroupHandler) updateEndpointGroup(endpoint portainer.Endpoint, groupID portainer.EndpointGroupID, associatedEndpoints []portainer.EndpointID) error { + if endpoint.GroupID == groupID { + return handler.checkForGroupUnassignment(endpoint, associatedEndpoints) + } else if endpoint.GroupID == portainer.EndpointGroupID(1) { + return handler.checkForGroupAssignment(endpoint, groupID, associatedEndpoints) + } + return nil +} + +func (handler *EndpointGroupHandler) checkForGroupUnassignment(endpoint portainer.Endpoint, associatedEndpoints []portainer.EndpointID) error { + for _, id := range associatedEndpoints { + if id == endpoint.ID { + return nil + } + } + + endpoint.GroupID = portainer.EndpointGroupID(1) + return handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) +} + +func (handler *EndpointGroupHandler) checkForGroupAssignment(endpoint portainer.Endpoint, groupID portainer.EndpointGroupID, associatedEndpoints []portainer.EndpointID) error { + for _, id := range associatedEndpoints { + + if id == endpoint.ID { + endpoint.GroupID = groupID + return handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + } + } + return nil +} + +// handleDeleteEndpointGroup handles DELETE requests on /endpoint_groups/:id +func (handler *EndpointGroupHandler) handleDeleteEndpointGroup(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + endpointGroupID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + if endpointGroupID == 1 { + httperror.WriteErrorResponse(w, portainer.ErrCannotRemoveDefaultGroup, http.StatusForbidden, handler.Logger) + return + } + + groupID := portainer.EndpointGroupID(endpointGroupID) + _, err = handler.EndpointGroupService.EndpointGroup(groupID) + if err == portainer.ErrEndpointGroupNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.EndpointGroupService.DeleteEndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + for _, endpoint := range endpoints { + if endpoint.GroupID == groupID { + endpoint.GroupID = portainer.EndpointGroupID(1) + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + } + } +} diff --git a/api/http/handler/extensions/storidge.go b/api/http/handler/extensions/storidge.go index 1977705e5..bee1cd7b3 100644 --- a/api/http/handler/extensions/storidge.go +++ b/api/http/handler/extensions/storidge.go @@ -20,6 +20,7 @@ type StoridgeHandler struct { *mux.Router Logger *log.Logger EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService TeamMembershipService portainer.TeamMembershipService ProxyManager *proxy.Manager } @@ -64,9 +65,17 @@ func (handler *StoridgeHandler) proxyRequestsToStoridgeAPI(w http.ResponseWriter return } - if tokenData.Role != portainer.AdministratorRole && !security.AuthorizedEndpointAccess(endpoint, tokenData.ID, memberships) { - httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger) - return + if tokenData.Role != portainer.AdministratorRole { + group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if !security.AuthorizedEndpointAccess(endpoint, group, tokenData.ID, memberships) { + httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger) + return + } } var storidgeExtension *portainer.EndpointExtension diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 1cea2f1fa..77c6ce478 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -19,6 +19,7 @@ type Handler struct { TeamHandler *TeamHandler TeamMembershipHandler *TeamMembershipHandler EndpointHandler *EndpointHandler + EndpointGroupHandler *EndpointGroupHandler RegistryHandler *RegistryHandler DockerHubHandler *DockerHubHandler ExtensionHandler *ExtensionHandler @@ -51,6 +52,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/dockerhub"): http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/endpoint_groups"): + http.StripPrefix("/api", h.EndpointGroupHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/endpoints"): switch { case strings.Contains(r.URL.Path, "/docker/"): diff --git a/api/http/security/authorization.go b/api/http/security/authorization.go index 54932f673..a2efe216a 100644 --- a/api/http/security/authorization.go +++ b/api/http/security/authorization.go @@ -124,34 +124,37 @@ func AuthorizedUserManagement(userID portainer.UserID, context *RestrictedReques // AuthorizedEndpointAccess ensure that the user can access the specified endpoint. // It will check if the user is part of the authorized users or part of a team that is +// listed in the authorized teams of the endpoint and the associated group. +func AuthorizedEndpointAccess(endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool { + groupAccess := authorizedAccess(userID, memberships, endpointGroup.AuthorizedUsers, endpointGroup.AuthorizedTeams) + if !groupAccess { + return authorizedAccess(userID, memberships, endpoint.AuthorizedUsers, endpoint.AuthorizedTeams) + } + return true +} + +// AuthorizedEndpointGroupAccess ensure that the user can access the specified endpoint group. +// It will check if the user is part of the authorized users or part of a team that is // listed in the authorized teams. -func AuthorizedEndpointAccess(endpoint *portainer.Endpoint, userID portainer.UserID, memberships []portainer.TeamMembership) bool { - for _, authorizedUserID := range endpoint.AuthorizedUsers { - if authorizedUserID == userID { - return true - } - } - for _, membership := range memberships { - for _, authorizedTeamID := range endpoint.AuthorizedTeams { - if membership.TeamID == authorizedTeamID { - return true - } - } - } - return false +func AuthorizedEndpointGroupAccess(endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool { + return authorizedAccess(userID, memberships, endpointGroup.AuthorizedUsers, endpointGroup.AuthorizedTeams) } // AuthorizedRegistryAccess ensure that the user can access the specified registry. // It will check if the user is part of the authorized users or part of a team that is // listed in the authorized teams. func AuthorizedRegistryAccess(registry *portainer.Registry, userID portainer.UserID, memberships []portainer.TeamMembership) bool { - for _, authorizedUserID := range registry.AuthorizedUsers { + return authorizedAccess(userID, memberships, registry.AuthorizedUsers, registry.AuthorizedTeams) +} + +func authorizedAccess(userID portainer.UserID, memberships []portainer.TeamMembership, authorizedUsers []portainer.UserID, authorizedTeams []portainer.TeamID) bool { + for _, authorizedUserID := range authorizedUsers { if authorizedUserID == userID { return true } } for _, membership := range memberships { - for _, authorizedTeamID := range registry.AuthorizedTeams { + for _, authorizedTeamID := range authorizedTeams { if membership.TeamID == authorizedTeamID { return true } diff --git a/api/http/security/filter.go b/api/http/security/filter.go index ffe5e1c49..5c1b0774f 100644 --- a/api/http/security/filter.go +++ b/api/http/security/filter.go @@ -79,15 +79,17 @@ func FilterRegistries(registries []portainer.Registry, context *RestrictedReques } // FilterEndpoints filters endpoints based on user role and team memberships. -// Non administrator users only have access to authorized endpoints. -func FilterEndpoints(endpoints []portainer.Endpoint, context *RestrictedRequestContext) ([]portainer.Endpoint, error) { +// Non administrator users only have access to authorized endpoints (can be inherited via endoint groups). +func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.EndpointGroup, context *RestrictedRequestContext) ([]portainer.Endpoint, error) { filteredEndpoints := endpoints if !context.IsAdmin { filteredEndpoints = make([]portainer.Endpoint, 0) for _, endpoint := range endpoints { - if AuthorizedEndpointAccess(&endpoint, context.UserID, context.UserMemberships) { + endpointGroup := getAssociatedGroup(&endpoint, groups) + + if AuthorizedEndpointAccess(&endpoint, endpointGroup, context.UserID, context.UserMemberships) { filteredEndpoints = append(filteredEndpoints, endpoint) } } @@ -95,3 +97,30 @@ func FilterEndpoints(endpoints []portainer.Endpoint, context *RestrictedRequestC return filteredEndpoints, nil } + +// FilterEndpointGroups filters endpoint groups based on user role and team memberships. +// Non administrator users only have access to authorized endpoint groups. +func FilterEndpointGroups(endpointGroups []portainer.EndpointGroup, context *RestrictedRequestContext) ([]portainer.EndpointGroup, error) { + filteredEndpointGroups := endpointGroups + + if !context.IsAdmin { + filteredEndpointGroups = make([]portainer.EndpointGroup, 0) + + for _, group := range endpointGroups { + if AuthorizedEndpointGroupAccess(&group, context.UserID, context.UserMemberships) { + filteredEndpointGroups = append(filteredEndpointGroups, group) + } + } + } + + return filteredEndpointGroups, nil +} + +func getAssociatedGroup(endpoint *portainer.Endpoint, groups []portainer.EndpointGroup) *portainer.EndpointGroup { + for _, group := range groups { + if group.ID == endpoint.GroupID { + return &group + } + } + return nil +} diff --git a/api/http/server.go b/api/http/server.go index 536344f68..153b667f6 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -22,6 +22,7 @@ type Server struct { TeamService portainer.TeamService TeamMembershipService portainer.TeamMembershipService EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService ResourceControlService portainer.ResourceControlService SettingsService portainer.SettingsService CryptoService portainer.CryptoService @@ -72,14 +73,19 @@ func (server *Server) Start() error { templatesHandler.SettingsService = server.SettingsService var dockerHandler = handler.NewDockerHandler(requestBouncer) dockerHandler.EndpointService = server.EndpointService + dockerHandler.EndpointGroupService = server.EndpointGroupService dockerHandler.TeamMembershipService = server.TeamMembershipService dockerHandler.ProxyManager = proxyManager var websocketHandler = handler.NewWebSocketHandler() websocketHandler.EndpointService = server.EndpointService var endpointHandler = handler.NewEndpointHandler(requestBouncer, server.EndpointManagement) endpointHandler.EndpointService = server.EndpointService + endpointHandler.EndpointGroupService = server.EndpointGroupService endpointHandler.FileService = server.FileService endpointHandler.ProxyManager = proxyManager + var endpointGroupHandler = handler.NewEndpointGroupHandler(requestBouncer) + endpointGroupHandler.EndpointGroupService = server.EndpointGroupService + endpointGroupHandler.EndpointService = server.EndpointService var registryHandler = handler.NewRegistryHandler(requestBouncer) registryHandler.RegistryService = server.RegistryService var dockerHubHandler = handler.NewDockerHubHandler(requestBouncer) @@ -102,6 +108,7 @@ func (server *Server) Start() error { extensionHandler.ProxyManager = proxyManager var storidgeHandler = extensions.NewStoridgeHandler(requestBouncer) storidgeHandler.EndpointService = server.EndpointService + storidgeHandler.EndpointGroupService = server.EndpointGroupService storidgeHandler.TeamMembershipService = server.TeamMembershipService storidgeHandler.ProxyManager = proxyManager @@ -111,6 +118,7 @@ func (server *Server) Start() error { TeamHandler: teamHandler, TeamMembershipHandler: teamMembershipHandler, EndpointHandler: endpointHandler, + EndpointGroupHandler: endpointGroupHandler, RegistryHandler: registryHandler, DockerHubHandler: dockerHubHandler, ResourceHandler: resourceHandler, diff --git a/api/portainer.go b/api/portainer.go index b3a5bc63d..1fc754f0c 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -174,6 +174,7 @@ type ( ID EndpointID `json:"Id"` Name string `json:"Name"` URL string `json:"URL"` + GroupID EndpointGroupID `json:"GroupId"` PublicURL string `json:"PublicURL"` TLSConfig TLSConfiguration `json:"TLSConfig"` AuthorizedUsers []UserID `json:"AuthorizedUsers"` @@ -188,6 +189,19 @@ type ( TLSKeyPath string `json:"TLSKey,omitempty"` } + // EndpointGroupID represents an endpoint group identifier. + EndpointGroupID int + + // EndpointGroup represents a group of endpoints. + EndpointGroup struct { + ID EndpointGroupID `json:"Id"` + Name string `json:"Name"` + Description string `json:"Description"` + AuthorizedUsers []UserID `json:"AuthorizedUsers"` + AuthorizedTeams []TeamID `json:"AuthorizedTeams"` + Labels []Pair `json:"Labels"` + } + // EndpointExtension represents a extension associated to an endpoint. EndpointExtension struct { Type EndpointExtensionType `json:"Type"` @@ -248,6 +262,7 @@ type ( // DataStore defines the interface to manage the data. DataStore interface { Open() error + Init() error Close() error MigrateData() error } @@ -301,6 +316,15 @@ type ( Synchronize(toCreate, toUpdate, toDelete []*Endpoint) error } + // EndpointGroupService represents a service for managing endpoint group data. + EndpointGroupService interface { + EndpointGroup(ID EndpointGroupID) (*EndpointGroup, error) + EndpointGroups() ([]EndpointGroup, error) + CreateEndpointGroup(group *EndpointGroup) error + UpdateEndpointGroup(ID EndpointGroupID, group *EndpointGroup) error + DeleteEndpointGroup(ID EndpointGroupID) error + } + // RegistryService represents a service for managing registry data. RegistryService interface { Registry(ID RegistryID) (*Registry, error) @@ -403,7 +427,7 @@ const ( // APIVersion is the version number of the Portainer API. APIVersion = "1.16.5" // DBVersion is the version number of the Portainer database. - DBVersion = 8 + DBVersion = 9 // DefaultTemplatesURL represents the default URL for the templates definitions. DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json" ) diff --git a/app/constants.js b/app/constants.js index 4062f9dd3..8a16181f7 100644 --- a/app/constants.js +++ b/app/constants.js @@ -2,6 +2,7 @@ angular.module('portainer') .constant('API_ENDPOINT_AUTH', 'api/auth') .constant('API_ENDPOINT_DOCKERHUB', 'api/dockerhub') .constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints') +.constant('API_ENDPOINT_ENDPOINT_GROUPS', 'api/endpoint_groups') .constant('API_ENDPOINT_REGISTRIES', 'api/registries') .constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls') .constant('API_ENDPOINT_SETTINGS', 'api/settings') diff --git a/app/portainer/__module.js b/app/portainer/__module.js index 64f61b20e..ca94b581b 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -132,6 +132,50 @@ angular.module('portainer.app', []) } }; + var groups = { + name: 'portainer.groups', + url: '/groups', + views: { + 'content@': { + templateUrl: 'app/portainer/views/groups/groups.html', + controller: 'GroupsController' + } + } + }; + + var group = { + name: 'portainer.groups.group', + url: '/:id', + views: { + 'content@': { + templateUrl: 'app/portainer/views/groups/edit/group.html', + controller: 'GroupController' + } + } + }; + + var groupCreation = { + name: 'portainer.groups.new', + url: '/new', + views: { + 'content@': { + templateUrl: 'app/portainer/views/groups/create/creategroup.html', + controller: 'CreateGroupController' + } + } + }; + + var groupAccess = { + name: 'portainer.groups.group.access', + url: '/access', + views: { + 'content@': { + templateUrl: 'app/portainer/views/groups/access/groupAccess.html', + controller: 'GroupAccessController' + } + } + }; + var registries = { name: 'portainer.registries', url: '/registries', @@ -253,6 +297,10 @@ angular.module('portainer.app', []) $stateRegistryProvider.register(endpoints); $stateRegistryProvider.register(endpoint); $stateRegistryProvider.register(endpointAccess); + $stateRegistryProvider.register(groups); + $stateRegistryProvider.register(group); + $stateRegistryProvider.register(groupAccess); + $stateRegistryProvider.register(groupCreation); $stateRegistryProvider.register(registries); $stateRegistryProvider.register(registry); $stateRegistryProvider.register(registryAccess); diff --git a/app/portainer/components/access-table/access-table.js b/app/portainer/components/access-table/access-table.js new file mode 100644 index 000000000..c1cd9cc37 --- /dev/null +++ b/app/portainer/components/access-table/access-table.js @@ -0,0 +1,21 @@ +angular.module('portainer.app').component('accessTable', { + templateUrl: 'app/portainer/components/access-table/accessTable.html', + controller: function() { + this.state = { + orderBy: 'Name', + reverseOrder: false, + paginatedItemLimit: '10', + textFilter: '' + }; + + this.changeOrderBy = function(orderField) { + this.state.reverseOrder = this.state.orderBy === orderField ? !this.state.reverseOrder : false; + this.state.orderBy = orderField; + }; + }, + bindings: { + dataset: '<', + entryClick: '<', + emptyDatasetMessage: '@' + } +}); diff --git a/app/portainer/components/access-table/accessTable.html b/app/portainer/components/access-table/accessTable.html new file mode 100644 index 000000000..bf2f366ce --- /dev/null +++ b/app/portainer/components/access-table/accessTable.html @@ -0,0 +1,64 @@ +
+ +
+ + +
+ + + + + + + + + + + + + + + + + + +
+ + Name + + + + + + Type + + + +
+ {{ item.Name }} + + inherited + + + {{ item.Type }} +
Loading...
{{ $ctrl.emptyDatasetMessage }}
+ +
diff --git a/app/portainer/components/accessManagement/por-access-management.js b/app/portainer/components/accessManagement/por-access-management.js index a59839e99..3a566a694 100644 --- a/app/portainer/components/accessManagement/por-access-management.js +++ b/app/portainer/components/accessManagement/por-access-management.js @@ -3,6 +3,8 @@ angular.module('portainer.app').component('porAccessManagement', { controller: 'porAccessManagementController', bindings: { accessControlledEntity: '<', + inheritFrom: '<', + entityType: '@', updateAccess: '&' } }); diff --git a/app/portainer/components/accessManagement/porAccessManagement.html b/app/portainer/components/accessManagement/porAccessManagement.html index eca9696a5..903f73128 100644 --- a/app/portainer/components/accessManagement/porAccessManagement.html +++ b/app/portainer/components/accessManagement/porAccessManagement.html @@ -1,134 +1,46 @@ -
-
- - -
- Items per page: - -
-
- -
- -
-
- -
-
- -
- - - - - - - - - - - - - - - - - - - -
- - Name - - - - - - Type - - - -
{{ user.Name }} - - {{ user.Type }} -
Loading...
No user or team available.
-
- + + + +
+
+

You can select which user or team can access this {{ $ctrl.entityType }} by moving them to the authorized accesses table. Simply click + on a user or team entry to move it from one table to the other.

+

+ Note: accesses tagged as inherited are inherited from the group accesses and cannot be remove at the endpoint level. +

+
+
+ +
+
Users and teams
+
+ +
+
+
- - -
-
- - -
- Items per page: - -
-
- -
- -
-
- -
-
- -
- - - - - - - - - - - - - - - - - - - -
- - Name - - - - - - Type - - - -
{{ user.Name }} - - {{ user.Type }} -
Loading...
No authorized user or team.
-
- + + +
+
Authorized users and teams
+
+ +
+
+
- - -
-
+ +
+
+
+
diff --git a/app/portainer/components/accessManagement/porAccessManagementController.js b/app/portainer/components/accessManagement/porAccessManagementController.js index 201aa13e5..dcf870498 100644 --- a/app/portainer/components/accessManagement/porAccessManagementController.js +++ b/app/portainer/components/accessManagement/porAccessManagementController.js @@ -1,40 +1,13 @@ angular.module('portainer.app') -.controller('porAccessManagementController', ['AccessService', 'PaginationService', 'Notifications', -function (AccessService, PaginationService, Notifications) { +.controller('porAccessManagementController', ['AccessService', 'Notifications', +function (AccessService, Notifications) { var ctrl = this; - ctrl.state = { - pagination_count_accesses: PaginationService.getPaginationLimit('access_management_accesses'), - pagination_count_authorizedAccesses: PaginationService.getPaginationLimit('access_management_AuthorizedAccesses'), - sortAccessesBy: 'Type', - sortAccessesReverse: false, - sortAuthorizedAccessesBy: 'Type', - sortAuthorizedAccessesReverse: false - }; - - ctrl.orderAccesses = function(sortBy) { - ctrl.state.sortAccessesReverse = (ctrl.state.sortAccessesBy === sortBy) ? !ctrl.state.sortAccessesReverse : false; - ctrl.state.sortAccessesBy = sortBy; - }; - - ctrl.orderAuthorizedAccesses = function(sortBy) { - ctrl.state.sortAuthorizedAccessesReverse = (ctrl.state.sortAuthorizedAccessesBy === sortBy) ? !ctrl.state.sortAuthorizedAccessesReverse : false; - ctrl.state.sortAuthorizedAccessesBy = sortBy; - }; - - ctrl.changePaginationCountAuthorizedAccesses = function() { - PaginationService.setPaginationLimit('access_management_AuthorizedAccesses', ctrl.state.pagination_count_authorizedAccesses); - }; - - ctrl.changePaginationCountAccesses = function() { - PaginationService.setPaginationLimit('access_management_accesses', ctrl.state.pagination_count_accesses); - }; - function dispatchUserAndTeamIDs(accesses, users, teams) { angular.forEach(accesses, function (access) { - if (access.Type === 'user') { + if (access.Type === 'user' && !access.Inherited) { users.push(access.Id); - } else if (access.Type === 'team') { + } else if (access.Type === 'team' && !access.Inherited) { teams.push(access.Id); } }); @@ -111,11 +84,20 @@ function (AccessService, PaginationService, Notifications) { }); }; + function moveAccesses(source, target) { + for (var i = 0; i < source.length; i++) { + var access = source[i]; + if (!access.Inherited) { + target.push(access); + source.splice(i, 1); + } + } + } + ctrl.unauthorizeAllAccesses = function() { ctrl.updateAccess({ userAccesses: [], teamAccesses: [] }) .then(function success(data) { - ctrl.accesses = ctrl.accesses.concat(ctrl.authorizedAccesses); - ctrl.authorizedAccesses = []; + moveAccesses(ctrl.authorizedAccesses, ctrl.accesses); Notifications.success('Accesses successfully updated'); }) .catch(function error(err) { @@ -130,8 +112,7 @@ function (AccessService, PaginationService, Notifications) { ctrl.updateAccess({ userAccesses: authorizedUserIDs, teamAccesses: authorizedTeamIDs }) .then(function success(data) { - ctrl.authorizedAccesses = ctrl.authorizedAccesses.concat(ctrl.accesses); - ctrl.accesses = []; + moveAccesses(ctrl.accesses, ctrl.authorizedAccesses); Notifications.success('Accesses successfully updated'); }) .catch(function error(err) { @@ -141,7 +122,8 @@ function (AccessService, PaginationService, Notifications) { function initComponent() { var entity = ctrl.accessControlledEntity; - AccessService.accesses(entity.AuthorizedUsers, entity.AuthorizedTeams) + var parent = ctrl.inheritFrom; + AccessService.accesses(entity.AuthorizedUsers, entity.AuthorizedTeams, parent ? parent.AuthorizedUsers: [], parent ? parent.AuthorizedTeams : []) .then(function success(data) { ctrl.accesses = data.accesses; ctrl.authorizedAccesses = data.authorizedAccesses; diff --git a/app/portainer/components/datatables/endpoints-datatable/endpointsDatatable.html b/app/portainer/components/datatables/endpoints-datatable/endpointsDatatable.html index 95dc16296..d3006a093 100644 --- a/app/portainer/components/datatables/endpoints-datatable/endpointsDatatable.html +++ b/app/portainer/components/datatables/endpoints-datatable/endpointsDatatable.html @@ -43,6 +43,13 @@ + + + Group + + + + Actions @@ -57,6 +64,7 @@ {{ item.Name }} {{ item.URL | stripprotocol }} + {{ item.GroupName }} Manage access diff --git a/app/portainer/components/datatables/groups-datatable/groupsDatatable.html b/app/portainer/components/datatables/groups-datatable/groupsDatatable.html new file mode 100644 index 000000000..29c46e32f --- /dev/null +++ b/app/portainer/components/datatables/groups-datatable/groupsDatatable.html @@ -0,0 +1,93 @@ +
+ + +
+
+ {{ $ctrl.title }} +
+
+ + Search + +
+
+
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + +
+ + + + + + Name + + + + Actions
+ + + + + {{ item.Name }} + + + Manage access + +
Loading...
No group available.
+
+ +
+
+
diff --git a/app/portainer/components/datatables/groups-datatable/groupsDatatable.js b/app/portainer/components/datatables/groups-datatable/groupsDatatable.js new file mode 100644 index 000000000..1ce8e9fba --- /dev/null +++ b/app/portainer/components/datatables/groups-datatable/groupsDatatable.js @@ -0,0 +1,15 @@ +angular.module('portainer.app').component('groupsDatatable', { + templateUrl: 'app/portainer/components/datatables/groups-datatable/groupsDatatable.html', + controller: 'GenericDatatableController', + bindings: { + title: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + showTextFilter: '<', + accessManagement: '<', + removeAction: '<' + } +}); diff --git a/app/portainer/components/endpoint-selector/endpoint-selector.js b/app/portainer/components/endpoint-selector/endpoint-selector.js new file mode 100644 index 000000000..cb4ddc207 --- /dev/null +++ b/app/portainer/components/endpoint-selector/endpoint-selector.js @@ -0,0 +1,9 @@ +angular.module('portainer.app').component('endpointSelector', { + templateUrl: 'app/portainer/components/endpoint-selector/endpointSelector.html', + controller: 'EndpointSelectorController', + bindings: { + 'endpoints': '<', + 'groups': '<', + 'selectEndpoint': '<' + } +}); diff --git a/app/portainer/components/endpoint-selector/endpointSelector.html b/app/portainer/components/endpoint-selector/endpointSelector.html new file mode 100644 index 000000000..eddbc3e55 --- /dev/null +++ b/app/portainer/components/endpoint-selector/endpointSelector.html @@ -0,0 +1,27 @@ +
+
+ +
+
+
+ + +
+
+ + +
+
+
diff --git a/app/portainer/components/endpoint-selector/endpointSelectorController.js b/app/portainer/components/endpoint-selector/endpointSelectorController.js new file mode 100644 index 000000000..fab3193cc --- /dev/null +++ b/app/portainer/components/endpoint-selector/endpointSelectorController.js @@ -0,0 +1,34 @@ +angular.module('portainer.app') +.controller('EndpointSelectorController', function () { + var ctrl = this; + + this.state = { + show: false, + selectedGroup: null, + selectedEndpoint: null + }; + + this.selectGroup = function() { + this.availableEndpoints = this.endpoints.filter(function f(endpoint) { + return endpoint.GroupId === ctrl.state.selectedGroup.Id; + }); + }; + + this.$onInit = function() { + this.availableGroups = filterEmptyGroups(this.groups, this.endpoints); + this.availableEndpoints = this.endpoints; + }; + + function filterEmptyGroups(groups, endpoints) { + return groups.filter(function f(group) { + for (var i = 0; i < endpoints.length; i++) { + + var endpoint = endpoints[i]; + if (endpoint.GroupId === group.Id) { + return true; + } + } + return false; + }); + } +}); diff --git a/app/portainer/components/forms/group-form/group-form.js b/app/portainer/components/forms/group-form/group-form.js new file mode 100644 index 000000000..e72054a7b --- /dev/null +++ b/app/portainer/components/forms/group-form/group-form.js @@ -0,0 +1,31 @@ +angular.module('portainer.app').component('groupForm', { + templateUrl: 'app/portainer/components/forms/group-form/groupForm.html', + controller: function() { + var ctrl = this; + + this.associateEndpoint = function(endpoint) { + ctrl.associatedEndpoints.push(endpoint); + _.remove(ctrl.availableEndpoints, function(n) { + return n.Id === endpoint.Id; + }); + }; + + this.dissociateEndpoint = function(endpoint) { + ctrl.availableEndpoints.push(endpoint); + _.remove(ctrl.associatedEndpoints, function(n) { + return n.Id === endpoint.Id; + }); + }; + + }, + bindings: { + model: '=', + availableEndpoints: '=', + associatedEndpoints: '=', + addLabelAction: '<', + removeLabelAction: '<', + formAction: '<', + formActionLabel: '@', + actionInProgress: '<' + } +}); diff --git a/app/portainer/components/forms/group-form/groupForm.html b/app/portainer/components/forms/group-form/groupForm.html new file mode 100644 index 000000000..0a8452a2f --- /dev/null +++ b/app/portainer/components/forms/group-form/groupForm.html @@ -0,0 +1,120 @@ +
+ +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+ +
+ +
+
+ + +
+
+ + + add label + +
+ +
+
+
+ name + +
+
+ value + +
+ +
+
+ +
+ + +
+
+ Associated endpoints +
+
+
+ You can select which endpoint should be part of this group by moving them to the associated endpoints table. Simply click + on any endpoint entry to move it from one table to the other. +
+
+ +
+
Available endpoints
+
+ +
+
+ + +
+
Associated endpoints
+
+ +
+
+ +
+
+
+
+
+ Unassociated endpoints +
+
+
+ +
+
+
+ All the endpoints are assigned to a group. +
+
+ + +
+ Actions +
+
+
+ +
+
+ +
diff --git a/app/portainer/components/group-association-table/group-association-table.js b/app/portainer/components/group-association-table/group-association-table.js new file mode 100644 index 000000000..3dd1f0771 --- /dev/null +++ b/app/portainer/components/group-association-table/group-association-table.js @@ -0,0 +1,21 @@ +angular.module('portainer.app').component('groupAssociationTable', { + templateUrl: 'app/portainer/components/group-association-table/groupAssociationTable.html', + controller: function() { + this.state = { + orderBy: 'Name', + reverseOrder: false, + paginatedItemLimit: '10', + textFilter: '' + }; + + this.changeOrderBy = function(orderField) { + this.state.reverseOrder = this.state.orderBy === orderField ? !this.state.reverseOrder : false; + this.state.orderBy = orderField; + }; + }, + bindings: { + dataset: '<', + entryClick: '<', + emptyDatasetMessage: '@' + } +}); diff --git a/app/portainer/components/group-association-table/groupAssociationTable.html b/app/portainer/components/group-association-table/groupAssociationTable.html new file mode 100644 index 000000000..18e024b77 --- /dev/null +++ b/app/portainer/components/group-association-table/groupAssociationTable.html @@ -0,0 +1,49 @@ +
+ +
+ + +
+ + + + + + + + + + + + + + + + +
+ + Name + + + +
{{ item.Name }}
Loading...
{{ $ctrl.emptyDatasetMessage }}
+ +
diff --git a/app/portainer/helpers/endpointHelper.js b/app/portainer/helpers/endpointHelper.js new file mode 100644 index 000000000..2eac33c8a --- /dev/null +++ b/app/portainer/helpers/endpointHelper.js @@ -0,0 +1,23 @@ +angular.module('portainer.app') +.factory('EndpointHelper', [function EndpointHelperFactory() { + 'use strict'; + var helper = {}; + + function findAssociatedGroup(endpoint, groups) { + return _.find(groups, function(group) { + return group.Id === endpoint.GroupId; + }); + } + + helper.mapGroupNameToEndpoint = function(endpoints, groups) { + for (var i = 0; i < endpoints.length; i++) { + var endpoint = endpoints[i]; + var group = findAssociatedGroup(endpoint, groups); + if (group) { + endpoint.GroupName = group.Name; + } + } + }; + + return helper; +}]); diff --git a/app/portainer/models/access.js b/app/portainer/models/access.js index b66a93240..06a50edd1 100644 --- a/app/portainer/models/access.js +++ b/app/portainer/models/access.js @@ -2,10 +2,12 @@ function UserAccessViewModel(data) { this.Id = data.Id; this.Name = data.Username; this.Type = 'user'; + this.Inherited = false; } function TeamAccessViewModel(data) { this.Id = data.Id; this.Name = data.Name; this.Type = 'team'; + this.Inherited = false; } diff --git a/app/portainer/models/group.js b/app/portainer/models/group.js new file mode 100644 index 000000000..bb8cf0b78 --- /dev/null +++ b/app/portainer/models/group.js @@ -0,0 +1,29 @@ +function EndpointGroupDefaultModel() { + this.Name = ''; + this.Description = ''; + this.Labels = []; +} + +function EndpointGroupModel(data) { + this.Id = data.Id; + this.Name = data.Name; + this.Description = data.Description; + this.Labels = data.Labels; + this.AuthorizedUsers = data.AuthorizedUsers; + this.AuthorizedTeams = data.AuthorizedTeams; +} + +function EndpointGroupCreateRequest(model, endpoints) { + this.Name = model.Name; + this.Description = model.Description; + this.Labels = model.Labels; + this.AssociatedEndpoints = endpoints; +} + +function EndpointGroupUpdateRequest(model, endpoints) { + this.id = model.Id; + this.Name = model.Name; + this.Description = model.Description; + this.Labels = model.Labels; + this.AssociatedEndpoints = endpoints; +} diff --git a/app/portainer/rest/group.js b/app/portainer/rest/group.js new file mode 100644 index 000000000..be4507756 --- /dev/null +++ b/app/portainer/rest/group.js @@ -0,0 +1,12 @@ +angular.module('portainer.app') +.factory('EndpointGroups', ['$resource', 'API_ENDPOINT_ENDPOINT_GROUPS', function EndpointGroupsFactory($resource, API_ENDPOINT_ENDPOINT_GROUPS) { + 'use strict'; + return $resource(API_ENDPOINT_ENDPOINT_GROUPS + '/:id/:action', {}, { + create: { method: 'POST', ignoreLoadingBar: true }, + query: { method: 'GET', isArray: true }, + get: { method: 'GET', params: { id: '@id' } }, + update: { method: 'PUT', params: { id: '@id' } }, + updateAccess: { method: 'PUT', params: { id: '@id', action: 'access' } }, + remove: { method: 'DELETE', params: { id: '@id'} } + }); +}]); diff --git a/app/portainer/services/api/accessService.js b/app/portainer/services/api/accessService.js index 65382a4ba..2db034baa 100644 --- a/app/portainer/services/api/accessService.js +++ b/app/portainer/services/api/accessService.js @@ -3,33 +3,30 @@ angular.module('portainer.app') 'use strict'; var service = {}; - function mapAccessDataFromAuthorizedIDs(userAccesses, teamAccesses, authorizedUserIDs, authorizedTeamIDs) { - var accesses = []; + function mapAccessData(accesses, authorizedIDs, inheritedIDs) { + var availableAccesses = []; var authorizedAccesses = []; - angular.forEach(userAccesses, function(access) { - if (_.includes(authorizedUserIDs, access.Id)) { - authorizedAccesses.push(access); - } else { - accesses.push(access); - } - }); + for (var i = 0; i < accesses.length; i++) { - angular.forEach(teamAccesses, function(access) { - if (_.includes(authorizedTeamIDs, access.Id)) { + var access = accesses[i]; + if (_.includes(inheritedIDs, access.Id)) { + access.Inherited = true; + authorizedAccesses.push(access); + } else if (_.includes(authorizedIDs, access.Id)) { authorizedAccesses.push(access); } else { - accesses.push(access); + availableAccesses.push(access); } - }); + } return { - accesses: accesses, + accesses: availableAccesses, authorizedAccesses: authorizedAccesses }; } - service.accesses = function(authorizedUserIDs, authorizedTeamIDs) { + service.accesses = function(authorizedUserIDs, authorizedTeamIDs, inheritedUserIDs, inheritedTeamIDs) { var deferred = $q.defer(); $q.all({ @@ -44,7 +41,14 @@ angular.module('portainer.app') return new TeamAccessViewModel(team); }); - var accessData = mapAccessDataFromAuthorizedIDs(userAccesses, teamAccesses, authorizedUserIDs, authorizedTeamIDs); + var userAccessData = mapAccessData(userAccesses, authorizedUserIDs, inheritedUserIDs); + var teamAccessData = mapAccessData(teamAccesses, authorizedTeamIDs, inheritedTeamIDs); + + var accessData = { + accesses: userAccessData.accesses.concat(teamAccessData.accesses), + authorizedAccesses: userAccessData.authorizedAccesses.concat(teamAccessData.authorizedAccesses) + }; + deferred.resolve(accessData); }) .catch(function error(err) { diff --git a/app/portainer/services/api/endpointService.js b/app/portainer/services/api/endpointService.js index d67a5296b..85e15aa4f 100644 --- a/app/portainer/services/api/endpointService.js +++ b/app/portainer/services/api/endpointService.js @@ -12,6 +12,23 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) { return Endpoints.query({}).$promise; }; + service.endpointsByGroup = function(groupId) { + var deferred = $q.defer(); + + Endpoints.query({}).$promise + .then(function success(data) { + var endpoints = data.filter(function (endpoint) { + return endpoint.GroupId === groupId; + }); + deferred.resolve(endpoints); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to retrieve endpoints', err: err}); + }); + + return deferred.promise; + }; + service.updateAccess = function(id, authorizedUserIDs, authorizedTeamIDs) { return Endpoints.updateAccess({id: id}, {authorizedUsers: authorizedUserIDs, authorizedTeams: authorizedTeamIDs}).$promise; }; @@ -20,6 +37,7 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) { var query = { name: endpointParams.name, PublicURL: endpointParams.PublicURL, + GroupId: endpointParams.GroupId, TLS: endpointParams.TLS, TLSSkipVerify: endpointParams.TLSSkipVerify, TLSSkipClientVerify: endpointParams.TLSSkipClientVerify, @@ -49,10 +67,10 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) { return Endpoints.remove({id: endpointID}).$promise; }; - service.createLocalEndpoint = function(name, URL, TLS, active) { + service.createLocalEndpoint = function() { var deferred = $q.defer(); - FileUploadService.createEndpoint('local', 'unix:///var/run/docker.sock', '', false) + FileUploadService.createEndpoint('local', 'unix:///var/run/docker.sock', '', 1, false) .then(function success(response) { deferred.resolve(response.data); }) @@ -63,10 +81,10 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) { return deferred.promise; }; - service.createRemoteEndpoint = function(name, URL, PublicURL, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { + service.createRemoteEndpoint = function(name, URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { var deferred = $q.defer(); - FileUploadService.createEndpoint(name, 'tcp://' + URL, PublicURL, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) + FileUploadService.createEndpoint(name, 'tcp://' + URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) .then(function success(response) { deferred.resolve(response.data); }) diff --git a/app/portainer/services/api/groupService.js b/app/portainer/services/api/groupService.js new file mode 100644 index 000000000..ac60f787d --- /dev/null +++ b/app/portainer/services/api/groupService.js @@ -0,0 +1,45 @@ +angular.module('portainer.app') +.factory('GroupService', ['$q', 'EndpointGroups', +function GroupService($q, EndpointGroups) { + 'use strict'; + var service = {}; + + service.group = function(groupId) { + var deferred = $q.defer(); + + EndpointGroups.get({ id: groupId }).$promise + .then(function success(data) { + var group = new EndpointGroupModel(data); + deferred.resolve(group); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve group', err: err }); + }); + + return deferred.promise; + }; + + service.groups = function() { + return EndpointGroups.query({}).$promise; + }; + + service.createGroup = function(model, endpoints) { + var payload = new EndpointGroupCreateRequest(model, endpoints); + return EndpointGroups.create(payload).$promise; + }; + + service.updateGroup = function(model, endpoints) { + var payload = new EndpointGroupUpdateRequest(model, endpoints); + return EndpointGroups.update(payload).$promise; + }; + + service.updateAccess = function(groupId, authorizedUserIDs, authorizedTeamIDs) { + return EndpointGroups.updateAccess({ id: groupId }, { authorizedUsers: authorizedUserIDs, authorizedTeams: authorizedTeamIDs }).$promise; + }; + + service.deleteGroup = function(groupId) { + return EndpointGroups.remove({ id: groupId }).$promise; + }; + + return service; +}]); diff --git a/app/portainer/services/fileUpload.js b/app/portainer/services/fileUpload.js index 590e1d65c..d2c2120d6 100644 --- a/app/portainer/services/fileUpload.js +++ b/app/portainer/services/fileUpload.js @@ -42,13 +42,14 @@ angular.module('portainer.app') }); }; - service.createEndpoint = function(name, URL, PublicURL, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { + service.createEndpoint = function(name, URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { return Upload.upload({ url: 'api/endpoints', data: { Name: name, URL: URL, PublicURL: PublicURL, + GroupID: groupID, TLS: TLS, TLSSkipVerify: TLSSkipVerify, TLSSkipClientVerify: TLSSkipClientVerify, diff --git a/app/portainer/views/endpoints/access/endpointAccess.html b/app/portainer/views/endpoints/access/endpointAccess.html index 32d111856..eae43d390 100644 --- a/app/portainer/views/endpoints/access/endpointAccess.html +++ b/app/portainer/views/endpoints/access/endpointAccess.html @@ -6,7 +6,7 @@
- - - +
+
+ +
+
diff --git a/app/portainer/views/endpoints/access/endpointAccessController.js b/app/portainer/views/endpoints/access/endpointAccessController.js index b2de91a22..c30dba972 100644 --- a/app/portainer/views/endpoints/access/endpointAccessController.js +++ b/app/portainer/views/endpoints/access/endpointAccessController.js @@ -1,6 +1,6 @@ angular.module('portainer.app') -.controller('EndpointAccessController', ['$scope', '$transition$', 'EndpointService', 'Notifications', -function ($scope, $transition$, EndpointService, Notifications) { +.controller('EndpointAccessController', ['$scope', '$transition$', 'EndpointService', 'GroupService', 'Notifications', +function ($scope, $transition$, EndpointService, GroupService, Notifications) { $scope.updateAccess = function(authorizedUsers, authorizedTeams) { return EndpointService.updateAccess($transition$.params().id, authorizedUsers, authorizedTeams); @@ -9,7 +9,12 @@ function ($scope, $transition$, EndpointService, Notifications) { function initView() { EndpointService.endpoint($transition$.params().id) .then(function success(data) { - $scope.endpoint = data; + var endpoint = data; + $scope.endpoint = endpoint; + return GroupService.group(endpoint.GroupId); + }) + .then(function success(data) { + $scope.group = data; }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve endpoint details'); diff --git a/app/portainer/views/endpoints/edit/endpoint.html b/app/portainer/views/endpoints/edit/endpoint.html index 0a4eba682..54e75b445 100644 --- a/app/portainer/views/endpoints/edit/endpoint.html +++ b/app/portainer/views/endpoints/edit/endpoint.html @@ -43,6 +43,19 @@
+
+ Grouping +
+ +
+ +
+ +
+
+
diff --git a/app/portainer/views/endpoints/edit/endpointController.js b/app/portainer/views/endpoints/edit/endpointController.js index cc9a89004..bdcbc63b0 100644 --- a/app/portainer/views/endpoints/edit/endpointController.js +++ b/app/portainer/views/endpoints/edit/endpointController.js @@ -1,6 +1,6 @@ angular.module('portainer.app') -.controller('EndpointController', ['$scope', '$state', '$transition$', '$filter', 'EndpointService', 'Notifications', -function ($scope, $state, $transition$, $filter, EndpointService, Notifications) { +.controller('EndpointController', ['$q', '$scope', '$state', '$transition$', '$filter', 'EndpointService', 'GroupService', 'EndpointProvider', 'Notifications', +function ($q, $scope, $state, $transition$, $filter, EndpointService, GroupService, EndpointProvider, Notifications) { if (!$scope.applicationState.application.endpointManagement) { $state.go('portainer.endpoints'); @@ -27,6 +27,7 @@ function ($scope, $state, $transition$, $filter, EndpointService, Notifications) name: endpoint.Name, URL: endpoint.URL, PublicURL: endpoint.PublicURL, + GroupId: endpoint.GroupId, TLS: TLS, TLSSkipVerify: TLSSkipVerify, TLSSkipClientVerify: TLSSkipClientVerify, @@ -40,7 +41,8 @@ function ($scope, $state, $transition$, $filter, EndpointService, Notifications) EndpointService.updateEndpoint(endpoint.Id, endpointParams) .then(function success(data) { Notifications.success('Endpoint updated', $scope.endpoint.Name); - $state.go('portainer.endpoints'); + EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); + $state.go('portainer.endpoints', {}, {reload: true}); }, function error(err) { Notifications.error('Failure', err, 'Unable to update endpoint'); $scope.state.actionInProgress = false; @@ -52,9 +54,12 @@ function ($scope, $state, $transition$, $filter, EndpointService, Notifications) }; function initView() { - EndpointService.endpoint($transition$.params().id) + $q.all({ + endpoint: EndpointService.endpoint($transition$.params().id), + groups: GroupService.groups() + }) .then(function success(data) { - var endpoint = data; + var endpoint = data.endpoint; if (endpoint.URL.indexOf('unix://') === 0) { $scope.endpointType = 'local'; } else { @@ -62,6 +67,7 @@ function ($scope, $state, $transition$, $filter, EndpointService, Notifications) } endpoint.URL = $filter('stripprotocol')(endpoint.URL); $scope.endpoint = endpoint; + $scope.groups = data.groups; }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve endpoint details'); diff --git a/app/portainer/views/endpoints/endpoints.html b/app/portainer/views/endpoints/endpoints.html index 7b6eb9205..a10d4acf3 100644 --- a/app/portainer/views/endpoints/endpoints.html +++ b/app/portainer/views/endpoints/endpoints.html @@ -59,6 +59,16 @@
+ +
+ +
+ +
+
+ diff --git a/app/portainer/views/endpoints/endpointsController.js b/app/portainer/views/endpoints/endpointsController.js index 389007111..b7f292d95 100644 --- a/app/portainer/views/endpoints/endpointsController.js +++ b/app/portainer/views/endpoints/endpointsController.js @@ -1,6 +1,6 @@ angular.module('portainer.app') -.controller('EndpointsController', ['$scope', '$state', '$filter', 'EndpointService', 'Notifications', -function ($scope, $state, $filter, EndpointService, Notifications) { +.controller('EndpointsController', ['$q', '$scope', '$state', '$filter', 'EndpointService', 'GroupService', 'EndpointHelper', 'Notifications', +function ($q, $scope, $state, $filter, EndpointService, GroupService, EndpointHelper, Notifications) { $scope.state = { uploadInProgress: false, actionInProgress: false @@ -10,6 +10,7 @@ function ($scope, $state, $filter, EndpointService, Notifications) { Name: '', URL: '', PublicURL: '', + GroupId: 1, SecurityFormData: new EndpointSecurityFormData() }; @@ -20,6 +21,7 @@ function ($scope, $state, $filter, EndpointService, Notifications) { if (PublicURL === '') { PublicURL = URL.split(':')[0]; } + var groupId = $scope.formValues.GroupId; var securityData = $scope.formValues.SecurityFormData; var TLS = securityData.TLS; @@ -31,7 +33,7 @@ function ($scope, $state, $filter, EndpointService, Notifications) { var TLSKeyFile = TLSSkipClientVerify ? null : securityData.TLSKey; $scope.state.actionInProgress = true; - EndpointService.createRemoteEndpoint(name, URL, PublicURL, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) + EndpointService.createRemoteEndpoint(name, URL, PublicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) .then(function success() { Notifications.success('Endpoint created', name); $state.reload(); @@ -65,16 +67,22 @@ function ($scope, $state, $filter, EndpointService, Notifications) { }); }; - function fetchEndpoints() { - EndpointService.endpoints() + function initView() { + $q.all({ + endpoints: EndpointService.endpoints(), + groups: GroupService.groups() + }) .then(function success(data) { - $scope.endpoints = data; + var endpoints = data.endpoints; + var groups = data.groups; + EndpointHelper.mapGroupNameToEndpoint(endpoints, groups); + $scope.groups = groups; + $scope.endpoints = endpoints; }) .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve endpoints'); - $scope.endpoints = []; + Notifications.error('Failure', err, 'Unable to load view'); }); } - fetchEndpoints(); + initView(); }]); diff --git a/app/portainer/views/groups/access/groupAccess.html b/app/portainer/views/groups/access/groupAccess.html new file mode 100644 index 000000000..9a857acb2 --- /dev/null +++ b/app/portainer/views/groups/access/groupAccess.html @@ -0,0 +1,34 @@ + + + + Groups > {{ group.Name }} > Access management + + + +
+
+ + + + + + + + + + +
Name + {{ group.Name }} +
+
+
+
+
+ +
+
+ +
+
diff --git a/app/portainer/views/groups/access/groupAccessController.js b/app/portainer/views/groups/access/groupAccessController.js new file mode 100644 index 000000000..191d7a98c --- /dev/null +++ b/app/portainer/views/groups/access/groupAccessController.js @@ -0,0 +1,22 @@ +angular.module('portainer.app') +.controller('GroupAccessController', ['$scope', '$transition$', 'GroupService', 'Notifications', +function ($scope, $transition$, GroupService, Notifications) { + + $scope.updateAccess = function(authorizedUsers, authorizedTeams) { + return GroupService.updateAccess($transition$.params().id, authorizedUsers, authorizedTeams); + }; + + function initView() { + var groupId = $transition$.params().id; + + GroupService.group(groupId) + .then(function success(data) { + $scope.group = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to load view'); + }); + } + + initView(); +}]); diff --git a/app/portainer/views/groups/create/createGroupController.js b/app/portainer/views/groups/create/createGroupController.js new file mode 100644 index 000000000..1a14510fa --- /dev/null +++ b/app/portainer/views/groups/create/createGroupController.js @@ -0,0 +1,54 @@ +angular.module('portainer.app') +.controller('CreateGroupController', ['$scope', '$state', 'GroupService', 'EndpointService', 'Notifications', +function ($scope, $state, GroupService, EndpointService, Notifications) { + + $scope.state = { + actionInProgress: false + }; + + $scope.addLabel = function() { + $scope.model.Labels.push({ name: '', value: '' }); + }; + + $scope.removeLabel = function(index) { + $scope.model.Labels.splice(index, 1); + }; + + $scope.create = function() { + var model = $scope.model; + + var associatedEndpoints = []; + for (var i = 0; i < $scope.associatedEndpoints.length; i++) { + var endpoint = $scope.associatedEndpoints[i]; + associatedEndpoints.push(endpoint.Id); + } + + $scope.state.actionInProgress = true; + GroupService.createGroup(model, associatedEndpoints) + .then(function success() { + Notifications.success('Group successfully created'); + $state.go('portainer.groups', {}, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create group'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + }; + + function initView() { + $scope.model = new EndpointGroupDefaultModel(); + + EndpointService.endpointsByGroup(1) + .then(function success(data) { + $scope.availableEndpoints = data; + $scope.associatedEndpoints = []; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve endpoints'); + }); + } + + initView(); +}]); diff --git a/app/portainer/views/groups/create/creategroup.html b/app/portainer/views/groups/create/creategroup.html new file mode 100644 index 000000000..e4938e6ff --- /dev/null +++ b/app/portainer/views/groups/create/creategroup.html @@ -0,0 +1,25 @@ + + + + Endpoint groups > Add group + + + +
+
+ + + + + +
+
diff --git a/app/portainer/views/groups/edit/group.html b/app/portainer/views/groups/edit/group.html new file mode 100644 index 000000000..4d5861d80 --- /dev/null +++ b/app/portainer/views/groups/edit/group.html @@ -0,0 +1,25 @@ + + + + Groups > {{ group.Name }} + + + +
+
+ + + + + +
+
diff --git a/app/portainer/views/groups/edit/groupController.js b/app/portainer/views/groups/edit/groupController.js new file mode 100644 index 000000000..7737a3d3c --- /dev/null +++ b/app/portainer/views/groups/edit/groupController.js @@ -0,0 +1,70 @@ +angular.module('portainer.app') +.controller('GroupController', ['$q', '$scope', '$state', '$transition$', 'GroupService', 'EndpointService', 'Notifications', +function ($q, $scope, $state, $transition$, GroupService, EndpointService, Notifications) { + + $scope.state = { + actionInProgress: false + }; + + $scope.addLabel = function() { + $scope.group.Labels.push({ name: '', value: '' }); + }; + + $scope.removeLabel = function(index) { + $scope.group.Labels.splice(index, 1); + }; + + $scope.update = function() { + var model = $scope.group; + + var associatedEndpoints = []; + for (var i = 0; i < $scope.associatedEndpoints.length; i++) { + var endpoint = $scope.associatedEndpoints[i]; + associatedEndpoints.push(endpoint.Id); + } + + $scope.state.actionInProgress = true; + GroupService.updateGroup(model, associatedEndpoints) + .then(function success(data) { + Notifications.success('Group successfully updated'); + $state.go('portainer.groups', {}, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update group'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + }; + + function initView() { + var groupId = $transition$.params().id; + + $q.all({ + group: GroupService.group(groupId), + endpoints: EndpointService.endpoints() + }) + .then(function success(data) { + $scope.group = data.group; + + var availableEndpoints = []; + var associatedEndpoints = []; + for (var i = 0; i < data.endpoints.length; i++) { + var endpoint = data.endpoints[i]; + if (endpoint.GroupId === +groupId) { + associatedEndpoints.push(endpoint); + } else if (endpoint.GroupId === 1) { + availableEndpoints.push(endpoint); + } + } + + $scope.availableEndpoints = availableEndpoints; + $scope.associatedEndpoints = associatedEndpoints; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to load view'); + }); + } + + initView(); +}]); diff --git a/app/portainer/views/groups/groups.html b/app/portainer/views/groups/groups.html new file mode 100644 index 000000000..2e6f91c39 --- /dev/null +++ b/app/portainer/views/groups/groups.html @@ -0,0 +1,20 @@ + + + + + + + Endpoint group management + + +
+
+ +
+
diff --git a/app/portainer/views/groups/groupsController.js b/app/portainer/views/groups/groupsController.js new file mode 100644 index 000000000..9a8d76a6c --- /dev/null +++ b/app/portainer/views/groups/groupsController.js @@ -0,0 +1,38 @@ +angular.module('portainer.app') +.controller('GroupsController', ['$scope', '$state', '$filter', 'GroupService', 'Notifications', +function ($scope, $state, $filter, GroupService, Notifications) { + + $scope.removeAction = function (selectedItems) { + var actionCount = selectedItems.length; + angular.forEach(selectedItems, function (group) { + GroupService.deleteGroup(group.Id) + .then(function success() { + Notifications.success('Endpoint group successfully removed', group.Name); + var index = $scope.groups.indexOf(group); + $scope.groups.splice(index, 1); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove group'); + }) + .finally(function final() { + --actionCount; + if (actionCount === 0) { + $state.reload(); + } + }); + }); + }; + + function initView() { + GroupService.groups() + .then(function success(data) { + $scope.groups = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve endpoint groups'); + $scope.groups = []; + }); + } + + initView(); +}]); diff --git a/app/portainer/views/init/endpoint/initEndpointController.js b/app/portainer/views/init/endpoint/initEndpointController.js index 0c982b795..e12e2f476 100644 --- a/app/portainer/views/init/endpoint/initEndpointController.js +++ b/app/portainer/views/init/endpoint/initEndpointController.js @@ -31,7 +31,7 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif var endpointID = 1; $scope.state.actionInProgress = true; - EndpointService.createLocalEndpoint(name, URL, false, true) + EndpointService.createLocalEndpoint() .then(function success(data) { endpointID = data.Id; EndpointProvider.setEndpointID(endpointID); diff --git a/app/portainer/views/sidebar/sidebar.html b/app/portainer/views/sidebar/sidebar.html index a50f6516e..ca809fb75 100644 --- a/app/portainer/views/sidebar/sidebar.html +++ b/app/portainer/views/sidebar/sidebar.html @@ -9,12 +9,12 @@