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 @@
+
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:
-
- All
- 10
- 25
- 50
- 100
-
-
-
-
-
- Authorize all
-
-
-
-
-
-
-
-
-
-
-
-
- Name
-
-
-
-
-
-
- Type
-
-
-
-
-
-
-
-
- {{ user.Name }}
-
-
- {{ user.Type }}
-
-
-
- Loading...
-
-
- No user or team available.
-
-
-
-
-
-
+
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 @@