feat(endpoint-groups): add endpoint-groups (#1837)

pull/1333/merge
Anthony Lapenna 2018-04-26 18:08:46 +02:00 committed by GitHub
parent 2ffcb946b1
commit 1162549209
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
58 changed files with 1838 additions and 265 deletions

View File

@ -20,6 +20,7 @@ type Store struct {
TeamService *TeamService TeamService *TeamService
TeamMembershipService *TeamMembershipService TeamMembershipService *TeamMembershipService
EndpointService *EndpointService EndpointService *EndpointService
EndpointGroupService *EndpointGroupService
ResourceControlService *ResourceControlService ResourceControlService *ResourceControlService
VersionService *VersionService VersionService *VersionService
SettingsService *SettingsService SettingsService *SettingsService
@ -38,6 +39,7 @@ const (
teamBucketName = "teams" teamBucketName = "teams"
teamMembershipBucketName = "team_membership" teamMembershipBucketName = "team_membership"
endpointBucketName = "endpoints" endpointBucketName = "endpoints"
endpointGroupBucketName = "endpoint_groups"
resourceControlBucketName = "resource_control" resourceControlBucketName = "resource_control"
settingsBucketName = "settings" settingsBucketName = "settings"
registryBucketName = "registries" registryBucketName = "registries"
@ -53,6 +55,7 @@ func NewStore(storePath string) (*Store, error) {
TeamService: &TeamService{}, TeamService: &TeamService{},
TeamMembershipService: &TeamMembershipService{}, TeamMembershipService: &TeamMembershipService{},
EndpointService: &EndpointService{}, EndpointService: &EndpointService{},
EndpointGroupService: &EndpointGroupService{},
ResourceControlService: &ResourceControlService{}, ResourceControlService: &ResourceControlService{},
VersionService: &VersionService{}, VersionService: &VersionService{},
SettingsService: &SettingsService{}, SettingsService: &SettingsService{},
@ -64,6 +67,7 @@ func NewStore(storePath string) (*Store, error) {
store.TeamService.store = store store.TeamService.store = store
store.TeamMembershipService.store = store store.TeamMembershipService.store = store
store.EndpointService.store = store store.EndpointService.store = store
store.EndpointGroupService.store = store
store.ResourceControlService.store = store store.ResourceControlService.store = store
store.VersionService.store = store store.VersionService.store = store
store.SettingsService.store = store store.SettingsService.store = store
@ -94,7 +98,7 @@ func (store *Store) Open() error {
store.db = db store.db = db
bucketsToCreate := []string{versionBucketName, userBucketName, teamBucketName, endpointBucketName, bucketsToCreate := []string{versionBucketName, userBucketName, teamBucketName, endpointBucketName,
resourceControlBucketName, teamMembershipBucketName, settingsBucketName, endpointGroupBucketName, resourceControlBucketName, teamMembershipBucketName, settingsBucketName,
registryBucketName, dockerhubBucketName, stackBucketName} registryBucketName, dockerhubBucketName, stackBucketName}
return db.Update(func(tx *bolt.Tx) error { 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. // Close closes the BoltDB database.
func (store *Store) Close() error { func (store *Store) Close() error {
if store.db != nil { if store.db != nil {

View File

@ -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
})
}

View File

@ -47,6 +47,16 @@ func UnmarshalEndpoint(data []byte, endpoint *portainer.Endpoint) error {
return json.Unmarshal(data, endpoint) 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. // MarshalStack encodes a stack to binary format.
func MarshalStack(stack *portainer.Stack) ([]byte, error) { func MarshalStack(stack *portainer.Stack) ([]byte, error) {
return json.Marshal(stack) return json.Marshal(stack)

View File

@ -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
}

View File

@ -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) err := m.VersionService.StoreDBVersion(portainer.DBVersion)
if err != nil { if err != nil {
return err return err

View File

@ -49,6 +49,11 @@ func initStore(dataStorePath string) *bolt.Store {
log.Fatal(err) log.Fatal(err)
} }
err = store.Init()
if err != nil {
log.Fatal(err)
}
err = store.MigrateData() err = store.MigrateData()
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@ -275,6 +280,7 @@ func main() {
TeamService: store.TeamService, TeamService: store.TeamService,
TeamMembershipService: store.TeamMembershipService, TeamMembershipService: store.TeamMembershipService,
EndpointService: store.EndpointService, EndpointService: store.EndpointService,
EndpointGroupService: store.EndpointGroupService,
ResourceControlService: store.ResourceControlService, ResourceControlService: store.ResourceControlService,
SettingsService: store.SettingsService, SettingsService: store.SettingsService,
RegistryService: store.RegistryService, RegistryService: store.RegistryService,

View File

@ -28,7 +28,7 @@ const (
// TeamMembership errors. // TeamMembership errors.
const ( const (
ErrTeamMembershipNotFound = Error("Team membership not found") 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. // ResourceControl errors.
@ -44,6 +44,12 @@ const (
ErrEndpointAccessDenied = Error("Access denied to endpoint") 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. // Registry errors.
const ( const (
ErrRegistryNotFound = Error("Registry not found") ErrRegistryNotFound = Error("Registry not found")

View File

@ -20,6 +20,7 @@ type DockerHandler struct {
*mux.Router *mux.Router
Logger *log.Logger Logger *log.Logger
EndpointService portainer.EndpointService EndpointService portainer.EndpointService
EndpointGroupService portainer.EndpointGroupService
TeamMembershipService portainer.TeamMembershipService TeamMembershipService portainer.TeamMembershipService
ProxyManager *proxy.Manager ProxyManager *proxy.Manager
} }
@ -64,9 +65,17 @@ func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r
return return
} }
if tokenData.Role != portainer.AdministratorRole && !security.AuthorizedEndpointAccess(endpoint, tokenData.ID, memberships) { if tokenData.Role != portainer.AdministratorRole {
httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger) group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID)
return 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 var proxy http.Handler

View File

@ -28,6 +28,7 @@ type EndpointHandler struct {
Logger *log.Logger Logger *log.Logger
authorizeEndpointManagement bool authorizeEndpointManagement bool
EndpointService portainer.EndpointService EndpointService portainer.EndpointService
EndpointGroupService portainer.EndpointGroupService
FileService portainer.FileService FileService portainer.FileService
ProxyManager *proxy.Manager ProxyManager *proxy.Manager
} }
@ -75,6 +76,7 @@ type (
Name string `valid:"-"` Name string `valid:"-"`
URL string `valid:"-"` URL string `valid:"-"`
PublicURL string `valid:"-"` PublicURL string `valid:"-"`
GroupID int `valid:"-"`
TLS bool `valid:"-"` TLS bool `valid:"-"`
TLSSkipVerify bool `valid:"-"` TLSSkipVerify bool `valid:"-"`
TLSSkipClientVerify bool `valid:"-"` TLSSkipClientVerify bool `valid:"-"`
@ -84,6 +86,7 @@ type (
name string name string
url string url string
publicURL string publicURL string
groupID int
useTLS bool useTLS bool
skipTLSServerVerification bool skipTLSServerVerification bool
skipTLSClientVerification bool skipTLSClientVerification bool
@ -107,7 +110,13 @@ func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *htt
return 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 { if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return return
@ -154,6 +163,7 @@ func (handler *EndpointHandler) createTLSSecuredEndpoint(payload *postEndpointPa
endpoint := &portainer.Endpoint{ endpoint := &portainer.Endpoint{
Name: payload.name, Name: payload.name,
URL: payload.url, URL: payload.url,
GroupID: portainer.EndpointGroupID(payload.groupID),
PublicURL: payload.publicURL, PublicURL: payload.publicURL,
TLSConfig: portainer.TLSConfiguration{ TLSConfig: portainer.TLSConfiguration{
TLS: payload.useTLS, TLS: payload.useTLS,
@ -225,6 +235,7 @@ func (handler *EndpointHandler) createUnsecuredEndpoint(payload *postEndpointPay
endpoint := &portainer.Endpoint{ endpoint := &portainer.Endpoint{
Name: payload.name, Name: payload.name,
URL: payload.url, URL: payload.url,
GroupID: portainer.EndpointGroupID(payload.groupID),
PublicURL: payload.publicURL, PublicURL: payload.publicURL,
TLSConfig: portainer.TLSConfiguration{ TLSConfig: portainer.TLSConfiguration{
TLS: false, TLS: false,
@ -259,6 +270,17 @@ func convertPostEndpointRequestToPayload(r *http.Request) (*postEndpointPayload,
return nil, ErrInvalidRequestFormat 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" payload.useTLS = r.FormValue("TLS") == "true"
if payload.useTLS { if payload.useTLS {
@ -439,6 +461,10 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http
endpoint.PublicURL = req.PublicURL endpoint.PublicURL = req.PublicURL
} }
if req.GroupID != 0 {
endpoint.GroupID = portainer.EndpointGroupID(req.GroupID)
}
folder := strconv.Itoa(int(endpoint.ID)) folder := strconv.Itoa(int(endpoint.ID))
if req.TLS { if req.TLS {
endpoint.TLSConfig.TLS = true endpoint.TLSConfig.TLS = true

View File

@ -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
}
}
}
}

View File

@ -20,6 +20,7 @@ type StoridgeHandler struct {
*mux.Router *mux.Router
Logger *log.Logger Logger *log.Logger
EndpointService portainer.EndpointService EndpointService portainer.EndpointService
EndpointGroupService portainer.EndpointGroupService
TeamMembershipService portainer.TeamMembershipService TeamMembershipService portainer.TeamMembershipService
ProxyManager *proxy.Manager ProxyManager *proxy.Manager
} }
@ -64,9 +65,17 @@ func (handler *StoridgeHandler) proxyRequestsToStoridgeAPI(w http.ResponseWriter
return return
} }
if tokenData.Role != portainer.AdministratorRole && !security.AuthorizedEndpointAccess(endpoint, tokenData.ID, memberships) { if tokenData.Role != portainer.AdministratorRole {
httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger) group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID)
return 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 var storidgeExtension *portainer.EndpointExtension

View File

@ -19,6 +19,7 @@ type Handler struct {
TeamHandler *TeamHandler TeamHandler *TeamHandler
TeamMembershipHandler *TeamMembershipHandler TeamMembershipHandler *TeamMembershipHandler
EndpointHandler *EndpointHandler EndpointHandler *EndpointHandler
EndpointGroupHandler *EndpointGroupHandler
RegistryHandler *RegistryHandler RegistryHandler *RegistryHandler
DockerHubHandler *DockerHubHandler DockerHubHandler *DockerHubHandler
ExtensionHandler *ExtensionHandler ExtensionHandler *ExtensionHandler
@ -51,6 +52,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r) http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/dockerhub"): case strings.HasPrefix(r.URL.Path, "/api/dockerhub"):
http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r) 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"): case strings.HasPrefix(r.URL.Path, "/api/endpoints"):
switch { switch {
case strings.Contains(r.URL.Path, "/docker/"): case strings.Contains(r.URL.Path, "/docker/"):

View File

@ -124,34 +124,37 @@ func AuthorizedUserManagement(userID portainer.UserID, context *RestrictedReques
// AuthorizedEndpointAccess ensure that the user can access the specified endpoint. // 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 // 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. // listed in the authorized teams.
func AuthorizedEndpointAccess(endpoint *portainer.Endpoint, userID portainer.UserID, memberships []portainer.TeamMembership) bool { func AuthorizedEndpointGroupAccess(endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
for _, authorizedUserID := range endpoint.AuthorizedUsers { return authorizedAccess(userID, memberships, endpointGroup.AuthorizedUsers, endpointGroup.AuthorizedTeams)
if authorizedUserID == userID {
return true
}
}
for _, membership := range memberships {
for _, authorizedTeamID := range endpoint.AuthorizedTeams {
if membership.TeamID == authorizedTeamID {
return true
}
}
}
return false
} }
// AuthorizedRegistryAccess ensure that the user can access the specified registry. // 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 // It will check if the user is part of the authorized users or part of a team that is
// listed in the authorized teams. // listed in the authorized teams.
func AuthorizedRegistryAccess(registry *portainer.Registry, userID portainer.UserID, memberships []portainer.TeamMembership) bool { 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 { if authorizedUserID == userID {
return true return true
} }
} }
for _, membership := range memberships { for _, membership := range memberships {
for _, authorizedTeamID := range registry.AuthorizedTeams { for _, authorizedTeamID := range authorizedTeams {
if membership.TeamID == authorizedTeamID { if membership.TeamID == authorizedTeamID {
return true return true
} }

View File

@ -79,15 +79,17 @@ func FilterRegistries(registries []portainer.Registry, context *RestrictedReques
} }
// FilterEndpoints filters endpoints based on user role and team memberships. // FilterEndpoints filters endpoints based on user role and team memberships.
// Non administrator users only have access to authorized endpoints. // Non administrator users only have access to authorized endpoints (can be inherited via endoint groups).
func FilterEndpoints(endpoints []portainer.Endpoint, context *RestrictedRequestContext) ([]portainer.Endpoint, error) { func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.EndpointGroup, context *RestrictedRequestContext) ([]portainer.Endpoint, error) {
filteredEndpoints := endpoints filteredEndpoints := endpoints
if !context.IsAdmin { if !context.IsAdmin {
filteredEndpoints = make([]portainer.Endpoint, 0) filteredEndpoints = make([]portainer.Endpoint, 0)
for _, endpoint := range endpoints { 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) filteredEndpoints = append(filteredEndpoints, endpoint)
} }
} }
@ -95,3 +97,30 @@ func FilterEndpoints(endpoints []portainer.Endpoint, context *RestrictedRequestC
return filteredEndpoints, nil 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
}

View File

@ -22,6 +22,7 @@ type Server struct {
TeamService portainer.TeamService TeamService portainer.TeamService
TeamMembershipService portainer.TeamMembershipService TeamMembershipService portainer.TeamMembershipService
EndpointService portainer.EndpointService EndpointService portainer.EndpointService
EndpointGroupService portainer.EndpointGroupService
ResourceControlService portainer.ResourceControlService ResourceControlService portainer.ResourceControlService
SettingsService portainer.SettingsService SettingsService portainer.SettingsService
CryptoService portainer.CryptoService CryptoService portainer.CryptoService
@ -72,14 +73,19 @@ func (server *Server) Start() error {
templatesHandler.SettingsService = server.SettingsService templatesHandler.SettingsService = server.SettingsService
var dockerHandler = handler.NewDockerHandler(requestBouncer) var dockerHandler = handler.NewDockerHandler(requestBouncer)
dockerHandler.EndpointService = server.EndpointService dockerHandler.EndpointService = server.EndpointService
dockerHandler.EndpointGroupService = server.EndpointGroupService
dockerHandler.TeamMembershipService = server.TeamMembershipService dockerHandler.TeamMembershipService = server.TeamMembershipService
dockerHandler.ProxyManager = proxyManager dockerHandler.ProxyManager = proxyManager
var websocketHandler = handler.NewWebSocketHandler() var websocketHandler = handler.NewWebSocketHandler()
websocketHandler.EndpointService = server.EndpointService websocketHandler.EndpointService = server.EndpointService
var endpointHandler = handler.NewEndpointHandler(requestBouncer, server.EndpointManagement) var endpointHandler = handler.NewEndpointHandler(requestBouncer, server.EndpointManagement)
endpointHandler.EndpointService = server.EndpointService endpointHandler.EndpointService = server.EndpointService
endpointHandler.EndpointGroupService = server.EndpointGroupService
endpointHandler.FileService = server.FileService endpointHandler.FileService = server.FileService
endpointHandler.ProxyManager = proxyManager endpointHandler.ProxyManager = proxyManager
var endpointGroupHandler = handler.NewEndpointGroupHandler(requestBouncer)
endpointGroupHandler.EndpointGroupService = server.EndpointGroupService
endpointGroupHandler.EndpointService = server.EndpointService
var registryHandler = handler.NewRegistryHandler(requestBouncer) var registryHandler = handler.NewRegistryHandler(requestBouncer)
registryHandler.RegistryService = server.RegistryService registryHandler.RegistryService = server.RegistryService
var dockerHubHandler = handler.NewDockerHubHandler(requestBouncer) var dockerHubHandler = handler.NewDockerHubHandler(requestBouncer)
@ -102,6 +108,7 @@ func (server *Server) Start() error {
extensionHandler.ProxyManager = proxyManager extensionHandler.ProxyManager = proxyManager
var storidgeHandler = extensions.NewStoridgeHandler(requestBouncer) var storidgeHandler = extensions.NewStoridgeHandler(requestBouncer)
storidgeHandler.EndpointService = server.EndpointService storidgeHandler.EndpointService = server.EndpointService
storidgeHandler.EndpointGroupService = server.EndpointGroupService
storidgeHandler.TeamMembershipService = server.TeamMembershipService storidgeHandler.TeamMembershipService = server.TeamMembershipService
storidgeHandler.ProxyManager = proxyManager storidgeHandler.ProxyManager = proxyManager
@ -111,6 +118,7 @@ func (server *Server) Start() error {
TeamHandler: teamHandler, TeamHandler: teamHandler,
TeamMembershipHandler: teamMembershipHandler, TeamMembershipHandler: teamMembershipHandler,
EndpointHandler: endpointHandler, EndpointHandler: endpointHandler,
EndpointGroupHandler: endpointGroupHandler,
RegistryHandler: registryHandler, RegistryHandler: registryHandler,
DockerHubHandler: dockerHubHandler, DockerHubHandler: dockerHubHandler,
ResourceHandler: resourceHandler, ResourceHandler: resourceHandler,

View File

@ -174,6 +174,7 @@ type (
ID EndpointID `json:"Id"` ID EndpointID `json:"Id"`
Name string `json:"Name"` Name string `json:"Name"`
URL string `json:"URL"` URL string `json:"URL"`
GroupID EndpointGroupID `json:"GroupId"`
PublicURL string `json:"PublicURL"` PublicURL string `json:"PublicURL"`
TLSConfig TLSConfiguration `json:"TLSConfig"` TLSConfig TLSConfiguration `json:"TLSConfig"`
AuthorizedUsers []UserID `json:"AuthorizedUsers"` AuthorizedUsers []UserID `json:"AuthorizedUsers"`
@ -188,6 +189,19 @@ type (
TLSKeyPath string `json:"TLSKey,omitempty"` 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 represents a extension associated to an endpoint.
EndpointExtension struct { EndpointExtension struct {
Type EndpointExtensionType `json:"Type"` Type EndpointExtensionType `json:"Type"`
@ -248,6 +262,7 @@ type (
// DataStore defines the interface to manage the data. // DataStore defines the interface to manage the data.
DataStore interface { DataStore interface {
Open() error Open() error
Init() error
Close() error Close() error
MigrateData() error MigrateData() error
} }
@ -301,6 +316,15 @@ type (
Synchronize(toCreate, toUpdate, toDelete []*Endpoint) error 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 represents a service for managing registry data.
RegistryService interface { RegistryService interface {
Registry(ID RegistryID) (*Registry, error) Registry(ID RegistryID) (*Registry, error)
@ -403,7 +427,7 @@ const (
// APIVersion is the version number of the Portainer API. // APIVersion is the version number of the Portainer API.
APIVersion = "1.16.5" APIVersion = "1.16.5"
// DBVersion is the version number of the Portainer database. // DBVersion is the version number of the Portainer database.
DBVersion = 8 DBVersion = 9
// DefaultTemplatesURL represents the default URL for the templates definitions. // DefaultTemplatesURL represents the default URL for the templates definitions.
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json" DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json"
) )

View File

@ -2,6 +2,7 @@ angular.module('portainer')
.constant('API_ENDPOINT_AUTH', 'api/auth') .constant('API_ENDPOINT_AUTH', 'api/auth')
.constant('API_ENDPOINT_DOCKERHUB', 'api/dockerhub') .constant('API_ENDPOINT_DOCKERHUB', 'api/dockerhub')
.constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints') .constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints')
.constant('API_ENDPOINT_ENDPOINT_GROUPS', 'api/endpoint_groups')
.constant('API_ENDPOINT_REGISTRIES', 'api/registries') .constant('API_ENDPOINT_REGISTRIES', 'api/registries')
.constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls') .constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls')
.constant('API_ENDPOINT_SETTINGS', 'api/settings') .constant('API_ENDPOINT_SETTINGS', 'api/settings')

View File

@ -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 = { var registries = {
name: 'portainer.registries', name: 'portainer.registries',
url: '/registries', url: '/registries',
@ -253,6 +297,10 @@ angular.module('portainer.app', [])
$stateRegistryProvider.register(endpoints); $stateRegistryProvider.register(endpoints);
$stateRegistryProvider.register(endpoint); $stateRegistryProvider.register(endpoint);
$stateRegistryProvider.register(endpointAccess); $stateRegistryProvider.register(endpointAccess);
$stateRegistryProvider.register(groups);
$stateRegistryProvider.register(group);
$stateRegistryProvider.register(groupAccess);
$stateRegistryProvider.register(groupCreation);
$stateRegistryProvider.register(registries); $stateRegistryProvider.register(registries);
$stateRegistryProvider.register(registry); $stateRegistryProvider.register(registry);
$stateRegistryProvider.register(registryAccess); $stateRegistryProvider.register(registryAccess);

View File

@ -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: '@'
}
});

View File

@ -0,0 +1,64 @@
<div class="datatable">
<table class="table table-hover">
<div class="col-sm-12">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search...">
</div>
<thead>
<tr>
<th>
<a ng-click="$ctrl.changeOrderBy('Name')">
Name
<span ng-show="$ctrl.state.orderBy == 'Name' && !$ctrl.state.reverseOrder" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.state.orderBy == 'Name' && $ctrl.state.reverseOrder" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('Type')">
Type
<span ng-show="$ctrl.state.orderBy == 'Type' && !$ctrl.state.reverseOrder" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.state.orderBy == 'Type' && $ctrl.state.reverseOrder" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</tr>
</thead>
<tbody>
<tr ng-click="!item.Inherited && $ctrl.entryClick(item)" ng-class="{ 'interactive': !item.Inherited }" dir-paginate="item in $ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit">
<td>
{{ item.Name }}
<!-- <span class="image-tag label label-">inherited</span> -->
<span ng-if="item.Inherited" class="text-muted small" style="margin-left: 2px;"><code style="font-size: 85% !important;">inherited</code></span>
</td>
<td>
<i class="fa" ng-class="item.Type === 'user' ? 'fa-user' : 'fa-users'" aria-hidden="true" style="margin-right: 2px;"></i>
{{ item.Type }}
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="2" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.dataset.length === 0 || ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit).length === 0">
<td colspan="2" class="text-center text-muted">{{ $ctrl.emptyDatasetMessage }}</td>
</tr>
</tbody>
</table>
<div class="footer" ng-if="$ctrl.dataset">
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<select ng-model="$ctrl.state.paginatedItemLimit">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</div>

View File

@ -3,6 +3,8 @@ angular.module('portainer.app').component('porAccessManagement', {
controller: 'porAccessManagementController', controller: 'porAccessManagementController',
bindings: { bindings: {
accessControlledEntity: '<', accessControlledEntity: '<',
inheritFrom: '<',
entityType: '@',
updateAccess: '&' updateAccess: '&'
} }
}); });

View File

@ -1,134 +1,46 @@
<div class="row"> <rd-widget>
<div class="col-sm-6"> <rd-widget-header icon="fa-users" title="Access management"></rd-widget-header>
<rd-widget> <rd-widget-body>
<rd-widget-header classes="col-sm-12 col-md-6 nopadding" icon="fa-users" title="Users and teams"> <form class="form-horizontal">
<div class="pull-md-right pull-lg-right"> <div class="small text-muted">
Items per page: <p>You can select which user or team can access this {{ $ctrl.entityType }} by moving them to the authorized accesses table. Simply click
<select ng-model="$ctrl.state.pagination_count_accesses" ng-change="$ctrl.changePaginationCountAccesses()"> on a user or team entry to move it from one table to the other.</p>
<option value="0">All</option> <p ng-if="$ctrl.inheritFrom">
<option value="10">10</option> <b>Note</b>: accesses tagged as <code>inherited</code> are inherited from the group accesses and cannot be remove at the endpoint level.
<option value="25">25</option> </p>
<option value="50">50</option> </div>
<option value="100">100</option> <div class="form-group" style="margin-top: 20px;">
</select> <!-- available-endpoints -->
</div> <div class="col-sm-6">
</rd-widget-header> <div class="text-center small text-muted">Users and teams</div>
<rd-widget-taskbar classes="col-sm-12 nopadding"> <div class="text-center small text-muted" style="margin-top: 5px;">
<div class="col-sm-12 col-md-6 nopadding"> <button class="btn btn-primary btn-sm" ng-click="$ctrl.authorizeAllAccesses()" ng-disabled="$ctrl.accesses.length === 0"><i class="fa fa-user-plus space-right" aria-hidden="true"></i>Authorize all</button>
<button class="btn btn-primary btn-sm" ng-click="$ctrl.authorizeAllAccesses()" ng-disabled="$ctrl.accesses.length === 0 || $ctrl.filteredUsers.length === 0"><i class="fa fa-user-plus space-right" aria-hidden="true"></i>Authorize all</button> </div>
</div> <div style="margin-top: 10px;">
<div class="col-sm-12 col-md-6 nopadding"> <access-table
<input type="text" id="filter" ng-model="$ctrl.state.filterUsers" placeholder="Filter..." class="form-control input-sm" /> dataset="$ctrl.accesses"
</div> entry-click="$ctrl.authorizeAccess"
</rd-widget-taskbar> empty-dataset-message="No user or team available"
<rd-widget-body classes="no-padding"> ></access-table>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>
<a ng-click="$ctrl.orderAccesses('Name')">
Name
<span ng-show="$ctrl.state.sortAccessesBy == 'Name' && !$ctrl.state.sortAccessesReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.state.sortAccessesBy == 'Name' && $ctrl.state.sortAccessesReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="$ctrl.orderAccesses('Type')">
Type
<span ng-show="$ctrl.state.sortAccessesBy == 'Type' && !$ctrl.state.sortAccessesReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.state.sortAccessesBy == 'Type' && $ctrl.state.sortAccessesReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</tr>
</thead>
<tbody>
<tr ng-click="$ctrl.authorizeAccess(user)" class="interactive" dir-paginate="user in $ctrl.accesses | filter:$ctrl.state.filterUsers | orderBy:$ctrl.state.sortAccessesBy:$ctrl.state.sortAccessesReverse | itemsPerPage: $ctrl.state.pagination_count_accesses">
<td>{{ user.Name }}</td>
<td>
<i class="fa" ng-class="user.Type === 'user' ? 'fa-user' : 'fa-users'" aria-hidden="true" style="margin-right: 2px;"></i>
{{ user.Type }}
</td>
</tr>
<tr ng-if="!$ctrl.accesses">
<td colspan="2" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.accesses.length === 0 || ($ctrl.accesses | filter:$ctrl.state.filterUsers | orderBy:$ctrl.state.sortAccessesBy:$ctrl.state.sortAccessesReverse | itemsPerPage: $ctrl.state.pagination_count_accesses).length === 0">
<td colspan="2" class="text-center text-muted">No user or team available.</td>
</tr>
</tbody>
</table>
<div ng-if="$ctrl.accesses" class="pull-left pagination-controls">
<dir-pagination-controls></dir-pagination-controls>
</div> </div>
</div> </div>
</rd-widget-body> <!-- !available-endpoints -->
</rd-widget> <!-- associated-endpoints -->
</div> <div class="col-sm-6">
<div class="col-sm-6"> <div class="text-center small text-muted">Authorized users and teams</div>
<rd-widget> <div class="text-center small text-muted" style="margin-top: 5px;">
<rd-widget-header classes="col-sm-12 col-md-6 nopadding" icon="fa-users" title="Authorized users and teams"> <button class="btn btn-primary btn-sm" ng-click="$ctrl.unauthorizeAllAccesses()" ng-disabled="$ctrl.authorizedAccesses.length === 0"><i class="fa fa-user-times space-right" aria-hidden="true"></i>Deny all</button>
<div class="pull-md-right pull-lg-right"> </div>
Items per page: <div style="margin-top: 10px;">
<select ng-model="$ctrl.state.pagination_count_authorizedAccesses" ng-change="$ctrl.changePaginationCountAuthorizedAccesses()"> <access-table
<option value="0">All</option> dataset="$ctrl.authorizedAccesses"
<option value="10">10</option> entry-click="$ctrl.unauthorizeAccess"
<option value="25">25</option> empty-dataset-message="No authorized user or team"
<option value="50">50</option> ></access-table>
<option value="100">100</option>
</select>
</div>
</rd-widget-header>
<rd-widget-taskbar classes="col-sm-12 nopadding">
<div class="col-sm-12 col-md-6 nopadding">
<button class="btn btn-primary btn-sm" ng-click="$ctrl.unauthorizeAllAccesses()" ng-disabled="$ctrl.authorizedAccesses.length === 0 || $ctrl.filteredAuthorizedUsers.length === 0"><i class="fa fa-user-times space-right" aria-hidden="true"></i>Deny all</button>
</div>
<div class="col-sm-12 col-md-6 nopadding">
<input type="text" id="filter" ng-model="$ctrl.state.filterAuthorizedUsers" placeholder="Filter..." class="form-control input-sm" />
</div>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>
<a ng-click="$ctrl.orderAuthorizedAccesses('Name')">
Name
<span ng-show="$ctrl.state.sortAuthorizedAccessesBy == 'Name' && !$ctrl.state.sortAuthorizedAccessesReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.state.sortAuthorizedAccessesBy == 'Name' && $ctrl.state.sortAuthorizedAccessesReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="$ctrl.orderAuthorizedAccesses('Type')">
Type
<span ng-show="$ctrl.state.sortAuthorizedAccessesBy == 'Type' && !$ctrl.state.sortAuthorizedAccessesReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.state.sortAuthorizedAccessesBy == 'Type' && $ctrl.state.sortAuthorizedAccessesReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</tr>
</thead>
<tbody>
<tr ng-click="$ctrl.unauthorizeAccess(user)" class="interactive" pagination-id="table_authaccess" dir-paginate="user in $ctrl.authorizedAccesses | filter:$ctrl.state.filterAuthorizedUsers | orderBy:$ctrl.state.sortAuthorizedAccessesBy:$ctrl.state.sortAuthorizedAccessesReverse | itemsPerPage: $ctrl.state.pagination_count_authorizedAccesses">
<td>{{ user.Name }}</td>
<td>
<i class="fa" ng-class="user.Type === 'user' ? 'fa-user' : 'fa-users'" aria-hidden="true" style="margin-right: 2px;"></i>
{{ user.Type }}
</td>
</tr>
<tr ng-if="!$ctrl.authorizedAccesses">
<td colspan="2" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.authorizedAccesses.length === 0 || (authorizedAccesses | filter:state.filterAuthorizedUsers | orderBy:sortTypeAuthorizedAccesses:sortReverseAuthorizedAccesses | itemsPerPage: state.pagination_count_authorizedAccesses).length === 0">
<td colspan="2" class="text-center text-muted">No authorized user or team.</td>
</tr>
</tbody>
</table>
<div ng-if="$ctrl.authorizedAccesses" class="pull-left pagination-controls">
<dir-pagination-controls pagination-id="table_authaccess"></dir-pagination-controls>
</div> </div>
</div> </div>
</rd-widget-body> <!-- !associated-endpoints -->
</rd-widget> </div>
</div> </form>
</div> </rd-widget-body>
</rd-widget>

View File

@ -1,40 +1,13 @@
angular.module('portainer.app') angular.module('portainer.app')
.controller('porAccessManagementController', ['AccessService', 'PaginationService', 'Notifications', .controller('porAccessManagementController', ['AccessService', 'Notifications',
function (AccessService, PaginationService, Notifications) { function (AccessService, Notifications) {
var ctrl = this; 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) { function dispatchUserAndTeamIDs(accesses, users, teams) {
angular.forEach(accesses, function (access) { angular.forEach(accesses, function (access) {
if (access.Type === 'user') { if (access.Type === 'user' && !access.Inherited) {
users.push(access.Id); users.push(access.Id);
} else if (access.Type === 'team') { } else if (access.Type === 'team' && !access.Inherited) {
teams.push(access.Id); 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.unauthorizeAllAccesses = function() {
ctrl.updateAccess({ userAccesses: [], teamAccesses: [] }) ctrl.updateAccess({ userAccesses: [], teamAccesses: [] })
.then(function success(data) { .then(function success(data) {
ctrl.accesses = ctrl.accesses.concat(ctrl.authorizedAccesses); moveAccesses(ctrl.authorizedAccesses, ctrl.accesses);
ctrl.authorizedAccesses = [];
Notifications.success('Accesses successfully updated'); Notifications.success('Accesses successfully updated');
}) })
.catch(function error(err) { .catch(function error(err) {
@ -130,8 +112,7 @@ function (AccessService, PaginationService, Notifications) {
ctrl.updateAccess({ userAccesses: authorizedUserIDs, teamAccesses: authorizedTeamIDs }) ctrl.updateAccess({ userAccesses: authorizedUserIDs, teamAccesses: authorizedTeamIDs })
.then(function success(data) { .then(function success(data) {
ctrl.authorizedAccesses = ctrl.authorizedAccesses.concat(ctrl.accesses); moveAccesses(ctrl.accesses, ctrl.authorizedAccesses);
ctrl.accesses = [];
Notifications.success('Accesses successfully updated'); Notifications.success('Accesses successfully updated');
}) })
.catch(function error(err) { .catch(function error(err) {
@ -141,7 +122,8 @@ function (AccessService, PaginationService, Notifications) {
function initComponent() { function initComponent() {
var entity = ctrl.accessControlledEntity; 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) { .then(function success(data) {
ctrl.accesses = data.accesses; ctrl.accesses = data.accesses;
ctrl.authorizedAccesses = data.authorizedAccesses; ctrl.authorizedAccesses = data.authorizedAccesses;

View File

@ -43,6 +43,13 @@
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'URL' && $ctrl.state.reverseOrder"></i> <i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'URL' && $ctrl.state.reverseOrder"></i>
</a> </a>
</th> </th>
<th>
<a ng-click="$ctrl.changeOrderBy('GroupName')">
Group
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'GroupName' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'GroupName' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@ -57,6 +64,7 @@
<span ng-if="!$ctrl.endpointManagement">{{ item.Name }}</span> <span ng-if="!$ctrl.endpointManagement">{{ item.Name }}</span>
</td> </td>
<td>{{ item.URL | stripprotocol }}</td> <td>{{ item.URL | stripprotocol }}</td>
<td>{{ item.GroupName }}</td>
<td> <td>
<a ui-sref="portainer.endpoints.endpoint.access({id: item.Id})" ng-if="$ctrl.accessManagement"> <a ui-sref="portainer.endpoints.endpoint.access({id: item.Id})" ng-if="$ctrl.accessManagement">
<i class="fa fa-users" aria-hidden="true"></i> Manage access <i class="fa fa-users" aria-hidden="true"></i> Manage access

View File

@ -0,0 +1,93 @@
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle">
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.title }}
</div>
<div class="settings">
<span class="setting" ng-class="{ 'setting-active': $ctrl.state.displayTextFilter }" ng-click="$ctrl.updateDisplayTextFilter()" ng-if="$ctrl.showTextFilter">
<i class="fa fa-search" aria-hidden="true"></i> Search
</span>
</div>
</div>
<div class="actionBar">
<button type="button" class="btn btn-sm btn-danger"
ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.removeAction($ctrl.state.selectedItems)">
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
</button>
<button type="button" class="btn btn-sm btn-primary" ui-sref="portainer.groups.new">
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add group
</button>
</div>
<div class="searchBar" ng-if="$ctrl.state.displayTextFilter">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
</div>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>
<span class="md-checkbox">
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" />
<label for="select_all"></label>
</span>
<a ng-click="$ctrl.changeOrderBy('Name')">
Name
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}">
<td>
<span class="md-checkbox">
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/>
<label for="select_{{ $index }}"></label>
</span>
<a ui-sref="portainer.groups.group({id: item.Id})">{{ item.Name }}</a>
</td>
<td>
<a ui-sref="portainer.groups.group.access({id: item.Id})" ng-if="$ctrl.accessManagement">
<i class="fa fa-users" aria-hidden="true"></i> Manage access
</a>
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="3" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="3" class="text-center text-muted">No group available.</td>
</tr>
</tbody>
</table>
</div>
<div class="footer" ng-if="$ctrl.dataset">
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0">
{{ $ctrl.state.selectedItemCount }} item(s) selected
</div>
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>

View File

@ -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: '<'
}
});

View File

@ -0,0 +1,9 @@
angular.module('portainer.app').component('endpointSelector', {
templateUrl: 'app/portainer/components/endpoint-selector/endpointSelector.html',
controller: 'EndpointSelectorController',
bindings: {
'endpoints': '<',
'groups': '<',
'selectEndpoint': '<'
}
});

View File

@ -0,0 +1,27 @@
<div ng-if="$ctrl.endpoints.length > 1">
<div ng-if="!$ctrl.state.show">
<li class="sidebar-title">
<span class="interactive" style="color: #fff;" ng-click="$ctrl.state.show = true;">
<span class="fa fa-plug space-right"></span>Change environment
</span>
</li>
</div>
<div ng-if="$ctrl.state.show">
<div ng-if="$ctrl.availableGroups.length > 1">
<li class="sidebar-title"><span>Group</span></li>
<li class="sidebar-title">
<select class="select-endpoint form-control" ng-options="group.Name for group in $ctrl.availableGroups" ng-model="$ctrl.state.selectedGroup" ng-change="$ctrl.selectGroup()">
<option value="">Select a group</option>
</select>
</li>
</div>
<div ng-if="$ctrl.state.selectedGroup || $ctrl.availableGroups.length <= 1">
<li class="sidebar-title"><span>Endpoint</span></li>
<li class="sidebar-title">
<select class="select-endpoint form-control" ng-options="endpoint.Name for endpoint in $ctrl.availableEndpoints" ng-model="$ctrl.state.selectedEndpoint" ng-change="$ctrl.selectEndpoint($ctrl.state.selectedEndpoint)">
<option value="">Select an endpoint</option>
</select>
</li>
</div>
</div>
</div>

View File

@ -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;
});
}
});

View File

@ -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: '<'
}
});

View File

@ -0,0 +1,120 @@
<form class="form-horizontal" name="endpointGroupForm">
<!-- name-input -->
<div class="form-group" ng-class="{ 'has-error': endpointGroupForm.group_name.$invalid }">
<label for="group_name" class="col-sm-3 col-lg-2 control-label text-left">Name</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" name="group_name" ng-model="$ctrl.model.Name" placeholder="e.g. my-group" required auto-focus>
</div>
</div>
<div class="form-group" ng-show="endpointGroupForm.group_name.$invalid">
<div class="col-sm-12 small text-danger">
<div ng-messages="endpointGroupForm.group_name.$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
</div>
</div>
</div>
<!-- !name-input -->
<!-- description-input -->
<div class="form-group">
<label for="group_description" class="col-sm-3 col-lg-2 control-label text-left">Description</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="group_description" ng-model="$ctrl.model.Description" placeholder="e.g. production environments...">
</div>
</div>
<!-- !description-input -->
<!-- labels -->
<div class="form-group">
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Labels</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="$ctrl.addLabelAction()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add label
</span>
</div>
<!-- labels-input-list -->
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="label in $ctrl.model.Labels" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
<input type="text" class="form-control" ng-model="label.name" placeholder="e.g. organization">
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. acme">
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="$ctrl.removeLabelAction($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !labels-input-list -->
</div>
<!-- !labels -->
<!-- endpoints -->
<div ng-if="$ctrl.model.Id !== 1">
<div class="col-sm-12 form-section-title">
Associated endpoints
</div>
<div class="form-group">
<div class="col-sm-12 small text-muted">
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.
</div>
<div class="col-sm-12" style="margin-top: 20px;">
<!-- available-endpoints -->
<div class="col-sm-6">
<div class="text-center small text-muted">Available endpoints</div>
<div style="margin-top: 10px;">
<group-association-table
dataset="$ctrl.availableEndpoints"
entry-click="$ctrl.associateEndpoint"
empty-dataset-message="No endpoint available"
></group-association-table>
</div>
</div>
<!-- !available-endpoints -->
<!-- associated-endpoints -->
<div class="col-sm-6">
<div class="text-center small text-muted">Associated endpoints</div>
<div style="margin-top: 10px;">
<group-association-table
dataset="$ctrl.associatedEndpoints"
entry-click="$ctrl.dissociateEndpoint"
empty-dataset-message="No associated endpoint"
></group-association-table>
</div>
</div>
<!-- !associated-endpoints -->
</div>
</div>
</div>
<div ng-if="$ctrl.model.Id === 1">
<div class="col-sm-12 form-section-title">
Unassociated endpoints
</div>
<div ng-if="$ctrl.associatedEndpoints.length > 0">
<div style="margin-top: 10px;">
<group-association-table
dataset="$ctrl.associatedEndpoints"
empty-dataset-message="No endpoint available"
></group-association-table>
</div>
</div>
<div class="col-sm-12">
<span class="text-muted small">All the endpoints are assigned to a group.</span>
</div>
</div>
<!-- !endpoints -->
<!-- actions -->
<div class="col-sm-12 form-section-title">
Actions
</div>
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-click="$ctrl.formAction()" ng-disabled="$ctrl.actionInProgress || !endpointGroupForm.$valid" button-spinner="$ctrl.actionInProgress">
<span ng-hide="$ctrl.actionInProgress">{{ $ctrl.formActionLabel }}</span>
<span ng-show="$ctrl.actionInProgress">In progress...</span>
</button>
</div>
</div>
<!-- !actions -->
</form>

View File

@ -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: '@'
}
});

View File

@ -0,0 +1,49 @@
<div class="datatable">
<table class="table table-hover">
<div class="col-sm-12">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search...">
</div>
<thead>
<tr>
<th>
<a ng-click="$ctrl.changeOrderBy('Name')">
Name
<span ng-show="$ctrl.state.orderBy == 'Name' && !$ctrl.state.reverseOrder" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.state.orderBy == 'Name' && $ctrl.state.reverseOrder" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</tr>
</thead>
<tbody>
<tr ng-click="$ctrl.entryClick(item)" class="interactive" dir-paginate="item in $ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit">
<td>{{ item.Name }}</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="2" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.dataset.length === 0 || ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit).length === 0">
<td colspan="2" class="text-center text-muted">{{ $ctrl.emptyDatasetMessage }}</td>
</tr>
</tbody>
</table>
<div class="footer" ng-if="$ctrl.dataset">
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<select ng-model="$ctrl.state.paginatedItemLimit">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</div>

View File

@ -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;
}]);

View File

@ -2,10 +2,12 @@ function UserAccessViewModel(data) {
this.Id = data.Id; this.Id = data.Id;
this.Name = data.Username; this.Name = data.Username;
this.Type = 'user'; this.Type = 'user';
this.Inherited = false;
} }
function TeamAccessViewModel(data) { function TeamAccessViewModel(data) {
this.Id = data.Id; this.Id = data.Id;
this.Name = data.Name; this.Name = data.Name;
this.Type = 'team'; this.Type = 'team';
this.Inherited = false;
} }

View File

@ -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;
}

View File

@ -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'} }
});
}]);

View File

@ -3,33 +3,30 @@ angular.module('portainer.app')
'use strict'; 'use strict';
var service = {}; var service = {};
function mapAccessDataFromAuthorizedIDs(userAccesses, teamAccesses, authorizedUserIDs, authorizedTeamIDs) { function mapAccessData(accesses, authorizedIDs, inheritedIDs) {
var accesses = []; var availableAccesses = [];
var authorizedAccesses = []; var authorizedAccesses = [];
angular.forEach(userAccesses, function(access) { for (var i = 0; i < accesses.length; i++) {
if (_.includes(authorizedUserIDs, access.Id)) {
authorizedAccesses.push(access);
} else {
accesses.push(access);
}
});
angular.forEach(teamAccesses, function(access) { var access = accesses[i];
if (_.includes(authorizedTeamIDs, access.Id)) { if (_.includes(inheritedIDs, access.Id)) {
access.Inherited = true;
authorizedAccesses.push(access);
} else if (_.includes(authorizedIDs, access.Id)) {
authorizedAccesses.push(access); authorizedAccesses.push(access);
} else { } else {
accesses.push(access); availableAccesses.push(access);
} }
}); }
return { return {
accesses: accesses, accesses: availableAccesses,
authorizedAccesses: authorizedAccesses authorizedAccesses: authorizedAccesses
}; };
} }
service.accesses = function(authorizedUserIDs, authorizedTeamIDs) { service.accesses = function(authorizedUserIDs, authorizedTeamIDs, inheritedUserIDs, inheritedTeamIDs) {
var deferred = $q.defer(); var deferred = $q.defer();
$q.all({ $q.all({
@ -44,7 +41,14 @@ angular.module('portainer.app')
return new TeamAccessViewModel(team); 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); deferred.resolve(accessData);
}) })
.catch(function error(err) { .catch(function error(err) {

View File

@ -12,6 +12,23 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) {
return Endpoints.query({}).$promise; 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) { service.updateAccess = function(id, authorizedUserIDs, authorizedTeamIDs) {
return Endpoints.updateAccess({id: id}, {authorizedUsers: authorizedUserIDs, authorizedTeams: authorizedTeamIDs}).$promise; return Endpoints.updateAccess({id: id}, {authorizedUsers: authorizedUserIDs, authorizedTeams: authorizedTeamIDs}).$promise;
}; };
@ -20,6 +37,7 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) {
var query = { var query = {
name: endpointParams.name, name: endpointParams.name,
PublicURL: endpointParams.PublicURL, PublicURL: endpointParams.PublicURL,
GroupId: endpointParams.GroupId,
TLS: endpointParams.TLS, TLS: endpointParams.TLS,
TLSSkipVerify: endpointParams.TLSSkipVerify, TLSSkipVerify: endpointParams.TLSSkipVerify,
TLSSkipClientVerify: endpointParams.TLSSkipClientVerify, TLSSkipClientVerify: endpointParams.TLSSkipClientVerify,
@ -49,10 +67,10 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) {
return Endpoints.remove({id: endpointID}).$promise; return Endpoints.remove({id: endpointID}).$promise;
}; };
service.createLocalEndpoint = function(name, URL, TLS, active) { service.createLocalEndpoint = function() {
var deferred = $q.defer(); 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) { .then(function success(response) {
deferred.resolve(response.data); deferred.resolve(response.data);
}) })
@ -63,10 +81,10 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) {
return deferred.promise; 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(); 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) { .then(function success(response) {
deferred.resolve(response.data); deferred.resolve(response.data);
}) })

View File

@ -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;
}]);

View File

@ -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({ return Upload.upload({
url: 'api/endpoints', url: 'api/endpoints',
data: { data: {
Name: name, Name: name,
URL: URL, URL: URL,
PublicURL: PublicURL, PublicURL: PublicURL,
GroupID: groupID,
TLS: TLS, TLS: TLS,
TLSSkipVerify: TLSSkipVerify, TLSSkipVerify: TLSSkipVerify,
TLSSkipClientVerify: TLSSkipClientVerify, TLSSkipClientVerify: TLSSkipClientVerify,

View File

@ -6,7 +6,7 @@
</rd-header> </rd-header>
<div class="row" ng-if="endpoint"> <div class="row" ng-if="endpoint">
<div class="col-lg-12 col-md-12 col-xs-12"> <div class="col-sm-12">
<rd-widget> <rd-widget>
<rd-widget-header icon="fa-plug" title="Endpoint"></rd-widget-header> <rd-widget-header icon="fa-plug" title="Endpoint"></rd-widget-header>
<rd-widget-body classes="no-padding"> <rd-widget-body classes="no-padding">
@ -25,11 +25,9 @@
</td> </td>
</tr> </tr>
<tr> <tr>
<td colspan="2"> <td>Group</td>
<span class="small text-muted"> <td>
You can select which user or team can access this endpoint by moving them to the authorized accesses table. Simply click <a ui-sref="portainer.groups.group({ id: group.Id })">{{ group.Name }}</a>
on a user or team entry to move it from one table to the other.
</span>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -39,5 +37,10 @@
</div> </div>
</div> </div>
<por-access-management ng-if="endpoint" access-controlled-entity="endpoint" update-access="updateAccess(userAccesses, teamAccesses)"> <div class="row" ng-if="endpoint && group">
</por-access-management> <div class="col-sm-12">
<por-access-management
access-controlled-entity="endpoint" entity-type="endpoint" inherit-from="group" update-access="updateAccess(userAccesses, teamAccesses)"
></por-access-management>
</div>
</div>

View File

@ -1,6 +1,6 @@
angular.module('portainer.app') angular.module('portainer.app')
.controller('EndpointAccessController', ['$scope', '$transition$', 'EndpointService', 'Notifications', .controller('EndpointAccessController', ['$scope', '$transition$', 'EndpointService', 'GroupService', 'Notifications',
function ($scope, $transition$, EndpointService, Notifications) { function ($scope, $transition$, EndpointService, GroupService, Notifications) {
$scope.updateAccess = function(authorizedUsers, authorizedTeams) { $scope.updateAccess = function(authorizedUsers, authorizedTeams) {
return EndpointService.updateAccess($transition$.params().id, authorizedUsers, authorizedTeams); return EndpointService.updateAccess($transition$.params().id, authorizedUsers, authorizedTeams);
@ -9,7 +9,12 @@ function ($scope, $transition$, EndpointService, Notifications) {
function initView() { function initView() {
EndpointService.endpoint($transition$.params().id) EndpointService.endpoint($transition$.params().id)
.then(function success(data) { .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) { .catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve endpoint details'); Notifications.error('Failure', err, 'Unable to retrieve endpoint details');

View File

@ -43,6 +43,19 @@
</div> </div>
</div> </div>
<!-- !endpoint-public-url-input --> <!-- !endpoint-public-url-input -->
<div class="col-sm-12 form-section-title">
Grouping
</div>
<!-- group -->
<div class="form-group">
<label for="endpoint_group" class="col-sm-3 col-lg-2 control-label text-left">
Group
</label>
<div class="col-sm-9 col-lg-10">
<select ng-options="group.Id as group.Name for group in groups" ng-model="endpoint.GroupId" id="endpoint_group" class="form-control"></select>
</div>
</div>
<!-- !group -->
<!-- endpoint-security --> <!-- endpoint-security -->
<div ng-if="endpointType === 'remote'"> <div ng-if="endpointType === 'remote'">
<div class="col-sm-12 form-section-title"> <div class="col-sm-12 form-section-title">

View File

@ -1,6 +1,6 @@
angular.module('portainer.app') angular.module('portainer.app')
.controller('EndpointController', ['$scope', '$state', '$transition$', '$filter', 'EndpointService', 'Notifications', .controller('EndpointController', ['$q', '$scope', '$state', '$transition$', '$filter', 'EndpointService', 'GroupService', 'EndpointProvider', 'Notifications',
function ($scope, $state, $transition$, $filter, EndpointService, Notifications) { function ($q, $scope, $state, $transition$, $filter, EndpointService, GroupService, EndpointProvider, Notifications) {
if (!$scope.applicationState.application.endpointManagement) { if (!$scope.applicationState.application.endpointManagement) {
$state.go('portainer.endpoints'); $state.go('portainer.endpoints');
@ -27,6 +27,7 @@ function ($scope, $state, $transition$, $filter, EndpointService, Notifications)
name: endpoint.Name, name: endpoint.Name,
URL: endpoint.URL, URL: endpoint.URL,
PublicURL: endpoint.PublicURL, PublicURL: endpoint.PublicURL,
GroupId: endpoint.GroupId,
TLS: TLS, TLS: TLS,
TLSSkipVerify: TLSSkipVerify, TLSSkipVerify: TLSSkipVerify,
TLSSkipClientVerify: TLSSkipClientVerify, TLSSkipClientVerify: TLSSkipClientVerify,
@ -40,7 +41,8 @@ function ($scope, $state, $transition$, $filter, EndpointService, Notifications)
EndpointService.updateEndpoint(endpoint.Id, endpointParams) EndpointService.updateEndpoint(endpoint.Id, endpointParams)
.then(function success(data) { .then(function success(data) {
Notifications.success('Endpoint updated', $scope.endpoint.Name); Notifications.success('Endpoint updated', $scope.endpoint.Name);
$state.go('portainer.endpoints'); EndpointProvider.setEndpointPublicURL(endpoint.PublicURL);
$state.go('portainer.endpoints', {}, {reload: true});
}, function error(err) { }, function error(err) {
Notifications.error('Failure', err, 'Unable to update endpoint'); Notifications.error('Failure', err, 'Unable to update endpoint');
$scope.state.actionInProgress = false; $scope.state.actionInProgress = false;
@ -52,9 +54,12 @@ function ($scope, $state, $transition$, $filter, EndpointService, Notifications)
}; };
function initView() { function initView() {
EndpointService.endpoint($transition$.params().id) $q.all({
endpoint: EndpointService.endpoint($transition$.params().id),
groups: GroupService.groups()
})
.then(function success(data) { .then(function success(data) {
var endpoint = data; var endpoint = data.endpoint;
if (endpoint.URL.indexOf('unix://') === 0) { if (endpoint.URL.indexOf('unix://') === 0) {
$scope.endpointType = 'local'; $scope.endpointType = 'local';
} else { } else {
@ -62,6 +67,7 @@ function ($scope, $state, $transition$, $filter, EndpointService, Notifications)
} }
endpoint.URL = $filter('stripprotocol')(endpoint.URL); endpoint.URL = $filter('stripprotocol')(endpoint.URL);
$scope.endpoint = endpoint; $scope.endpoint = endpoint;
$scope.groups = data.groups;
}) })
.catch(function error(err) { .catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve endpoint details'); Notifications.error('Failure', err, 'Unable to retrieve endpoint details');

View File

@ -59,6 +59,16 @@
</div> </div>
</div> </div>
<!-- !endpoint-public-url-input --> <!-- !endpoint-public-url-input -->
<!-- group -->
<div class="form-group">
<label for="endpoint_group" class="col-sm-3 col-lg-2 control-label text-left">
Group
</label>
<div class="col-sm-9 col-lg-10">
<select ng-options="group.Id as group.Name for group in groups" ng-model="formValues.GroupId" id="endpoint_group" class="form-control"></select>
</div>
</div>
<!-- !group -->
<!-- endpoint-security --> <!-- endpoint-security -->
<por-endpoint-security form-data="formValues.SecurityFormData"></por-endpoint-security> <por-endpoint-security form-data="formValues.SecurityFormData"></por-endpoint-security>
<!-- !endpoint-security --> <!-- !endpoint-security -->

View File

@ -1,6 +1,6 @@
angular.module('portainer.app') angular.module('portainer.app')
.controller('EndpointsController', ['$scope', '$state', '$filter', 'EndpointService', 'Notifications', .controller('EndpointsController', ['$q', '$scope', '$state', '$filter', 'EndpointService', 'GroupService', 'EndpointHelper', 'Notifications',
function ($scope, $state, $filter, EndpointService, Notifications) { function ($q, $scope, $state, $filter, EndpointService, GroupService, EndpointHelper, Notifications) {
$scope.state = { $scope.state = {
uploadInProgress: false, uploadInProgress: false,
actionInProgress: false actionInProgress: false
@ -10,6 +10,7 @@ function ($scope, $state, $filter, EndpointService, Notifications) {
Name: '', Name: '',
URL: '', URL: '',
PublicURL: '', PublicURL: '',
GroupId: 1,
SecurityFormData: new EndpointSecurityFormData() SecurityFormData: new EndpointSecurityFormData()
}; };
@ -20,6 +21,7 @@ function ($scope, $state, $filter, EndpointService, Notifications) {
if (PublicURL === '') { if (PublicURL === '') {
PublicURL = URL.split(':')[0]; PublicURL = URL.split(':')[0];
} }
var groupId = $scope.formValues.GroupId;
var securityData = $scope.formValues.SecurityFormData; var securityData = $scope.formValues.SecurityFormData;
var TLS = securityData.TLS; var TLS = securityData.TLS;
@ -31,7 +33,7 @@ function ($scope, $state, $filter, EndpointService, Notifications) {
var TLSKeyFile = TLSSkipClientVerify ? null : securityData.TLSKey; var TLSKeyFile = TLSSkipClientVerify ? null : securityData.TLSKey;
$scope.state.actionInProgress = true; $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() { .then(function success() {
Notifications.success('Endpoint created', name); Notifications.success('Endpoint created', name);
$state.reload(); $state.reload();
@ -65,16 +67,22 @@ function ($scope, $state, $filter, EndpointService, Notifications) {
}); });
}; };
function fetchEndpoints() { function initView() {
EndpointService.endpoints() $q.all({
endpoints: EndpointService.endpoints(),
groups: GroupService.groups()
})
.then(function success(data) { .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) { .catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve endpoints'); Notifications.error('Failure', err, 'Unable to load view');
$scope.endpoints = [];
}); });
} }
fetchEndpoints(); initView();
}]); }]);

View File

@ -0,0 +1,34 @@
<rd-header>
<rd-header-title title="Endpoint group access"></rd-header-title>
<rd-header-content>
<a ui-sref="portainer.groups">Groups</a> &gt; <a ui-sref="portainer.groups.group({id: group.Id})">{{ group.Name }}</a> &gt; Access management
</rd-header-content>
</rd-header>
<div class="row" ng-if="group">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-object-group" title="Group"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td>Name</td>
<td>
{{ group.Name }}
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-if="group">
<div class="col-sm-12">
<por-access-management
ng-if="group" access-controlled-entity="group" entity-type="group" update-access="updateAccess(userAccesses, teamAccesses)"
></por-access-management>
</div>
</div>

View File

@ -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();
}]);

View File

@ -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();
}]);

View File

@ -0,0 +1,25 @@
<rd-header>
<rd-header-title title="Create endpoint group"></rd-header-title>
<rd-header-content>
<a ui-sref="portainer.groups">Endpoint groups</a> &gt; Add group
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<group-form
model="model"
available-endpoints="availableEndpoints"
associated-endpoints="associatedEndpoints"
add-label-action="addLabel"
remove-label-action="removeLabel"
form-action="create"
form-action-label="Create the group"
action-in-progress="state.actionInProgress"
></group-form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@ -0,0 +1,25 @@
<rd-header>
<rd-header-title title="Endpoint group details"></rd-header-title>
<rd-header-content>
<a ui-sref="portainer.groups">Groups</a> &gt; <a ui-sref="portainer.groups.group({id: group.Id})">{{ group.Name }}</a>
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<group-form
model="group"
available-endpoints="availableEndpoints"
associated-endpoints="associatedEndpoints"
add-label-action="addLabel"
remove-label-action="removeLabel"
form-action="update"
form-action-label="Update the group"
action-in-progress="state.actionInProgress"
></group-form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@ -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();
}]);

View File

@ -0,0 +1,20 @@
<rd-header>
<rd-header-title title="Endpoint groups">
<a data-toggle="tooltip" title="Refresh" ui-sref="portainer.groups" ui-sref-opts="{reload: true}">
<i class="fa fa-sync" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>Endpoint group management</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<groups-datatable
title="Endpoint groups" title-icon="fa-object-group"
dataset="groups" table-key="groups"
order-by="Name" show-text-filter="true"
access-management="applicationState.application.authentication"
remove-action="removeAction"
></groups-datatable>
</div>
</div>

View File

@ -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();
}]);

View File

@ -31,7 +31,7 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif
var endpointID = 1; var endpointID = 1;
$scope.state.actionInProgress = true; $scope.state.actionInProgress = true;
EndpointService.createLocalEndpoint(name, URL, false, true) EndpointService.createLocalEndpoint()
.then(function success(data) { .then(function success(data) {
endpointID = data.Id; endpointID = data.Id;
EndpointProvider.setEndpointID(endpointID); EndpointProvider.setEndpointID(endpointID);

View File

@ -9,12 +9,12 @@
</div> </div>
<div class="sidebar-content"> <div class="sidebar-content">
<ul class="sidebar"> <ul class="sidebar">
<li class="sidebar-title"><span>Active endpoint</span></li> <endpoint-selector ng-if="endpoints && groups"
<li class="sidebar-title"> endpoints="endpoints"
<select class="select-endpoint form-control" ng-options="endpoint.Name for endpoint in endpoints" ng-model="activeEndpoint" ng-change="switchEndpoint(activeEndpoint)"> groups="groups"
</select> select-endpoint="switchEndpoint"
</li> ></endpoint-selector>
<li class="sidebar-title"><span>Endpoint actions</span></li> <li class="sidebar-title"><span>{{ activeEndpoint.Name }}</span></li>
<docker-sidebar-content <docker-sidebar-content
endpoint-api-version="applicationState.endpoint.apiVersion" endpoint-api-version="applicationState.endpoint.apiVersion"
swarm-management="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'" swarm-management="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'"
@ -47,6 +47,9 @@
</li> </li>
<li class="sidebar-list" ng-if="!applicationState.application.authentication || isAdmin"> <li class="sidebar-list" ng-if="!applicationState.application.authentication || isAdmin">
<a ui-sref="portainer.endpoints" ui-sref-active="active">Endpoints <span class="menu-icon fa fa-plug fa-fw"></span></a> <a ui-sref="portainer.endpoints" ui-sref-active="active">Endpoints <span class="menu-icon fa fa-plug fa-fw"></span></a>
<div class="sidebar-sublist" ng-if="toggle && ($state.current.name === 'portainer.endpoints' || $state.current.name === 'portainer.endpoints.endpoint' || $state.current.name === 'portainer.endpoints.endpoint.access' || $state.current.name === 'portainer.groups' || $state.current.name === 'portainer.groups.group' || $state.current.name === 'portainer.groups.group.access' || $state.current.name === 'portainer.groups.new')">
<a ui-sref="portainer.groups" ui-sref-active="active">Groups</a>
</div>
</li> </li>
<li class="sidebar-list" ng-if="!applicationState.application.authentication || isAdmin"> <li class="sidebar-list" ng-if="!applicationState.application.authentication || isAdmin">
<a ui-sref="portainer.registries" ui-sref-active="active">Registries <span class="menu-icon fa fa-database fa-fw"></span></a> <a ui-sref="portainer.registries" ui-sref-active="active">Registries <span class="menu-icon fa fa-database fa-fw"></span></a>

View File

@ -1,6 +1,6 @@
angular.module('portainer.app') angular.module('portainer.app')
.controller('SidebarController', ['$q', '$scope', '$state', 'Settings', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications', 'Authentication', 'UserService', 'ExtensionManager', .controller('SidebarController', ['$q', '$scope', '$state', 'EndpointService', 'GroupService', 'StateManager', 'EndpointProvider', 'Notifications', 'Authentication', 'UserService', 'ExtensionManager',
function ($q, $scope, $state, Settings, EndpointService, StateManager, EndpointProvider, Notifications, Authentication, UserService, ExtensionManager) { function ($q, $scope, $state, EndpointService, GroupService, StateManager, EndpointProvider, Notifications, Authentication, UserService, ExtensionManager) {
$scope.switchEndpoint = function(endpoint) { $scope.switchEndpoint = function(endpoint) {
var activeEndpointID = EndpointProvider.endpointID(); var activeEndpointID = EndpointProvider.endpointID();
@ -25,16 +25,6 @@ function ($q, $scope, $state, Settings, EndpointService, StateManager, EndpointP
}); });
}; };
function setActiveEndpoint(endpoints) {
var activeEndpointID = EndpointProvider.endpointID();
angular.forEach(endpoints, function (endpoint) {
if (endpoint.Id === activeEndpointID) {
$scope.activeEndpoint = endpoint;
EndpointProvider.setEndpointPublicURL(endpoint.PublicURL);
}
});
}
function checkPermissions(memberships) { function checkPermissions(memberships) {
var isLeader = false; var isLeader = false;
angular.forEach(memberships, function(membership) { angular.forEach(memberships, function(membership) {
@ -49,13 +39,24 @@ function ($q, $scope, $state, Settings, EndpointService, StateManager, EndpointP
$scope.uiVersion = StateManager.getState().application.version; $scope.uiVersion = StateManager.getState().application.version;
$scope.displayExternalContributors = StateManager.getState().application.displayExternalContributors; $scope.displayExternalContributors = StateManager.getState().application.displayExternalContributors;
$scope.logo = StateManager.getState().application.logo; $scope.logo = StateManager.getState().application.logo;
$scope.endpoints = [];
EndpointService.endpoints() $q.all({
endpoints: EndpointService.endpoints(),
groups: GroupService.groups()
})
.then(function success(data) { .then(function success(data) {
var endpoints = data; var endpoints = data.endpoints;
$scope.endpoints = _.sortBy(endpoints, ['Name']); $scope.groups = data.groups;
setActiveEndpoint(endpoints); $scope.endpoints = endpoints;
var activeEndpointID = EndpointProvider.endpointID();
for (var i = 0; i < endpoints.length; i++) {
var endpoint = endpoints[i];
if (endpoint.Id === activeEndpointID) {
$scope.activeEndpoint = endpoint;
break;
}
}
if (StateManager.getState().application.authentication) { if (StateManager.getState().application.authentication) {
var userDetails = Authentication.getUserDetails(); var userDetails = Authentication.getUserDetails();