From 5523fc9023c6056338531969f9b27b874a4da7c7 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 23 May 2017 20:56:10 +0200 Subject: [PATCH] feat(global): introduce user teams and new UAC system (#868) --- api/bolt/datastore.go | 32 +- api/bolt/internal/internal.go | 20 + api/bolt/migrate_dbversion0.go | 39 + api/bolt/migrate_dbversion1.go | 103 +++ api/bolt/{data_migration.go => migrator.go} | 55 +- api/bolt/resource_control_service.go | 148 ++++ api/bolt/resourcecontrol_service.go | 110 --- api/bolt/team_membership_service.go | 217 ++++++ api/bolt/team_service.go | 144 ++++ api/cmd/portainer/main.go | 16 +- api/{http => crypto}/tls.go | 6 +- api/errors.go | 26 +- api/http/docker_handler.go | 80 --- api/http/docker_proxy.go | 121 ---- api/http/error/error.go | 30 + api/http/{auth_handler.go => handler/auth.go} | 31 +- api/http/handler/docker.go | 94 +++ .../endpoint.go} | 137 ++-- api/http/{file_handler.go => handler/file.go} | 5 +- api/http/{ => handler}/handler.go | 62 +- api/http/handler/resource_control.go | 256 +++++++ .../settings.go} | 15 +- api/http/handler/team.go | 252 +++++++ api/http/handler/team_membership.go | 240 +++++++ .../templates.go} | 23 +- .../{upload_handler.go => handler/upload.go} | 19 +- api/http/handler/user.go | 490 +++++++++++++ .../websocket.go} | 5 +- api/http/middleware.go | 119 ---- api/http/proxy.go | 67 -- api/http/proxy/access_control.go | 21 + api/http/proxy/containers.go | 98 +++ api/http/proxy/decorator.go | 90 +++ api/http/proxy/factory.go | 55 ++ api/http/proxy/filter.go | 91 +++ api/http/proxy/manager.go | 68 ++ api/http/proxy/response.go | 90 +++ api/http/proxy/reverse_proxy.go | 46 ++ api/http/proxy/service.go | 64 ++ api/http/proxy/socket.go | 40 ++ api/http/proxy/transport.go | 237 +++++++ api/http/proxy/utils.go | 17 + api/http/proxy/volumes.go | 73 ++ api/http/proxy_transport.go | 664 ------------------ api/http/security/authorization.go | 123 ++++ api/http/security/bouncer.go | 176 +++++ api/http/security/context.go | 50 ++ api/http/security/filter.go | 95 +++ api/http/server.go | 73 +- api/http/user_handler.go | 480 ------------- api/portainer.go | 133 +++- app/app.js | 49 +- app/components/auth/authController.js | 8 +- .../accessControlForm/accessControlForm.html | 126 ++++ .../accessControlFormController.js | 55 ++ .../accessControlPanel.html | 178 +++++ .../accessControlPanelController.js | 158 +++++ app/components/container/container.html | 2 + .../container/containerController.js | 96 +-- .../containerConsoleController.js | 14 +- .../containerLogs/containerLogsController.js | 4 +- app/components/containers/containers.html | 37 +- .../containers/containersController.js | 120 +--- .../createContainerController.js | 214 +++--- .../createContainer/createcontainer.html | 29 +- .../createNetwork/createNetworkController.js | 4 +- .../createService/createServiceController.js | 104 +-- .../createService/createservice.html | 29 +- .../createVolume/createVolumeController.js | 54 +- app/components/createVolume/createvolume.html | 27 +- .../dashboard/dashboardController.js | 2 +- app/components/docker/dockerController.js | 4 +- app/components/endpoint/endpointController.js | 6 +- .../endpointAccess/endpointAccess.html | 78 +- .../endpointAccessController.js | 242 ++++--- .../endpointInit/endpointInitController.js | 6 +- .../endpoints/endpointsController.js | 8 +- app/components/events/eventsController.js | 2 +- app/components/images/imagesController.js | 8 +- app/components/network/networkController.js | 18 +- app/components/networks/networksController.js | 12 +- app/components/node/node.html | 14 +- app/components/node/nodeController.js | 13 +- app/components/service/includes/tasks.html | 14 +- app/components/service/service.html | 2 + app/components/service/serviceController.js | 74 +- app/components/services/services.html | 27 +- app/components/services/servicesController.js | 97 +-- app/components/settings/settings.html | 23 +- app/components/settings/settingsController.js | 4 +- app/components/sidebar/sidebar.html | 14 +- app/components/sidebar/sidebarController.js | 61 +- app/components/stats/statsController.js | 42 +- app/components/task/task.html | 14 +- app/components/task/taskController.js | 39 +- app/components/team/team.html | 176 +++++ app/components/team/teamController.js | 229 ++++++ app/components/teams/teams.html | 130 ++++ app/components/teams/teamsController.js | 140 ++++ app/components/templates/templates.html | 18 +- .../templates/templatesController.js | 60 +- app/components/user/user.html | 31 +- app/components/user/userController.js | 28 +- app/components/users/users.html | 50 +- app/components/users/usersController.js | 58 +- app/components/volume/volume.html | 68 ++ app/components/volume/volumeController.js | 37 + app/components/volumes/volumes.html | 43 +- app/components/volumes/volumesController.js | 117 +-- app/directives/header-content.js | 2 +- app/directives/header.js | 2 +- app/filters/filters.js | 25 + app/helpers/infoHelper.js | 14 +- app/helpers/nodeHelper.js | 4 +- app/helpers/resourceControlHelper.js | 42 ++ app/helpers/templateHelper.js | 2 +- app/helpers/userHelper.js | 15 + app/models/api/endpointAccess.js | 11 + app/models/api/resourceControl.js | 19 + app/models/api/team.js | 5 + app/models/api/teamMembership.js | 6 + app/models/{ => api}/template.js | 0 app/models/{ => api}/templateLinuxServer.js | 0 app/models/{ => api}/user.js | 6 +- app/models/{ => docker}/container.js | 5 +- app/models/docker/containerDetails.js | 15 + app/models/{ => docker}/event.js | 0 app/models/{ => docker}/image.js | 0 app/models/{ => docker}/imageDetails.js | 0 app/models/{ => docker}/node.js | 0 app/models/{ => docker}/service.js | 5 +- app/models/docker/task.js | 10 + app/models/{ => docker}/volume.js | 11 +- app/models/task.js | 15 - app/rest/endpoint.js | 2 +- app/rest/resourceControl.js | 10 +- app/rest/response/handlers.js | 4 +- app/rest/team.js | 12 + app/rest/teamMembership.js | 10 + app/rest/user.js | 10 +- app/rest/volume.js | 9 +- app/services/containerService.js | 25 +- app/services/controllerDataPipeline.js | 36 + app/services/endpointProvider.js | 13 +- app/services/endpointService.js | 10 +- app/services/formValidator.js | 24 + app/services/lineChart.js | 10 +- app/services/modalService.js | 29 +- app/services/networkService.js | 6 +- app/services/nodeService.js | 24 + app/services/resourceControlService.js | 120 +++- app/services/serviceService.js | 41 ++ app/services/taskService.js | 39 + app/services/teamMembershipService.js | 44 ++ app/services/teamService.js | 84 +++ app/services/userService.js | 121 +++- app/services/volumeService.js | 56 +- assets/css/app.css | 233 ++++-- bower.json | 5 +- gruntfile.js | 24 +- 160 files changed, 7112 insertions(+), 3166 deletions(-) create mode 100644 api/bolt/migrate_dbversion0.go create mode 100644 api/bolt/migrate_dbversion1.go rename api/bolt/{data_migration.go => migrator.go} (55%) create mode 100644 api/bolt/resource_control_service.go delete mode 100644 api/bolt/resourcecontrol_service.go create mode 100644 api/bolt/team_membership_service.go create mode 100644 api/bolt/team_service.go rename api/{http => crypto}/tls.go (77%) delete mode 100644 api/http/docker_handler.go delete mode 100644 api/http/docker_proxy.go create mode 100644 api/http/error/error.go rename api/http/{auth_handler.go => handler/auth.go} (64%) create mode 100644 api/http/handler/docker.go rename api/http/{endpoint_handler.go => handler/endpoint.go} (58%) rename api/http/{file_handler.go => handler/file.go} (84%) rename api/http/{ => handler}/handler.go (63%) create mode 100644 api/http/handler/resource_control.go rename api/http/{settings_handler.go => handler/settings.go} (58%) create mode 100644 api/http/handler/team.go create mode 100644 api/http/handler/team_membership.go rename api/http/{templates_handler.go => handler/templates.go} (57%) rename api/http/{upload_handler.go => handler/upload.go} (63%) create mode 100644 api/http/handler/user.go rename api/http/{websocket_handler.go => handler/websocket.go} (97%) delete mode 100644 api/http/middleware.go delete mode 100644 api/http/proxy.go create mode 100644 api/http/proxy/access_control.go create mode 100644 api/http/proxy/containers.go create mode 100644 api/http/proxy/decorator.go create mode 100644 api/http/proxy/factory.go create mode 100644 api/http/proxy/filter.go create mode 100644 api/http/proxy/manager.go create mode 100644 api/http/proxy/response.go create mode 100644 api/http/proxy/reverse_proxy.go create mode 100644 api/http/proxy/service.go create mode 100644 api/http/proxy/socket.go create mode 100644 api/http/proxy/transport.go create mode 100644 api/http/proxy/utils.go create mode 100644 api/http/proxy/volumes.go delete mode 100644 api/http/proxy_transport.go create mode 100644 api/http/security/authorization.go create mode 100644 api/http/security/bouncer.go create mode 100644 api/http/security/context.go create mode 100644 api/http/security/filter.go delete mode 100644 api/http/user_handler.go create mode 100644 app/components/common/accessControlForm/accessControlForm.html create mode 100644 app/components/common/accessControlForm/accessControlFormController.js create mode 100644 app/components/common/accessControlPanel/accessControlPanel.html create mode 100644 app/components/common/accessControlPanel/accessControlPanelController.js create mode 100644 app/components/team/team.html create mode 100644 app/components/team/teamController.js create mode 100644 app/components/teams/teams.html create mode 100644 app/components/teams/teamsController.js create mode 100644 app/components/volume/volume.html create mode 100644 app/components/volume/volumeController.js create mode 100644 app/helpers/resourceControlHelper.js create mode 100644 app/helpers/userHelper.js create mode 100644 app/models/api/endpointAccess.js create mode 100644 app/models/api/resourceControl.js create mode 100644 app/models/api/team.js create mode 100644 app/models/api/teamMembership.js rename app/models/{ => api}/template.js (100%) rename app/models/{ => api}/templateLinuxServer.js (100%) rename app/models/{ => api}/user.js (62%) rename app/models/{ => docker}/container.js (85%) create mode 100644 app/models/docker/containerDetails.js rename app/models/{ => docker}/event.js (100%) rename app/models/{ => docker}/image.js (100%) rename app/models/{ => docker}/imageDetails.js (100%) rename app/models/{ => docker}/node.js (100%) rename app/models/{ => docker}/service.js (95%) create mode 100644 app/models/docker/task.js rename app/models/{ => docker}/volume.js (50%) delete mode 100644 app/models/task.js create mode 100644 app/rest/team.js create mode 100644 app/rest/teamMembership.js create mode 100644 app/services/controllerDataPipeline.js create mode 100644 app/services/formValidator.js create mode 100644 app/services/nodeService.js create mode 100644 app/services/serviceService.js create mode 100644 app/services/taskService.js create mode 100644 app/services/teamMembershipService.js create mode 100644 app/services/teamService.js diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index 0b41ee306..96b179b46 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -17,6 +17,8 @@ type Store struct { // Services UserService *UserService + TeamService *TeamService + TeamMembershipService *TeamMembershipService EndpointService *EndpointService ResourceControlService *ResourceControlService VersionService *VersionService @@ -26,13 +28,13 @@ type Store struct { } const ( - databaseFileName = "portainer.db" - versionBucketName = "version" - userBucketName = "users" - endpointBucketName = "endpoints" - containerResourceControlBucketName = "containerResourceControl" - serviceResourceControlBucketName = "serviceResourceControl" - volumeResourceControlBucketName = "volumeResourceControl" + databaseFileName = "portainer.db" + versionBucketName = "version" + userBucketName = "users" + teamBucketName = "teams" + teamMembershipBucketName = "team_membership" + endpointBucketName = "endpoints" + resourceControlBucketName = "resource_control" ) // NewStore initializes a new Store and the associated services @@ -40,11 +42,15 @@ func NewStore(storePath string) (*Store, error) { store := &Store{ Path: storePath, UserService: &UserService{}, + TeamService: &TeamService{}, + TeamMembershipService: &TeamMembershipService{}, EndpointService: &EndpointService{}, ResourceControlService: &ResourceControlService{}, VersionService: &VersionService{}, } store.UserService.store = store + store.TeamService.store = store + store.TeamMembershipService.store = store store.EndpointService.store = store store.ResourceControlService.store = store store.VersionService.store = store @@ -78,19 +84,19 @@ func (store *Store) Open() error { if err != nil { return err } + _, err = tx.CreateBucketIfNotExists([]byte(teamBucketName)) + if err != nil { + return err + } _, err = tx.CreateBucketIfNotExists([]byte(endpointBucketName)) if err != nil { return err } - _, err = tx.CreateBucketIfNotExists([]byte(containerResourceControlBucketName)) + _, err = tx.CreateBucketIfNotExists([]byte(resourceControlBucketName)) if err != nil { return err } - _, err = tx.CreateBucketIfNotExists([]byte(serviceResourceControlBucketName)) - if err != nil { - return err - } - _, err = tx.CreateBucketIfNotExists([]byte(volumeResourceControlBucketName)) + _, err = tx.CreateBucketIfNotExists([]byte(teamMembershipBucketName)) if err != nil { return err } diff --git a/api/bolt/internal/internal.go b/api/bolt/internal/internal.go index 351592963..e02c26ba4 100644 --- a/api/bolt/internal/internal.go +++ b/api/bolt/internal/internal.go @@ -17,6 +17,26 @@ func UnmarshalUser(data []byte, user *portainer.User) error { return json.Unmarshal(data, user) } +// MarshalTeam encodes a team to binary format. +func MarshalTeam(team *portainer.Team) ([]byte, error) { + return json.Marshal(team) +} + +// UnmarshalTeam decodes a team from a binary data. +func UnmarshalTeam(data []byte, team *portainer.Team) error { + return json.Unmarshal(data, team) +} + +// MarshalTeamMembership encodes a team membership to binary format. +func MarshalTeamMembership(membership *portainer.TeamMembership) ([]byte, error) { + return json.Marshal(membership) +} + +// UnmarshalTeamMembership decodes a team membership from a binary data. +func UnmarshalTeamMembership(data []byte, membership *portainer.TeamMembership) error { + return json.Unmarshal(data, membership) +} + // MarshalEndpoint encodes an endpoint to binary format. func MarshalEndpoint(endpoint *portainer.Endpoint) ([]byte, error) { return json.Marshal(endpoint) diff --git a/api/bolt/migrate_dbversion0.go b/api/bolt/migrate_dbversion0.go new file mode 100644 index 000000000..f0223ee9e --- /dev/null +++ b/api/bolt/migrate_dbversion0.go @@ -0,0 +1,39 @@ +package bolt + +import ( + "github.com/boltdb/bolt" + "github.com/portainer/portainer" +) + +func (m *Migrator) updateAdminUserToDBVersion1() error { + u, err := m.UserService.UserByUsername("admin") + if err == nil { + admin := &portainer.User{ + Username: "admin", + Password: u.Password, + Role: portainer.AdministratorRole, + } + err = m.UserService.CreateUser(admin) + if err != nil { + return err + } + err = m.removeLegacyAdminUser() + if err != nil { + return err + } + } else if err != nil && err != portainer.ErrUserNotFound { + return err + } + return nil +} + +func (m *Migrator) removeLegacyAdminUser() error { + return m.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(userBucketName)) + err := bucket.Delete([]byte("admin")) + if err != nil { + return err + } + return nil + }) +} diff --git a/api/bolt/migrate_dbversion1.go b/api/bolt/migrate_dbversion1.go new file mode 100644 index 000000000..b34ba7867 --- /dev/null +++ b/api/bolt/migrate_dbversion1.go @@ -0,0 +1,103 @@ +package bolt + +import ( + "github.com/boltdb/bolt" + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" +) + +func (m *Migrator) updateResourceControlsToDBVersion2() error { + legacyResourceControls, err := m.retrieveLegacyResourceControls() + if err != nil { + return err + } + + for _, resourceControl := range legacyResourceControls { + resourceControl.SubResourceIDs = []string{} + resourceControl.TeamAccesses = []portainer.TeamResourceAccess{} + + owner, err := m.UserService.User(resourceControl.OwnerID) + if err != nil { + return err + } + + if owner.Role == portainer.AdministratorRole { + resourceControl.AdministratorsOnly = true + resourceControl.UserAccesses = []portainer.UserResourceAccess{} + } else { + resourceControl.AdministratorsOnly = false + userAccess := portainer.UserResourceAccess{ + UserID: resourceControl.OwnerID, + AccessLevel: portainer.ReadWriteAccessLevel, + } + resourceControl.UserAccesses = []portainer.UserResourceAccess{userAccess} + } + + err = m.ResourceControlService.CreateResourceControl(&resourceControl) + if err != nil { + return err + } + } + + return nil +} + +func (m *Migrator) updateEndpointsToDBVersion2() error { + legacyEndpoints, err := m.EndpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range legacyEndpoints { + endpoint.AuthorizedTeams = []portainer.TeamID{} + err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + } + + return nil +} + +func (m *Migrator) retrieveLegacyResourceControls() ([]portainer.ResourceControl, error) { + legacyResourceControls := make([]portainer.ResourceControl, 0) + err := m.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte("containerResourceControl")) + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var resourceControl portainer.ResourceControl + err := internal.UnmarshalResourceControl(v, &resourceControl) + if err != nil { + return err + } + resourceControl.Type = portainer.ContainerResourceControl + legacyResourceControls = append(legacyResourceControls, resourceControl) + } + + bucket = tx.Bucket([]byte("serviceResourceControl")) + cursor = bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var resourceControl portainer.ResourceControl + err := internal.UnmarshalResourceControl(v, &resourceControl) + if err != nil { + return err + } + resourceControl.Type = portainer.ServiceResourceControl + legacyResourceControls = append(legacyResourceControls, resourceControl) + } + + bucket = tx.Bucket([]byte("volumeResourceControl")) + cursor = bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var resourceControl portainer.ResourceControl + err := internal.UnmarshalResourceControl(v, &resourceControl) + if err != nil { + return err + } + resourceControl.Type = portainer.VolumeResourceControl + legacyResourceControls = append(legacyResourceControls, resourceControl) + } + return nil + }) + return legacyResourceControls, err +} diff --git a/api/bolt/data_migration.go b/api/bolt/migrator.go similarity index 55% rename from api/bolt/data_migration.go rename to api/bolt/migrator.go index 2cc094e24..b6c4dd4df 100644 --- a/api/bolt/data_migration.go +++ b/api/bolt/migrator.go @@ -1,10 +1,8 @@ package bolt -import ( - "github.com/boltdb/bolt" - "github.com/portainer/portainer" -) +import "github.com/portainer/portainer" +// Migrator defines a service to migrate data after a Portainer version update. type Migrator struct { UserService *UserService EndpointService *EndpointService @@ -14,6 +12,7 @@ type Migrator struct { store *Store } +// NewMigrator creates a new Migrator. func NewMigrator(store *Store, version int) *Migrator { return &Migrator{ UserService: store.UserService, @@ -25,11 +24,24 @@ func NewMigrator(store *Store, version int) *Migrator { } } +// Migrate checks the database version and migrate the existing data to the most recent data model. func (m *Migrator) Migrate() error { // Portainer < 1.12 if m.CurrentDBVersion == 0 { - err := m.updateAdminUser() + err := m.updateAdminUserToDBVersion1() + if err != nil { + return err + } + } + + // Portainer 1.12.x + if m.CurrentDBVersion == 1 { + err := m.updateResourceControlsToDBVersion2() + if err != nil { + return err + } + err = m.updateEndpointsToDBVersion2() if err != nil { return err } @@ -41,36 +53,3 @@ func (m *Migrator) Migrate() error { } return nil } - -func (m *Migrator) updateAdminUser() error { - u, err := m.UserService.UserByUsername("admin") - if err == nil { - admin := &portainer.User{ - Username: "admin", - Password: u.Password, - Role: portainer.AdministratorRole, - } - err = m.UserService.CreateUser(admin) - if err != nil { - return err - } - err = m.removeLegacyAdminUser() - if err != nil { - return err - } - } else if err != nil && err != portainer.ErrUserNotFound { - return err - } - return nil -} - -func (m *Migrator) removeLegacyAdminUser() error { - return m.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(userBucketName)) - err := bucket.Delete([]byte("admin")) - if err != nil { - return err - } - return nil - }) -} diff --git a/api/bolt/resource_control_service.go b/api/bolt/resource_control_service.go new file mode 100644 index 000000000..2986d5add --- /dev/null +++ b/api/bolt/resource_control_service.go @@ -0,0 +1,148 @@ +package bolt + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +// ResourceControlService represents a service for managing resource controls. +type ResourceControlService struct { + store *Store +} + +// ResourceControl returns a ResourceControl object by ID +func (service *ResourceControlService) ResourceControl(ID portainer.ResourceControlID) (*portainer.ResourceControl, error) { + var data []byte + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(resourceControlBucketName)) + value := bucket.Get(internal.Itob(int(ID))) + if value == nil { + return portainer.ErrResourceControlNotFound + } + + data = make([]byte, len(value)) + copy(data, value) + return nil + }) + if err != nil { + return nil, err + } + + var resourceControl portainer.ResourceControl + err = internal.UnmarshalResourceControl(data, &resourceControl) + if err != nil { + return nil, err + } + return &resourceControl, nil +} + +// ResourceControlByResourceID returns a ResourceControl object by checking if the resourceID is equal +// to the main ResourceID or in SubResourceIDs +func (service *ResourceControlService) ResourceControlByResourceID(resourceID string) (*portainer.ResourceControl, error) { + var resourceControl *portainer.ResourceControl + + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(resourceControlBucketName)) + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var rc portainer.ResourceControl + err := internal.UnmarshalResourceControl(v, &rc) + if err != nil { + return err + } + if rc.ResourceID == resourceID { + resourceControl = &rc + } + for _, subResourceID := range rc.SubResourceIDs { + if subResourceID == resourceID { + resourceControl = &rc + } + } + } + + if resourceControl == nil { + return portainer.ErrResourceControlNotFound + } + return nil + }) + if err != nil { + return nil, err + } + return resourceControl, nil +} + +// ResourceControls returns all the ResourceControl objects +func (service *ResourceControlService) ResourceControls() ([]portainer.ResourceControl, error) { + var rcs = make([]portainer.ResourceControl, 0) + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(resourceControlBucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var resourceControl portainer.ResourceControl + err := internal.UnmarshalResourceControl(v, &resourceControl) + if err != nil { + return err + } + rcs = append(rcs, resourceControl) + } + + return nil + }) + if err != nil { + return nil, err + } + + return rcs, nil +} + +// CreateResourceControl creates a new ResourceControl object +func (service *ResourceControlService) CreateResourceControl(resourceControl *portainer.ResourceControl) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(resourceControlBucketName)) + id, _ := bucket.NextSequence() + resourceControl.ID = portainer.ResourceControlID(id) + data, err := internal.MarshalResourceControl(resourceControl) + if err != nil { + return err + } + + err = bucket.Put(internal.Itob(int(resourceControl.ID)), data) + if err != nil { + return err + } + return nil + }) +} + +// UpdateResourceControl saves a ResourceControl object. +func (service *ResourceControlService) UpdateResourceControl(ID portainer.ResourceControlID, resourceControl *portainer.ResourceControl) error { + data, err := internal.MarshalResourceControl(resourceControl) + if err != nil { + return err + } + + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(resourceControlBucketName)) + err = bucket.Put(internal.Itob(int(ID)), data) + + if err != nil { + return err + } + return nil + }) +} + +// DeleteResourceControl deletes a ResourceControl object by ID +func (service *ResourceControlService) DeleteResourceControl(ID portainer.ResourceControlID) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(resourceControlBucketName)) + err := bucket.Delete(internal.Itob(int(ID))) + if err != nil { + return err + } + return nil + }) +} diff --git a/api/bolt/resourcecontrol_service.go b/api/bolt/resourcecontrol_service.go deleted file mode 100644 index 07b174616..000000000 --- a/api/bolt/resourcecontrol_service.go +++ /dev/null @@ -1,110 +0,0 @@ -package bolt - -import ( - "github.com/portainer/portainer" - "github.com/portainer/portainer/bolt/internal" - - "github.com/boltdb/bolt" -) - -// ResourceControlService represents a service for managing resource controls. -type ResourceControlService struct { - store *Store -} - -func getBucketNameByResourceControlType(rcType portainer.ResourceControlType) string { - bucketName := containerResourceControlBucketName - if rcType == portainer.ServiceResourceControl { - bucketName = serviceResourceControlBucketName - } else if rcType == portainer.VolumeResourceControl { - bucketName = volumeResourceControlBucketName - } - return bucketName -} - -// ResourceControl returns a resource control object by resource ID -func (service *ResourceControlService) ResourceControl(resourceID string, rcType portainer.ResourceControlType) (*portainer.ResourceControl, error) { - var data []byte - bucketName := getBucketNameByResourceControlType(rcType) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(bucketName)) - value := bucket.Get([]byte(resourceID)) - if value == nil { - return nil - } - - data = make([]byte, len(value)) - copy(data, value) - return nil - }) - if err != nil { - return nil, err - } - if data == nil { - return nil, nil - } - - var rc portainer.ResourceControl - err = internal.UnmarshalResourceControl(data, &rc) - if err != nil { - return nil, err - } - return &rc, nil -} - -// ResourceControls returns all resource control objects -func (service *ResourceControlService) ResourceControls(rcType portainer.ResourceControlType) ([]portainer.ResourceControl, error) { - var rcs = make([]portainer.ResourceControl, 0) - bucketName := getBucketNameByResourceControlType(rcType) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(bucketName)) - - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var rc portainer.ResourceControl - err := internal.UnmarshalResourceControl(v, &rc) - if err != nil { - return err - } - rcs = append(rcs, rc) - } - - return nil - }) - if err != nil { - return nil, err - } - - return rcs, nil -} - -// CreateResourceControl creates a new resource control -func (service *ResourceControlService) CreateResourceControl(resourceID string, rc *portainer.ResourceControl, rcType portainer.ResourceControlType) error { - bucketName := getBucketNameByResourceControlType(rcType) - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(bucketName)) - data, err := internal.MarshalResourceControl(rc) - if err != nil { - return err - } - - err = bucket.Put([]byte(resourceID), data) - if err != nil { - return err - } - return nil - }) -} - -// DeleteResourceControl deletes a resource control object by resource ID -func (service *ResourceControlService) DeleteResourceControl(resourceID string, rcType portainer.ResourceControlType) error { - bucketName := getBucketNameByResourceControlType(rcType) - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(bucketName)) - err := bucket.Delete([]byte(resourceID)) - if err != nil { - return err - } - return nil - }) -} diff --git a/api/bolt/team_membership_service.go b/api/bolt/team_membership_service.go new file mode 100644 index 000000000..da2b47266 --- /dev/null +++ b/api/bolt/team_membership_service.go @@ -0,0 +1,217 @@ +package bolt + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +// TeamMembershipService represents a service for managing TeamMembership objects. +type TeamMembershipService struct { + store *Store +} + +// TeamMembership returns a TeamMembership object by ID +func (service *TeamMembershipService) TeamMembership(ID portainer.TeamMembershipID) (*portainer.TeamMembership, error) { + var data []byte + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamMembershipBucketName)) + value := bucket.Get(internal.Itob(int(ID))) + if value == nil { + return portainer.ErrTeamMembershipNotFound + } + + data = make([]byte, len(value)) + copy(data, value) + return nil + }) + if err != nil { + return nil, err + } + + var membership portainer.TeamMembership + err = internal.UnmarshalTeamMembership(data, &membership) + if err != nil { + return nil, err + } + return &membership, nil +} + +// TeamMemberships return an array containing all the TeamMembership objects. +func (service *TeamMembershipService) TeamMemberships() ([]portainer.TeamMembership, error) { + var memberships = make([]portainer.TeamMembership, 0) + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamMembershipBucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var membership portainer.TeamMembership + err := internal.UnmarshalTeamMembership(v, &membership) + if err != nil { + return err + } + memberships = append(memberships, membership) + } + + return nil + }) + if err != nil { + return nil, err + } + + return memberships, nil +} + +// TeamMembershipsByUserID return an array containing all the TeamMembership objects where the specified userID is present. +func (service *TeamMembershipService) TeamMembershipsByUserID(userID portainer.UserID) ([]portainer.TeamMembership, error) { + var memberships = make([]portainer.TeamMembership, 0) + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamMembershipBucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var membership portainer.TeamMembership + err := internal.UnmarshalTeamMembership(v, &membership) + if err != nil { + return err + } + if membership.UserID == userID { + memberships = append(memberships, membership) + } + } + + return nil + }) + if err != nil { + return nil, err + } + + return memberships, nil +} + +// TeamMembershipsByTeamID return an array containing all the TeamMembership objects where the specified teamID is present. +func (service *TeamMembershipService) TeamMembershipsByTeamID(teamID portainer.TeamID) ([]portainer.TeamMembership, error) { + var memberships = make([]portainer.TeamMembership, 0) + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamMembershipBucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var membership portainer.TeamMembership + err := internal.UnmarshalTeamMembership(v, &membership) + if err != nil { + return err + } + if membership.TeamID == teamID { + memberships = append(memberships, membership) + } + } + + return nil + }) + if err != nil { + return nil, err + } + + return memberships, nil +} + +// UpdateTeamMembership saves a TeamMembership object. +func (service *TeamMembershipService) UpdateTeamMembership(ID portainer.TeamMembershipID, membership *portainer.TeamMembership) error { + data, err := internal.MarshalTeamMembership(membership) + if err != nil { + return err + } + + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamMembershipBucketName)) + err = bucket.Put(internal.Itob(int(ID)), data) + + if err != nil { + return err + } + return nil + }) +} + +// CreateTeamMembership creates a new TeamMembership object. +func (service *TeamMembershipService) CreateTeamMembership(membership *portainer.TeamMembership) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamMembershipBucketName)) + + id, _ := bucket.NextSequence() + membership.ID = portainer.TeamMembershipID(id) + + data, err := internal.MarshalTeamMembership(membership) + if err != nil { + return err + } + + err = bucket.Put(internal.Itob(int(membership.ID)), data) + if err != nil { + return err + } + return nil + }) +} + +// DeleteTeamMembership deletes a TeamMembership object. +func (service *TeamMembershipService) DeleteTeamMembership(ID portainer.TeamMembershipID) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamMembershipBucketName)) + err := bucket.Delete(internal.Itob(int(ID))) + if err != nil { + return err + } + return nil + }) +} + +// DeleteTeamMembershipByUserID deletes all the TeamMembership object associated to a UserID. +func (service *TeamMembershipService) DeleteTeamMembershipByUserID(userID portainer.UserID) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamMembershipBucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var membership portainer.TeamMembership + err := internal.UnmarshalTeamMembership(v, &membership) + if err != nil { + return err + } + if membership.UserID == userID { + err := bucket.Delete(internal.Itob(int(membership.ID))) + if err != nil { + return err + } + } + } + + return nil + }) +} + +// DeleteTeamMembershipByTeamID deletes all the TeamMembership object associated to a TeamID. +func (service *TeamMembershipService) DeleteTeamMembershipByTeamID(teamID portainer.TeamID) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamMembershipBucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var membership portainer.TeamMembership + err := internal.UnmarshalTeamMembership(v, &membership) + if err != nil { + return err + } + if membership.TeamID == teamID { + err := bucket.Delete(internal.Itob(int(membership.ID))) + if err != nil { + return err + } + } + } + + return nil + }) +} diff --git a/api/bolt/team_service.go b/api/bolt/team_service.go new file mode 100644 index 000000000..2830e7783 --- /dev/null +++ b/api/bolt/team_service.go @@ -0,0 +1,144 @@ +package bolt + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +// TeamService represents a service for managing teams. +type TeamService struct { + store *Store +} + +// Team returns a Team by ID +func (service *TeamService) Team(ID portainer.TeamID) (*portainer.Team, error) { + var data []byte + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamBucketName)) + value := bucket.Get(internal.Itob(int(ID))) + if value == nil { + return portainer.ErrTeamNotFound + } + + data = make([]byte, len(value)) + copy(data, value) + return nil + }) + if err != nil { + return nil, err + } + + var team portainer.Team + err = internal.UnmarshalTeam(data, &team) + if err != nil { + return nil, err + } + return &team, nil +} + +// TeamByName returns a team by name. +func (service *TeamService) TeamByName(name string) (*portainer.Team, error) { + var team *portainer.Team + + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamBucketName)) + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var t portainer.Team + err := internal.UnmarshalTeam(v, &t) + if err != nil { + return err + } + if t.Name == name { + team = &t + } + } + + if team == nil { + return portainer.ErrTeamNotFound + } + return nil + }) + if err != nil { + return nil, err + } + return team, nil +} + +// Teams return an array containing all the teams. +func (service *TeamService) Teams() ([]portainer.Team, error) { + var teams = make([]portainer.Team, 0) + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamBucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var team portainer.Team + err := internal.UnmarshalTeam(v, &team) + if err != nil { + return err + } + teams = append(teams, team) + } + + return nil + }) + if err != nil { + return nil, err + } + + return teams, nil +} + +// UpdateTeam saves a Team. +func (service *TeamService) UpdateTeam(ID portainer.TeamID, team *portainer.Team) error { + data, err := internal.MarshalTeam(team) + if err != nil { + return err + } + + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamBucketName)) + err = bucket.Put(internal.Itob(int(ID)), data) + + if err != nil { + return err + } + return nil + }) +} + +// CreateTeam creates a new Team. +func (service *TeamService) CreateTeam(team *portainer.Team) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamBucketName)) + + id, _ := bucket.NextSequence() + team.ID = portainer.TeamID(id) + + data, err := internal.MarshalTeam(team) + if err != nil { + return err + } + + err = bucket.Put(internal.Itob(int(team.ID)), data) + if err != nil { + return err + } + return nil + }) +} + +// DeleteTeam deletes a Team. +func (service *TeamService) DeleteTeam(ID portainer.TeamID) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(teamBucketName)) + err := bucket.Delete(internal.Itob(int(ID))) + if err != nil { + return err + } + return nil + }) +} diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 0743fc140..68c6183fb 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -124,12 +124,14 @@ func main() { } if len(endpoints) == 0 { endpoint := &portainer.Endpoint{ - Name: "primary", - URL: *flags.Endpoint, - TLS: *flags.TLSVerify, - TLSCACertPath: *flags.TLSCacert, - TLSCertPath: *flags.TLSCert, - TLSKeyPath: *flags.TLSKey, + Name: "primary", + URL: *flags.Endpoint, + TLS: *flags.TLSVerify, + TLSCACertPath: *flags.TLSCacert, + TLSCertPath: *flags.TLSCert, + TLSKeyPath: *flags.TLSKey, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, } err = store.EndpointService.CreateEndpoint(endpoint) if err != nil { @@ -161,6 +163,8 @@ func main() { AuthDisabled: *flags.NoAuth, EndpointManagement: authorizeEndpointMgmt, UserService: store.UserService, + TeamService: store.TeamService, + TeamMembershipService: store.TeamMembershipService, EndpointService: store.EndpointService, ResourceControlService: store.ResourceControlService, CryptoService: cryptoService, diff --git a/api/http/tls.go b/api/crypto/tls.go similarity index 77% rename from api/http/tls.go rename to api/crypto/tls.go index 20d679ef6..ff47d43dc 100644 --- a/api/http/tls.go +++ b/api/crypto/tls.go @@ -1,4 +1,4 @@ -package http +package crypto import ( "crypto/tls" @@ -6,8 +6,8 @@ import ( "io/ioutil" ) -// createTLSConfiguration initializes a tls.Config using a CA certificate, a certificate and a key -func createTLSConfiguration(caCertPath, certPath, keyPath string) (*tls.Config, error) { +// CreateTLSConfiguration initializes a tls.Config using a CA certificate, a certificate and a key +func CreateTLSConfiguration(caCertPath, certPath, keyPath string) (*tls.Config, error) { cert, err := tls.LoadX509KeyPair(certPath, keyPath) if err != nil { return nil, err diff --git a/api/errors.go b/api/errors.go index 14dbc3156..ad19b05d4 100644 --- a/api/errors.go +++ b/api/errors.go @@ -2,17 +2,39 @@ package portainer // General errors. const ( - ErrUnauthorized = Error("Unauthorized") - ErrResourceAccessDenied = Error("Access denied to resource") + ErrUnauthorized = Error("Unauthorized") + ErrResourceAccessDenied = Error("Access denied to resource") + ErrUnsupportedDockerAPI = Error("Unsupported Docker API response") + ErrMissingSecurityContext = Error("Unable to find security details in request context") ) // User errors. const ( ErrUserNotFound = Error("User not found") ErrUserAlreadyExists = Error("User already exists") + ErrInvalidUsername = Error("Invalid username. White spaces are not allowed.") ErrAdminAlreadyInitialized = Error("Admin user already initialized") ) +// Team errors. +const ( + ErrTeamNotFound = Error("Team not found") + ErrTeamAlreadyExists = Error("Team already exists") +) + +// TeamMembership errors. +const ( + ErrTeamMembershipNotFound = Error("Team membership not found") + ErrTeamMembershipAlreadyExists = Error("Team membership already exists for this user and team.") +) + +// ResourceControl errors. +const ( + ErrResourceControlNotFound = Error("Resource control not found") + ErrResourceControlAlreadyExists = Error("A resource control is already applied on this resource") + ErrInvalidResourceControlType = Error("Unsupported resource control type") +) + // Endpoint errors. const ( ErrEndpointNotFound = Error("Endpoint not found") diff --git a/api/http/docker_handler.go b/api/http/docker_handler.go deleted file mode 100644 index c50d3c23b..000000000 --- a/api/http/docker_handler.go +++ /dev/null @@ -1,80 +0,0 @@ -package http - -import ( - "strconv" - - "github.com/portainer/portainer" - - "log" - "net/http" - "os" - - "github.com/gorilla/mux" -) - -// DockerHandler represents an HTTP API handler for proxying requests to the Docker API. -type DockerHandler struct { - *mux.Router - Logger *log.Logger - EndpointService portainer.EndpointService - ProxyService *ProxyService -} - -// NewDockerHandler returns a new instance of DockerHandler. -func NewDockerHandler(mw *middleWareService, resourceControlService portainer.ResourceControlService) *DockerHandler { - h := &DockerHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.PathPrefix("/{id}/").Handler( - mw.authenticated(http.HandlerFunc(h.proxyRequestsToDockerAPI))) - return h -} - -func checkEndpointAccessControl(endpoint *portainer.Endpoint, userID portainer.UserID) bool { - for _, authorizedUserID := range endpoint.AuthorizedUsers { - if authorizedUserID == userID { - return true - } - } - return false -} - -func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - parsedID, err := strconv.Atoi(id) - if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) - return - } - - endpointID := portainer.EndpointID(parsedID) - endpoint, err := handler.EndpointService.Endpoint(endpointID) - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - tokenData, err := extractTokenDataFromRequestContext(r) - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - } - if tokenData.Role != portainer.AdministratorRole && !checkEndpointAccessControl(endpoint, tokenData.ID) { - Error(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - var proxy http.Handler - proxy = handler.ProxyService.GetProxy(string(endpointID)) - if proxy == nil { - proxy, err = handler.ProxyService.CreateAndRegisterProxy(endpoint) - if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) - return - } - } - - http.StripPrefix("/"+id, proxy).ServeHTTP(w, r) -} diff --git a/api/http/docker_proxy.go b/api/http/docker_proxy.go deleted file mode 100644 index f4644dd36..000000000 --- a/api/http/docker_proxy.go +++ /dev/null @@ -1,121 +0,0 @@ -package http - -import ( - "io" - "net" - "net/http" - "net/http/httputil" - "net/url" - "strings" - - "github.com/portainer/portainer" -) - -// ProxyFactory is a factory to create reverse proxies to Docker endpoints -type ProxyFactory struct { - ResourceControlService portainer.ResourceControlService -} - -// singleJoiningSlash from golang.org/src/net/http/httputil/reverseproxy.go -// included here for use in NewSingleHostReverseProxyWithHostHeader -// because its used in NewSingleHostReverseProxy from golang.org/src/net/http/httputil/reverseproxy.go -func singleJoiningSlash(a, b string) string { - aslash := strings.HasSuffix(a, "/") - bslash := strings.HasPrefix(b, "/") - switch { - case aslash && bslash: - return a + b[1:] - case !aslash && !bslash: - return a + "/" + b - } - return a + b -} - -// NewSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy -// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host -// HTTP header, which NewSingleHostReverseProxy deliberately preserves. -// It also adds an extra Transport to the proxy to allow Portainer to rewrite the responses. -func (factory *ProxyFactory) newSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy { - targetQuery := target.RawQuery - director := func(req *http.Request) { - req.URL.Scheme = target.Scheme - req.URL.Host = target.Host - req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path) - req.Host = req.URL.Host - if targetQuery == "" || req.URL.RawQuery == "" { - req.URL.RawQuery = targetQuery + req.URL.RawQuery - } else { - req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery - } - if _, ok := req.Header["User-Agent"]; !ok { - // explicitly disable User-Agent so it's not set to default value - req.Header.Set("User-Agent", "") - } - } - transport := &proxyTransport{ - ResourceControlService: factory.ResourceControlService, - transport: &http.Transport{}, - } - return &httputil.ReverseProxy{Director: director, Transport: transport} -} - -func (factory *ProxyFactory) newHTTPProxy(u *url.URL) http.Handler { - u.Scheme = "http" - return factory.newSingleHostReverseProxyWithHostHeader(u) -} - -func (factory *ProxyFactory) newHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) { - u.Scheme = "https" - proxy := factory.newSingleHostReverseProxyWithHostHeader(u) - config, err := createTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath) - if err != nil { - return nil, err - } - - proxy.Transport.(*proxyTransport).transport.TLSClientConfig = config - return proxy, nil -} - -func (factory *ProxyFactory) newSocketProxy(path string) http.Handler { - return &unixSocketHandler{path, &proxyTransport{ - ResourceControlService: factory.ResourceControlService, - }} -} - -// unixSocketHandler represents a handler to proxy HTTP requests via a unix:// socket -type unixSocketHandler struct { - path string - transport *proxyTransport -} - -func (h *unixSocketHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - conn, err := net.Dial("unix", h.path) - if err != nil { - Error(w, err, http.StatusInternalServerError, nil) - return - } - c := httputil.NewClientConn(conn, nil) - defer c.Close() - - res, err := c.Do(r) - if err != nil { - Error(w, err, http.StatusInternalServerError, nil) - return - } - defer res.Body.Close() - - err = h.transport.proxyDockerRequests(r, res) - if err != nil { - Error(w, err, http.StatusInternalServerError, nil) - return - } - - for k, vv := range res.Header { - for _, v := range vv { - w.Header().Add(k, v) - } - } - if _, err := io.Copy(w, res.Body); err != nil { - Error(w, err, http.StatusInternalServerError, nil) - } -} diff --git a/api/http/error/error.go b/api/http/error/error.go new file mode 100644 index 000000000..03f5220a8 --- /dev/null +++ b/api/http/error/error.go @@ -0,0 +1,30 @@ +package error + +import ( + "encoding/json" + "log" + "net/http" + "strings" +) + +// errorResponse is a generic response for sending a error. +type errorResponse struct { + Err string `json:"err,omitempty"` +} + +// WriteErrorResponse writes an error message to the response and logger. +func WriteErrorResponse(w http.ResponseWriter, err error, code int, logger *log.Logger) { + if logger != nil { + logger.Printf("http error: %s (code=%d)", err, code) + } + + w.WriteHeader(code) + json.NewEncoder(w).Encode(&errorResponse{Err: err.Error()}) +} + +// WriteMethodNotAllowedResponse writes an error message to the response and sets the Allow header. +func WriteMethodNotAllowedResponse(w http.ResponseWriter, allowedMethods []string) { + w.Header().Set("Allow", strings.Join(allowedMethods, ", ")) + w.WriteHeader(http.StatusMethodNotAllowed) + json.NewEncoder(w).Encode(&errorResponse{Err: http.StatusText(http.StatusMethodNotAllowed)}) +} diff --git a/api/http/auth_handler.go b/api/http/handler/auth.go similarity index 64% rename from api/http/auth_handler.go rename to api/http/handler/auth.go index 0eb0e7559..3b464a165 100644 --- a/api/http/auth_handler.go +++ b/api/http/handler/auth.go @@ -1,4 +1,4 @@ -package http +package handler import ( "github.com/portainer/portainer" @@ -10,6 +10,8 @@ import ( "github.com/asaskevich/govalidator" "github.com/gorilla/mux" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" ) // AuthHandler represents an HTTP API handler for managing authentication. @@ -33,37 +35,38 @@ const ( ) // NewAuthHandler returns a new instance of AuthHandler. -func NewAuthHandler(mw *middleWareService) *AuthHandler { +func NewAuthHandler(bouncer *security.RequestBouncer, authDisabled bool) *AuthHandler { h := &AuthHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + authDisabled: authDisabled, } h.Handle("/auth", - mw.public(http.HandlerFunc(h.handlePostAuth))) + bouncer.PublicAccess(http.HandlerFunc(h.handlePostAuth))) return h } func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - handleNotAllowed(w, []string{http.MethodPost}) + httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodPost}) return } if handler.authDisabled { - Error(w, ErrAuthDisabled, http.StatusServiceUnavailable, handler.Logger) + httperror.WriteErrorResponse(w, ErrAuthDisabled, http.StatusServiceUnavailable, handler.Logger) return } var req postAuthRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) return } _, err := govalidator.ValidateStruct(req) if err != nil { - Error(w, ErrInvalidCredentialsFormat, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, ErrInvalidCredentialsFormat, http.StatusBadRequest, handler.Logger) return } @@ -72,16 +75,16 @@ func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Reques u, err := handler.UserService.UserByUsername(username) if err == portainer.ErrUserNotFound { - Error(w, err, http.StatusNotFound, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) return } else if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } err = handler.CryptoService.CompareHashAndData(u.Password, password) if err != nil { - Error(w, ErrInvalidCredentials, http.StatusUnprocessableEntity, handler.Logger) + httperror.WriteErrorResponse(w, ErrInvalidCredentials, http.StatusUnprocessableEntity, handler.Logger) return } @@ -92,7 +95,7 @@ func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Reques } token, err := handler.JWTService.GenerateToken(tokenData) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } @@ -100,7 +103,7 @@ func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Reques } type postAuthRequest struct { - Username string `valid:"alphanum,required"` + Username string `valid:"required"` Password string `valid:"required"` } diff --git a/api/http/handler/docker.go b/api/http/handler/docker.go new file mode 100644 index 000000000..6c4e23636 --- /dev/null +++ b/api/http/handler/docker.go @@ -0,0 +1,94 @@ +package handler + +import ( + "strconv" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/security" + + "log" + "net/http" + "os" + + "github.com/gorilla/mux" +) + +// DockerHandler represents an HTTP API handler for proxying requests to the Docker API. +type DockerHandler struct { + *mux.Router + Logger *log.Logger + EndpointService portainer.EndpointService + TeamMembershipService portainer.TeamMembershipService + ProxyManager *proxy.Manager +} + +// NewDockerHandler returns a new instance of DockerHandler. +func NewDockerHandler(bouncer *security.RequestBouncer) *DockerHandler { + h := &DockerHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + } + h.PathPrefix("/{id}/").Handler( + bouncer.AuthenticatedAccess(http.HandlerFunc(h.proxyRequestsToDockerAPI))) + return h +} + +func (handler *DockerHandler) checkEndpointAccessControl(endpoint *portainer.Endpoint, userID portainer.UserID) bool { + for _, authorizedUserID := range endpoint.AuthorizedUsers { + if authorizedUserID == userID { + return true + } + } + + memberships, _ := handler.TeamMembershipService.TeamMembershipsByUserID(userID) + for _, authorizedTeamID := range endpoint.AuthorizedTeams { + for _, membership := range memberships { + if membership.TeamID == authorizedTeamID { + return true + } + } + } + return false +} + +func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + parsedID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + endpointID := portainer.EndpointID(parsedID) + endpoint, err := handler.EndpointService.Endpoint(endpointID) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + if tokenData.Role != portainer.AdministratorRole && !handler.checkEndpointAccessControl(endpoint, tokenData.ID) { + httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + var proxy http.Handler + proxy = handler.ProxyManager.GetProxy(string(endpointID)) + if proxy == nil { + proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + } + + http.StripPrefix("/"+id, proxy).ServeHTTP(w, r) +} diff --git a/api/http/endpoint_handler.go b/api/http/handler/endpoint.go similarity index 58% rename from api/http/endpoint_handler.go rename to api/http/handler/endpoint.go index d72c017e3..7118a9d69 100644 --- a/api/http/endpoint_handler.go +++ b/api/http/handler/endpoint.go @@ -1,7 +1,10 @@ -package http +package handler import ( "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/security" "encoding/json" "log" @@ -20,7 +23,7 @@ type EndpointHandler struct { authorizeEndpointManagement bool EndpointService portainer.EndpointService FileService portainer.FileService - ProxyService *ProxyService + ProxyManager *proxy.Manager } const ( @@ -30,78 +33,67 @@ const ( ) // NewEndpointHandler returns a new instance of EndpointHandler. -func NewEndpointHandler(mw *middleWareService) *EndpointHandler { +func NewEndpointHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bool) *EndpointHandler { h := &EndpointHandler{ Router: mux.NewRouter(), Logger: log.New(os.Stderr, "", log.LstdFlags), + authorizeEndpointManagement: authorizeEndpointManagement, } h.Handle("/endpoints", - mw.administrator(http.HandlerFunc(h.handlePostEndpoints))).Methods(http.MethodPost) + bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostEndpoints))).Methods(http.MethodPost) h.Handle("/endpoints", - mw.authenticated(http.HandlerFunc(h.handleGetEndpoints))).Methods(http.MethodGet) + bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetEndpoints))).Methods(http.MethodGet) h.Handle("/endpoints/{id}", - mw.administrator(http.HandlerFunc(h.handleGetEndpoint))).Methods(http.MethodGet) + bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetEndpoint))).Methods(http.MethodGet) h.Handle("/endpoints/{id}", - mw.administrator(http.HandlerFunc(h.handlePutEndpoint))).Methods(http.MethodPut) + bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpoint))).Methods(http.MethodPut) h.Handle("/endpoints/{id}/access", - mw.administrator(http.HandlerFunc(h.handlePutEndpointAccess))).Methods(http.MethodPut) + bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpointAccess))).Methods(http.MethodPut) h.Handle("/endpoints/{id}", - mw.administrator(http.HandlerFunc(h.handleDeleteEndpoint))).Methods(http.MethodDelete) + bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteEndpoint))).Methods(http.MethodDelete) return h } // handleGetEndpoints handles GET requests on /endpoints func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *http.Request) { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + endpoints, err := handler.EndpointService.Endpoints() if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } - tokenData, err := extractTokenDataFromRequestContext(r) + filteredEndpoints, err := security.FilterEndpoints(endpoints, securityContext) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - } - if tokenData == nil { - Error(w, portainer.ErrInvalidJWTToken, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } - var allowedEndpoints []portainer.Endpoint - if tokenData.Role != portainer.AdministratorRole { - allowedEndpoints = make([]portainer.Endpoint, 0) - for _, endpoint := range endpoints { - for _, authorizedUserID := range endpoint.AuthorizedUsers { - if authorizedUserID == tokenData.ID { - allowedEndpoints = append(allowedEndpoints, endpoint) - break - } - } - } - } else { - allowedEndpoints = endpoints - } - - encodeJSON(w, allowedEndpoints, handler.Logger) + encodeJSON(w, filteredEndpoints, handler.Logger) } // handlePostEndpoints handles POST requests on /endpoints func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *http.Request) { if !handler.authorizeEndpointManagement { - Error(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) + httperror.WriteErrorResponse(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) return } var req postEndpointsRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) return } _, err := govalidator.ValidateStruct(req) if err != nil { - Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) return } @@ -111,11 +103,12 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht PublicURL: req.PublicURL, TLS: req.TLS, AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, } err = handler.EndpointService.CreateEndpoint(endpoint) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } @@ -128,7 +121,7 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht endpoint.TLSKeyPath = keyPath err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } } @@ -154,16 +147,16 @@ func (handler *EndpointHandler) handleGetEndpoint(w http.ResponseWriter, r *http endpointID, err := strconv.Atoi(id) if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) return } endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) if err == portainer.ErrEndpointNotFound { - Error(w, err, http.StatusNotFound, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) return } else if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } @@ -177,52 +170,63 @@ func (handler *EndpointHandler) handlePutEndpointAccess(w http.ResponseWriter, r endpointID, err := strconv.Atoi(id) if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) return } var req putEndpointAccessRequest if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) return } _, err = govalidator.ValidateStruct(req) if err != nil { - Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) return } endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) if err == portainer.ErrEndpointNotFound { - Error(w, err, http.StatusNotFound, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) return } else if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } - authorizedUserIDs := []portainer.UserID{} - for _, value := range req.AuthorizedUsers { - authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value)) + if req.AuthorizedUsers != nil { + authorizedUserIDs := []portainer.UserID{} + for _, value := range req.AuthorizedUsers { + authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value)) + } + endpoint.AuthorizedUsers = authorizedUserIDs + } + + if req.AuthorizedTeams != nil { + authorizedTeamIDs := []portainer.TeamID{} + for _, value := range req.AuthorizedTeams { + authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value)) + } + endpoint.AuthorizedTeams = authorizedTeamIDs } - endpoint.AuthorizedUsers = authorizedUserIDs err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } } type putEndpointAccessRequest struct { AuthorizedUsers []int `valid:"-"` + AuthorizedTeams []int `valid:"-"` } // handlePutEndpoint handles PUT requests on /endpoints/:id func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http.Request) { if !handler.authorizeEndpointManagement { - Error(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) + httperror.WriteErrorResponse(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) return } @@ -231,28 +235,28 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http endpointID, err := strconv.Atoi(id) if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) return } var req putEndpointsRequest if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) return } _, err = govalidator.ValidateStruct(req) if err != nil { - Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) return } endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) if err == portainer.ErrEndpointNotFound { - Error(w, err, http.StatusNotFound, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) return } else if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } @@ -283,20 +287,20 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http endpoint.TLSKeyPath = "" err = handler.FileService.DeleteTLSFiles(endpoint.ID) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } } - _, err = handler.ProxyService.CreateAndRegisterProxy(endpoint) + _, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } } @@ -311,7 +315,7 @@ type putEndpointsRequest struct { // handleDeleteEndpoint handles DELETE requests on /endpoints/:id func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *http.Request) { if !handler.authorizeEndpointManagement { - Error(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) + httperror.WriteErrorResponse(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) return } @@ -320,32 +324,33 @@ func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *h endpointID, err := strconv.Atoi(id) if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) return } endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) if err == portainer.ErrEndpointNotFound { - Error(w, err, http.StatusNotFound, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) return } else if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } - handler.ProxyService.DeleteProxy(string(endpointID)) + handler.ProxyManager.DeleteProxy(string(endpointID)) err = handler.EndpointService.DeleteEndpoint(portainer.EndpointID(endpointID)) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } if endpoint.TLS { err = handler.FileService.DeleteTLSFiles(portainer.EndpointID(endpointID)) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return } } } diff --git a/api/http/file_handler.go b/api/http/handler/file.go similarity index 84% rename from api/http/file_handler.go rename to api/http/handler/file.go index 09a849b7c..008275932 100644 --- a/api/http/file_handler.go +++ b/api/http/handler/file.go @@ -1,4 +1,4 @@ -package http +package handler import ( "net/http" @@ -10,7 +10,8 @@ type FileHandler struct { http.Handler } -func newFileHandler(assetPath string) *FileHandler { +// NewFileHandler returns a new instance of FileHandler. +func NewFileHandler(assetPath string) *FileHandler { h := &FileHandler{ Handler: http.FileServer(http.Dir(assetPath)), } diff --git a/api/http/handler.go b/api/http/handler/handler.go similarity index 63% rename from api/http/handler.go rename to api/http/handler/handler.go index 7a27925c7..89a963c33 100644 --- a/api/http/handler.go +++ b/api/http/handler/handler.go @@ -1,25 +1,29 @@ -package http +package handler import ( - "github.com/portainer/portainer" - "encoding/json" "log" "net/http" "strings" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" ) // Handler is a collection of all the service handlers. type Handler struct { - AuthHandler *AuthHandler - UserHandler *UserHandler - EndpointHandler *EndpointHandler - SettingsHandler *SettingsHandler - TemplatesHandler *TemplatesHandler - DockerHandler *DockerHandler - WebSocketHandler *WebSocketHandler - UploadHandler *UploadHandler - FileHandler *FileHandler + AuthHandler *AuthHandler + UserHandler *UserHandler + TeamHandler *TeamHandler + TeamMembershipHandler *TeamMembershipHandler + EndpointHandler *EndpointHandler + ResourceHandler *ResourceHandler + SettingsHandler *SettingsHandler + TemplatesHandler *TemplatesHandler + DockerHandler *DockerHandler + WebSocketHandler *WebSocketHandler + UploadHandler *UploadHandler + FileHandler *FileHandler } const ( @@ -30,7 +34,7 @@ const ( // ErrInvalidQueryFormat defines an error raised when the data sent in the query or the URL is invalid ErrInvalidQueryFormat = portainer.Error("Invalid query format") // ErrEmptyResponseBody defines an error raised when portainer excepts to parse the body of a HTTP response and there is nothing to parse - ErrEmptyResponseBody = portainer.Error("Empty response body") + // ErrEmptyResponseBody = portainer.Error("Empty response body") ) // ServeHTTP delegates a request to the appropriate subhandler. @@ -39,8 +43,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r) } else if strings.HasPrefix(r.URL.Path, "/api/users") { http.StripPrefix("/api", h.UserHandler).ServeHTTP(w, r) + } else if strings.HasPrefix(r.URL.Path, "/api/teams") { + http.StripPrefix("/api", h.TeamHandler).ServeHTTP(w, r) + } else if strings.HasPrefix(r.URL.Path, "/api/team_memberships") { + http.StripPrefix("/api", h.TeamMembershipHandler).ServeHTTP(w, r) } else if strings.HasPrefix(r.URL.Path, "/api/endpoints") { http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r) + } else if strings.HasPrefix(r.URL.Path, "/api/resource_controls") { + http.StripPrefix("/api", h.ResourceHandler).ServeHTTP(w, r) } else if strings.HasPrefix(r.URL.Path, "/api/settings") { http.StripPrefix("/api", h.SettingsHandler).ServeHTTP(w, r) } else if strings.HasPrefix(r.URL.Path, "/api/templates") { @@ -56,33 +66,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } -// Error writes an API error message to the response and logger. -func Error(w http.ResponseWriter, err error, code int, logger *log.Logger) { - // Log error. - if logger != nil { - logger.Printf("http error: %s (code=%d)", err, code) - } - - // Write generic error response. - w.WriteHeader(code) - json.NewEncoder(w).Encode(&errorResponse{Err: err.Error()}) -} - -// errorResponse is a generic response for sending a error. -type errorResponse struct { - Err string `json:"err,omitempty"` -} - -// handleNotAllowed writes an API error message to the response and sets the Allow header. -func handleNotAllowed(w http.ResponseWriter, allowedMethods []string) { - w.Header().Set("Allow", strings.Join(allowedMethods, ", ")) - w.WriteHeader(http.StatusMethodNotAllowed) - json.NewEncoder(w).Encode(&errorResponse{Err: http.StatusText(http.StatusMethodNotAllowed)}) -} - // encodeJSON encodes v to w in JSON format. Error() is called if encoding fails. func encodeJSON(w http.ResponseWriter, v interface{}, logger *log.Logger) { if err := json.NewEncoder(w).Encode(v); err != nil { - Error(w, err, http.StatusInternalServerError, logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, logger) } } diff --git a/api/http/handler/resource_control.go b/api/http/handler/resource_control.go new file mode 100644 index 000000000..7952cfbda --- /dev/null +++ b/api/http/handler/resource_control.go @@ -0,0 +1,256 @@ +package handler + +import ( + "encoding/json" + "strconv" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" + + "log" + "net/http" + "os" + + "github.com/gorilla/mux" +) + +// ResourceHandler represents an HTTP API handler for managing resource controls. +type ResourceHandler struct { + *mux.Router + Logger *log.Logger + ResourceControlService portainer.ResourceControlService +} + +// NewResourceHandler returns a new instance of ResourceHandler. +func NewResourceHandler(bouncer *security.RequestBouncer) *ResourceHandler { + h := &ResourceHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + } + h.Handle("/resource_controls", + bouncer.RestrictedAccess(http.HandlerFunc(h.handlePostResources))).Methods(http.MethodPost) + h.Handle("/resource_controls/{id}", + bouncer.RestrictedAccess(http.HandlerFunc(h.handlePutResources))).Methods(http.MethodPut) + h.Handle("/resource_controls/{id}", + bouncer.RestrictedAccess(http.HandlerFunc(h.handleDeleteResources))).Methods(http.MethodDelete) + + return h +} + +// handlePostResources handles POST requests on /resources +func (handler *ResourceHandler) handlePostResources(w http.ResponseWriter, r *http.Request) { + var req postResourcesRequest + 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 + } + + var resourceControlType portainer.ResourceControlType + switch req.Type { + case "container": + resourceControlType = portainer.ContainerResourceControl + case "service": + resourceControlType = portainer.ServiceResourceControl + case "volume": + resourceControlType = portainer.VolumeResourceControl + default: + httperror.WriteErrorResponse(w, portainer.ErrInvalidResourceControlType, http.StatusBadRequest, handler.Logger) + return + } + + if len(req.Users) == 0 && len(req.Teams) == 0 && !req.AdministratorsOnly { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + rc, err := handler.ResourceControlService.ResourceControlByResourceID(req.ResourceID) + if err != nil && err != portainer.ErrResourceControlNotFound { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + if rc != nil { + httperror.WriteErrorResponse(w, portainer.ErrResourceControlAlreadyExists, http.StatusConflict, handler.Logger) + return + } + + var userAccesses = make([]portainer.UserResourceAccess, 0) + for _, v := range req.Users { + userAccess := portainer.UserResourceAccess{ + UserID: portainer.UserID(v), + AccessLevel: portainer.ReadWriteAccessLevel, + } + userAccesses = append(userAccesses, userAccess) + } + + var teamAccesses = make([]portainer.TeamResourceAccess, 0) + for _, v := range req.Teams { + teamAccess := portainer.TeamResourceAccess{ + TeamID: portainer.TeamID(v), + AccessLevel: portainer.ReadWriteAccessLevel, + } + teamAccesses = append(teamAccesses, teamAccess) + } + + resourceControl := portainer.ResourceControl{ + ResourceID: req.ResourceID, + SubResourceIDs: req.SubResourceIDs, + Type: resourceControlType, + AdministratorsOnly: req.AdministratorsOnly, + UserAccesses: userAccesses, + TeamAccesses: teamAccesses, + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if !security.AuthorizedResourceControlCreation(&resourceControl, securityContext) { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + err = handler.ResourceControlService.CreateResourceControl(&resourceControl) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + return +} + +type postResourcesRequest struct { + ResourceID string `valid:"required"` + Type string `valid:"required"` + AdministratorsOnly bool `valid:"-"` + Users []int `valid:"-"` + Teams []int `valid:"-"` + SubResourceIDs []string `valid:"-"` +} + +// handlePutResources handles PUT requests on /resources/:id +func (handler *ResourceHandler) handlePutResources(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + resourceControlID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + var req putResourcesRequest + 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 + } + + resourceControl, err := handler.ResourceControlService.ResourceControl(portainer.ResourceControlID(resourceControlID)) + + if err == portainer.ErrResourceControlNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + resourceControl.AdministratorsOnly = req.AdministratorsOnly + + var userAccesses = make([]portainer.UserResourceAccess, 0) + for _, v := range req.Users { + userAccess := portainer.UserResourceAccess{ + UserID: portainer.UserID(v), + AccessLevel: portainer.ReadWriteAccessLevel, + } + userAccesses = append(userAccesses, userAccess) + } + resourceControl.UserAccesses = userAccesses + + var teamAccesses = make([]portainer.TeamResourceAccess, 0) + for _, v := range req.Teams { + teamAccess := portainer.TeamResourceAccess{ + TeamID: portainer.TeamID(v), + AccessLevel: portainer.ReadWriteAccessLevel, + } + teamAccesses = append(teamAccesses, teamAccess) + } + resourceControl.TeamAccesses = teamAccesses + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if !security.AuthorizedResourceControlUpdate(resourceControl, securityContext) { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + err = handler.ResourceControlService.UpdateResourceControl(resourceControl.ID, resourceControl) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +type putResourcesRequest struct { + AdministratorsOnly bool `valid:"-"` + Users []int `valid:"-"` + Teams []int `valid:"-"` +} + +// handleDeleteResources handles DELETE requests on /resources/:id +func (handler *ResourceHandler) handleDeleteResources(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + resourceControlID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + resourceControl, err := handler.ResourceControlService.ResourceControl(portainer.ResourceControlID(resourceControlID)) + + if err == portainer.ErrResourceControlNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if !security.AuthorizedResourceControlDeletion(resourceControl, securityContext) { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + err = handler.ResourceControlService.DeleteResourceControl(portainer.ResourceControlID(resourceControlID)) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} diff --git a/api/http/settings_handler.go b/api/http/handler/settings.go similarity index 58% rename from api/http/settings_handler.go rename to api/http/handler/settings.go index db426c071..cfae1dc30 100644 --- a/api/http/settings_handler.go +++ b/api/http/handler/settings.go @@ -1,7 +1,9 @@ -package http +package handler import ( "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" "log" "net/http" @@ -18,13 +20,14 @@ type SettingsHandler struct { } // NewSettingsHandler returns a new instance of SettingsHandler. -func NewSettingsHandler(mw *middleWareService) *SettingsHandler { +func NewSettingsHandler(bouncer *security.RequestBouncer, settings *portainer.Settings) *SettingsHandler { h := &SettingsHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + settings: settings, } h.Handle("/settings", - mw.public(http.HandlerFunc(h.handleGetSettings))) + bouncer.PublicAccess(http.HandlerFunc(h.handleGetSettings))) return h } @@ -32,7 +35,7 @@ func NewSettingsHandler(mw *middleWareService) *SettingsHandler { // handleGetSettings handles GET requests on /settings func (handler *SettingsHandler) handleGetSettings(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { - handleNotAllowed(w, []string{http.MethodGet}) + httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodGet}) return } diff --git a/api/http/handler/team.go b/api/http/handler/team.go new file mode 100644 index 000000000..3f4d9fc50 --- /dev/null +++ b/api/http/handler/team.go @@ -0,0 +1,252 @@ +package handler + +import ( + "strconv" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" + + "encoding/json" + "log" + "net/http" + "os" + + "github.com/asaskevich/govalidator" + "github.com/gorilla/mux" +) + +// TeamHandler represents an HTTP API handler for managing teams. +type TeamHandler struct { + *mux.Router + Logger *log.Logger + TeamService portainer.TeamService + TeamMembershipService portainer.TeamMembershipService + ResourceControlService portainer.ResourceControlService +} + +// NewTeamHandler returns a new instance of TeamHandler. +func NewTeamHandler(bouncer *security.RequestBouncer) *TeamHandler { + h := &TeamHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + } + h.Handle("/teams", + bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostTeams))).Methods(http.MethodPost) + h.Handle("/teams", + bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetTeams))).Methods(http.MethodGet) + h.Handle("/teams/{id}", + bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeam))).Methods(http.MethodGet) + h.Handle("/teams/{id}", + bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutTeam))).Methods(http.MethodPut) + h.Handle("/teams/{id}", + bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteTeam))).Methods(http.MethodDelete) + h.Handle("/teams/{id}/memberships", + bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetMemberships))).Methods(http.MethodGet) + + return h +} + +// handlePostTeams handles POST requests on /teams +func (handler *TeamHandler) handlePostTeams(w http.ResponseWriter, r *http.Request) { + var req postTeamsRequest + 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 + } + + team, err := handler.TeamService.TeamByName(req.Name) + if err != nil && err != portainer.ErrTeamNotFound { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + if team != nil { + httperror.WriteErrorResponse(w, portainer.ErrTeamAlreadyExists, http.StatusConflict, handler.Logger) + return + } + + team = &portainer.Team{ + Name: req.Name, + } + + err = handler.TeamService.CreateTeam(team) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, &postTeamsResponse{ID: int(team.ID)}, handler.Logger) +} + +type postTeamsResponse struct { + ID int `json:"Id"` +} + +type postTeamsRequest struct { + Name string `valid:"required"` +} + +// handleGetTeams handles GET requests on /teams +func (handler *TeamHandler) handleGetTeams(w http.ResponseWriter, r *http.Request) { + teams, err := handler.TeamService.Teams() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, teams, handler.Logger) +} + +// handleGetTeam handles GET requests on /teams/:id +func (handler *TeamHandler) handleGetTeam(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + tid, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + teamID := portainer.TeamID(tid) + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if !security.AuthorizedTeamManagement(teamID, securityContext) { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + team, err := handler.TeamService.Team(teamID) + if err == portainer.ErrTeamNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, &team, handler.Logger) +} + +// handlePutTeam handles PUT requests on /teams/:id +func (handler *TeamHandler) handlePutTeam(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + teamID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + var req putTeamRequest + 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 + } + + team, err := handler.TeamService.Team(portainer.TeamID(teamID)) + if err == portainer.ErrTeamNotFound { + 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 != "" { + team.Name = req.Name + } + + err = handler.TeamService.UpdateTeam(team.ID, team) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +type putTeamRequest struct { + Name string `valid:"-"` +} + +// handleDeleteTeam handles DELETE requests on /teams/:id +func (handler *TeamHandler) handleDeleteTeam(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + teamID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + _, err = handler.TeamService.Team(portainer.TeamID(teamID)) + + if err == portainer.ErrTeamNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.TeamService.DeleteTeam(portainer.TeamID(teamID)) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.TeamMembershipService.DeleteTeamMembershipByTeamID(portainer.TeamID(teamID)) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +// handleGetMemberships handles GET requests on /teams/:id/memberships +func (handler *TeamHandler) handleGetMemberships(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + tid, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + teamID := portainer.TeamID(tid) + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if !security.AuthorizedTeamManagement(teamID, securityContext) { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + memberships, err := handler.TeamMembershipService.TeamMembershipsByTeamID(teamID) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, memberships, handler.Logger) +} diff --git a/api/http/handler/team_membership.go b/api/http/handler/team_membership.go new file mode 100644 index 000000000..e6c9075ef --- /dev/null +++ b/api/http/handler/team_membership.go @@ -0,0 +1,240 @@ +package handler + +import ( + "strconv" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" + + "encoding/json" + "log" + "net/http" + "os" + + "github.com/asaskevich/govalidator" + "github.com/gorilla/mux" +) + +// TeamMembershipHandler represents an HTTP API handler for managing teams. +type TeamMembershipHandler struct { + *mux.Router + Logger *log.Logger + TeamMembershipService portainer.TeamMembershipService + ResourceControlService portainer.ResourceControlService +} + +// NewTeamMembershipHandler returns a new instance of TeamMembershipHandler. +func NewTeamMembershipHandler(bouncer *security.RequestBouncer) *TeamMembershipHandler { + h := &TeamMembershipHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + } + h.Handle("/team_memberships", + bouncer.RestrictedAccess(http.HandlerFunc(h.handlePostTeamMemberships))).Methods(http.MethodPost) + h.Handle("/team_memberships", + bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeamsMemberships))).Methods(http.MethodGet) + h.Handle("/team_memberships/{id}", + bouncer.RestrictedAccess(http.HandlerFunc(h.handlePutTeamMembership))).Methods(http.MethodPut) + h.Handle("/team_memberships/{id}", + bouncer.RestrictedAccess(http.HandlerFunc(h.handleDeleteTeamMembership))).Methods(http.MethodDelete) + + return h +} + +// handlePostTeamMemberships handles POST requests on /team_memberships +func (handler *TeamMembershipHandler) handlePostTeamMemberships(w http.ResponseWriter, r *http.Request) { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + var req postTeamMembershipsRequest + 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 + } + + userID := portainer.UserID(req.UserID) + teamID := portainer.TeamID(req.TeamID) + role := portainer.MembershipRole(req.Role) + + if !security.AuthorizedTeamManagement(teamID, securityContext) { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(userID) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + if len(memberships) > 0 { + for _, membership := range memberships { + if membership.UserID == userID && membership.TeamID == teamID { + httperror.WriteErrorResponse(w, portainer.ErrTeamMembershipAlreadyExists, http.StatusConflict, handler.Logger) + return + } + } + } + + membership := &portainer.TeamMembership{ + UserID: userID, + TeamID: teamID, + Role: role, + } + + err = handler.TeamMembershipService.CreateTeamMembership(membership) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, &postTeamMembershipsResponse{ID: int(membership.ID)}, handler.Logger) +} + +type postTeamMembershipsResponse struct { + ID int `json:"Id"` +} + +type postTeamMembershipsRequest struct { + UserID int `valid:"required"` + TeamID int `valid:"required"` + Role int `valid:"required"` +} + +// handleGetTeamsMemberships handles GET requests on /team_memberships +func (handler *TeamMembershipHandler) handleGetTeamsMemberships(w http.ResponseWriter, r *http.Request) { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if !securityContext.IsAdmin && !securityContext.IsTeamLeader { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + memberships, err := handler.TeamMembershipService.TeamMemberships() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, memberships, handler.Logger) +} + +// handlePutTeamMembership handles PUT requests on /team_memberships/:id +func (handler *TeamMembershipHandler) handlePutTeamMembership(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + membershipID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + var req putTeamMembershipRequest + 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 + } + + userID := portainer.UserID(req.UserID) + teamID := portainer.TeamID(req.TeamID) + role := portainer.MembershipRole(req.Role) + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if !security.AuthorizedTeamManagement(teamID, securityContext) { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + membership, err := handler.TeamMembershipService.TeamMembership(portainer.TeamMembershipID(membershipID)) + if err == portainer.ErrTeamMembershipNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if securityContext.IsTeamLeader && membership.Role != role { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + membership.UserID = userID + membership.TeamID = teamID + membership.Role = role + + err = handler.TeamMembershipService.UpdateTeamMembership(membership.ID, membership) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +type putTeamMembershipRequest struct { + UserID int `valid:"required"` + TeamID int `valid:"required"` + Role int `valid:"required"` +} + +// handleDeleteTeamMembership handles DELETE requests on /team_memberships/:id +func (handler *TeamMembershipHandler) handleDeleteTeamMembership(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + membershipID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + membership, err := handler.TeamMembershipService.TeamMembership(portainer.TeamMembershipID(membershipID)) + if err == portainer.ErrTeamMembershipNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if !security.AuthorizedTeamManagement(membership.TeamID, securityContext) { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + err = handler.TeamMembershipService.DeleteTeamMembership(portainer.TeamMembershipID(membershipID)) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} diff --git a/api/http/templates_handler.go b/api/http/handler/templates.go similarity index 57% rename from api/http/templates_handler.go rename to api/http/handler/templates.go index be994ddc2..9383e407e 100644 --- a/api/http/templates_handler.go +++ b/api/http/handler/templates.go @@ -1,4 +1,4 @@ -package http +package handler import ( "io/ioutil" @@ -7,6 +7,8 @@ import ( "os" "github.com/gorilla/mux" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" ) // TemplatesHandler represents an HTTP API handler for managing templates. @@ -21,26 +23,27 @@ const ( ) // NewTemplatesHandler returns a new instance of TemplatesHandler. -func NewTemplatesHandler(mw *middleWareService) *TemplatesHandler { +func NewTemplatesHandler(bouncer *security.RequestBouncer, containerTemplatesURL string) *TemplatesHandler { h := &TemplatesHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + containerTemplatesURL: containerTemplatesURL, } h.Handle("/templates", - mw.authenticated(http.HandlerFunc(h.handleGetTemplates))) + bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetTemplates))) return h } // handleGetTemplates handles GET requests on /templates?key= func (handler *TemplatesHandler) handleGetTemplates(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { - handleNotAllowed(w, []string{http.MethodGet}) + httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodGet}) return } key := r.FormValue("key") if key == "" { - Error(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) return } @@ -50,19 +53,19 @@ func (handler *TemplatesHandler) handleGetTemplates(w http.ResponseWriter, r *ht } else if key == "linuxserver.io" { templatesURL = containerTemplatesURLLinuxServerIo } else { - Error(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) return } resp, err := http.Get(templatesURL) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } w.Header().Set("Content-Type", "application/json") diff --git a/api/http/upload_handler.go b/api/http/handler/upload.go similarity index 63% rename from api/http/upload_handler.go rename to api/http/handler/upload.go index a89bf03a4..d96a45c5a 100644 --- a/api/http/upload_handler.go +++ b/api/http/handler/upload.go @@ -1,7 +1,9 @@ -package http +package handler import ( "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" "log" "net/http" @@ -19,19 +21,19 @@ type UploadHandler struct { } // NewUploadHandler returns a new instance of UploadHandler. -func NewUploadHandler(mw *middleWareService) *UploadHandler { +func NewUploadHandler(bouncer *security.RequestBouncer) *UploadHandler { h := &UploadHandler{ Router: mux.NewRouter(), Logger: log.New(os.Stderr, "", log.LstdFlags), } h.Handle("/upload/tls/{endpointID}/{certificate:(?:ca|cert|key)}", - mw.authenticated(http.HandlerFunc(h.handlePostUploadTLS))) + bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePostUploadTLS))) return h } func (handler *UploadHandler) handlePostUploadTLS(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - handleNotAllowed(w, []string{http.MethodPost}) + httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodPost}) return } @@ -40,14 +42,14 @@ func (handler *UploadHandler) handlePostUploadTLS(w http.ResponseWriter, r *http certificate := vars["certificate"] ID, err := strconv.Atoi(endpointID) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } file, _, err := r.FormFile("file") defer file.Close() if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } @@ -60,12 +62,13 @@ func (handler *UploadHandler) handlePostUploadTLS(w http.ResponseWriter, r *http case "key": fileType = portainer.TLSFileKey default: - Error(w, portainer.ErrUndefinedTLSFileType, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, portainer.ErrUndefinedTLSFileType, http.StatusInternalServerError, handler.Logger) return } err = handler.FileService.StoreTLSFile(portainer.EndpointID(ID), fileType, file) if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return } } diff --git a/api/http/handler/user.go b/api/http/handler/user.go new file mode 100644 index 000000000..44c15495e --- /dev/null +++ b/api/http/handler/user.go @@ -0,0 +1,490 @@ +package handler + +import ( + "strconv" + "strings" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" + + "encoding/json" + "log" + "net/http" + "os" + + "github.com/asaskevich/govalidator" + "github.com/gorilla/mux" +) + +// UserHandler represents an HTTP API handler for managing users. +type UserHandler struct { + *mux.Router + Logger *log.Logger + UserService portainer.UserService + TeamService portainer.TeamService + TeamMembershipService portainer.TeamMembershipService + ResourceControlService portainer.ResourceControlService + CryptoService portainer.CryptoService +} + +// NewUserHandler returns a new instance of UserHandler. +func NewUserHandler(bouncer *security.RequestBouncer) *UserHandler { + h := &UserHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + } + h.Handle("/users", + bouncer.RestrictedAccess(http.HandlerFunc(h.handlePostUsers))).Methods(http.MethodPost) + h.Handle("/users", + bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetUsers))).Methods(http.MethodGet) + h.Handle("/users/{id}", + bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetUser))).Methods(http.MethodGet) + h.Handle("/users/{id}", + bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePutUser))).Methods(http.MethodPut) + h.Handle("/users/{id}", + bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteUser))).Methods(http.MethodDelete) + h.Handle("/users/{id}/memberships", + bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetMemberships))).Methods(http.MethodGet) + h.Handle("/users/{id}/teams", + bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeams))).Methods(http.MethodGet) + h.Handle("/users/{id}/passwd", + bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePostUserPasswd))) + h.Handle("/users/admin/check", + bouncer.PublicAccess(http.HandlerFunc(h.handleGetAdminCheck))) + h.Handle("/users/admin/init", + bouncer.PublicAccess(http.HandlerFunc(h.handlePostAdminInit))) + + return h +} + +// handlePostUsers handles POST requests on /users +func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Request) { + var req postUsersRequest + 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 + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if !securityContext.IsAdmin && !securityContext.IsTeamLeader { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) + return + } + + if securityContext.IsTeamLeader && req.Role == 1 { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) + return + } + + if strings.ContainsAny(req.Username, " ") { + httperror.WriteErrorResponse(w, portainer.ErrInvalidUsername, http.StatusBadRequest, handler.Logger) + return + } + + var role portainer.UserRole + if req.Role == 1 { + role = portainer.AdministratorRole + } else { + role = portainer.StandardUserRole + } + + user, err := handler.UserService.UserByUsername(req.Username) + if err != nil && err != portainer.ErrUserNotFound { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + if user != nil { + httperror.WriteErrorResponse(w, portainer.ErrUserAlreadyExists, http.StatusConflict, handler.Logger) + return + } + + user = &portainer.User{ + Username: req.Username, + Role: role, + } + user.Password, err = handler.CryptoService.Hash(req.Password) + if err != nil { + httperror.WriteErrorResponse(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) + return + } + + err = handler.UserService.CreateUser(user) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, &postUsersResponse{ID: int(user.ID)}, handler.Logger) +} + +type postUsersResponse struct { + ID int `json:"Id"` +} + +type postUsersRequest struct { + Username string `valid:"required"` + Password string `valid:"required"` + Role int `valid:"required"` +} + +// handleGetUsers handles GET requests on /users +func (handler *UserHandler) handleGetUsers(w http.ResponseWriter, r *http.Request) { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + users, err := handler.UserService.Users() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + filteredUsers := security.FilterUsers(users, securityContext) + + for i := range filteredUsers { + filteredUsers[i].Password = "" + } + + encodeJSON(w, filteredUsers, handler.Logger) +} + +// handlePostUserPasswd handles POST requests on /users/:id/passwd +func (handler *UserHandler) handlePostUserPasswd(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodPost}) + return + } + + vars := mux.Vars(r) + id := vars["id"] + + userID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + var req postUserPasswdRequest + 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 + } + + var password = req.Password + + u, err := handler.UserService.User(portainer.UserID(userID)) + if err == portainer.ErrUserNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + valid := true + err = handler.CryptoService.CompareHashAndData(u.Password, password) + if err != nil { + valid = false + } + + encodeJSON(w, &postUserPasswdResponse{Valid: valid}, handler.Logger) +} + +type postUserPasswdRequest struct { + Password string `valid:"required"` +} + +type postUserPasswdResponse struct { + Valid bool `json:"valid"` +} + +// handleGetUser handles GET requests on /users/:id +func (handler *UserHandler) handleGetUser(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + userID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + user, err := handler.UserService.User(portainer.UserID(userID)) + if err == portainer.ErrUserNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + user.Password = "" + encodeJSON(w, &user, handler.Logger) +} + +// handlePutUser handles PUT requests on /users/:id +func (handler *UserHandler) handlePutUser(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + userID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { + httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger) + return + } + + var req putUserRequest + 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 + } + + if req.Password == "" && req.Role == 0 { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + user, err := handler.UserService.User(portainer.UserID(userID)) + if err == portainer.ErrUserNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if req.Password != "" { + user.Password, err = handler.CryptoService.Hash(req.Password) + if err != nil { + httperror.WriteErrorResponse(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) + return + } + } + + if req.Role != 0 { + if tokenData.Role != portainer.AdministratorRole { + httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger) + return + } + if req.Role == 1 { + user.Role = portainer.AdministratorRole + } else { + user.Role = portainer.StandardUserRole + } + } + + err = handler.UserService.UpdateUser(user.ID, user) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +type putUserRequest struct { + Password string `valid:"-"` + Role int `valid:"-"` +} + +// handlePostAdminInit handles GET requests on /users/admin/check +func (handler *UserHandler) handleGetAdminCheck(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodGet}) + return + } + + users, err := handler.UserService.UsersByRole(portainer.AdministratorRole) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + if len(users) == 0 { + httperror.WriteErrorResponse(w, portainer.ErrUserNotFound, http.StatusNotFound, handler.Logger) + return + } +} + +// handlePostAdminInit handles POST requests on /users/admin/init +func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodPost}) + return + } + + var req postAdminInitRequest + 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 + } + + user, err := handler.UserService.UserByUsername("admin") + if err == portainer.ErrUserNotFound { + user := &portainer.User{ + Username: "admin", + Role: portainer.AdministratorRole, + } + user.Password, err = handler.CryptoService.Hash(req.Password) + if err != nil { + httperror.WriteErrorResponse(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) + return + } + + err = handler.UserService.CreateUser(user) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + if user != nil { + httperror.WriteErrorResponse(w, portainer.ErrAdminAlreadyInitialized, http.StatusForbidden, handler.Logger) + return + } +} + +type postAdminInitRequest struct { + Password string `valid:"required"` +} + +// handleDeleteUser handles DELETE requests on /users/:id +func (handler *UserHandler) handleDeleteUser(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + userID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + _, err = handler.UserService.User(portainer.UserID(userID)) + + if err == portainer.ErrUserNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.UserService.DeleteUser(portainer.UserID(userID)) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.TeamMembershipService.DeleteTeamMembershipByUserID(portainer.UserID(userID)) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +// handleGetMemberships handles GET requests on /users/:id/memberships +func (handler *UserHandler) handleGetMemberships(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + userID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { + httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger) + return + } + + memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(portainer.UserID(userID)) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, memberships, handler.Logger) +} + +// handleGetTeams handles GET requests on /users/:id/teams +func (handler *UserHandler) handleGetTeams(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + uid, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + userID := portainer.UserID(uid) + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if !security.AuthorizedUserManagement(userID, securityContext) { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + teams, err := handler.TeamService.Teams() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + filteredTeams := security.FilterUserTeams(teams, securityContext) + + encodeJSON(w, filteredTeams, handler.Logger) +} diff --git a/api/http/websocket_handler.go b/api/http/handler/websocket.go similarity index 97% rename from api/http/websocket_handler.go rename to api/http/handler/websocket.go index 126fcbed0..39f626a99 100644 --- a/api/http/websocket_handler.go +++ b/api/http/handler/websocket.go @@ -1,4 +1,4 @@ -package http +package handler import ( "bytes" @@ -17,6 +17,7 @@ import ( "github.com/gorilla/mux" "github.com/portainer/portainer" + "github.com/portainer/portainer/crypto" "golang.org/x/net/websocket" ) @@ -71,7 +72,7 @@ func (handler *WebSocketHandler) webSocketDockerExec(ws *websocket.Conn) { // Should not be managed here var tlsConfig *tls.Config if endpoint.TLS { - tlsConfig, err = createTLSConfiguration(endpoint.TLSCACertPath, + tlsConfig, err = crypto.CreateTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath) if err != nil { diff --git a/api/http/middleware.go b/api/http/middleware.go deleted file mode 100644 index 4221a61f4..000000000 --- a/api/http/middleware.go +++ /dev/null @@ -1,119 +0,0 @@ -package http - -import ( - "context" - - "github.com/portainer/portainer" - - "net/http" - "strings" -) - -type ( - // middleWareService represents a service to manage HTTP middlewares - middleWareService struct { - jwtService portainer.JWTService - authDisabled bool - } - contextKey int -) - -const ( - contextAuthenticationKey contextKey = iota -) - -func extractTokenDataFromRequestContext(request *http.Request) (*portainer.TokenData, error) { - contextData := request.Context().Value(contextAuthenticationKey) - if contextData == nil { - return nil, portainer.ErrMissingContextData - } - - tokenData := contextData.(*portainer.TokenData) - return tokenData, nil -} - -// public defines a chain of middleware for public endpoints (no authentication required) -func (service *middleWareService) public(h http.Handler) http.Handler { - h = mwSecureHeaders(h) - return h -} - -// authenticated defines a chain of middleware for private endpoints (authentication required) -func (service *middleWareService) authenticated(h http.Handler) http.Handler { - h = service.mwCheckAuthentication(h) - h = mwSecureHeaders(h) - return h -} - -// administrator defines a chain of middleware for private administrator restricted endpoints -// (authentication and role admin required) -func (service *middleWareService) administrator(h http.Handler) http.Handler { - h = mwCheckAdministratorRole(h) - h = service.mwCheckAuthentication(h) - h = mwSecureHeaders(h) - return h -} - -// mwSecureHeaders provides secure headers middleware for handlers -func mwSecureHeaders(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Add("X-Content-Type-Options", "nosniff") - w.Header().Add("X-Frame-Options", "DENY") - next.ServeHTTP(w, r) - }) -} - -// mwCheckAdministratorRole check the role of the user associated to the request -func mwCheckAdministratorRole(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - tokenData, err := extractTokenDataFromRequestContext(r) - if err != nil { - Error(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) - return - } - - if tokenData.Role != portainer.AdministratorRole { - Error(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) - return - } - - next.ServeHTTP(w, r) - }) -} - -// mwCheckAuthentication provides Authentication middleware for handlers -func (service *middleWareService) mwCheckAuthentication(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var tokenData *portainer.TokenData - if !service.authDisabled { - var token string - - // Get token from the Authorization header - tokens, ok := r.Header["Authorization"] - if ok && len(tokens) >= 1 { - token = tokens[0] - token = strings.TrimPrefix(token, "Bearer ") - } - - if token == "" { - Error(w, portainer.ErrUnauthorized, http.StatusUnauthorized, nil) - return - } - - var err error - tokenData, err = service.jwtService.ParseAndVerifyToken(token) - if err != nil { - Error(w, err, http.StatusUnauthorized, nil) - return - } - } else { - tokenData = &portainer.TokenData{ - Role: portainer.AdministratorRole, - } - } - - ctx := context.WithValue(r.Context(), contextAuthenticationKey, tokenData) - next.ServeHTTP(w, r.WithContext(ctx)) - return - }) -} diff --git a/api/http/proxy.go b/api/http/proxy.go deleted file mode 100644 index 053656be5..000000000 --- a/api/http/proxy.go +++ /dev/null @@ -1,67 +0,0 @@ -package http - -import ( - "net/http" - "net/url" - - "github.com/orcaman/concurrent-map" - "github.com/portainer/portainer" -) - -// ProxyService represents a service used to manage Docker proxies. -type ProxyService struct { - proxyFactory *ProxyFactory - proxies cmap.ConcurrentMap -} - -// NewProxyService initializes a new ProxyService -func NewProxyService(resourceControlService portainer.ResourceControlService) *ProxyService { - return &ProxyService{ - proxies: cmap.New(), - proxyFactory: &ProxyFactory{ - ResourceControlService: resourceControlService, - }, - } -} - -// CreateAndRegisterProxy creates a new HTTP reverse proxy and adds it to the registered proxies. -// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy. -func (service *ProxyService) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (http.Handler, error) { - var proxy http.Handler - - endpointURL, err := url.Parse(endpoint.URL) - if err != nil { - return nil, err - } - - if endpointURL.Scheme == "tcp" { - if endpoint.TLS { - proxy, err = service.proxyFactory.newHTTPSProxy(endpointURL, endpoint) - if err != nil { - return nil, err - } - } else { - proxy = service.proxyFactory.newHTTPProxy(endpointURL) - } - } else { - // Assume unix:// scheme - proxy = service.proxyFactory.newSocketProxy(endpointURL.Path) - } - - service.proxies.Set(string(endpoint.ID), proxy) - return proxy, nil -} - -// GetProxy returns the proxy associated to a key -func (service *ProxyService) GetProxy(key string) http.Handler { - proxy, ok := service.proxies.Get(key) - if !ok { - return nil - } - return proxy.(http.Handler) -} - -// DeleteProxy deletes the proxy associated to a key -func (service *ProxyService) DeleteProxy(key string) { - service.proxies.Remove(key) -} diff --git a/api/http/proxy/access_control.go b/api/http/proxy/access_control.go new file mode 100644 index 000000000..eb26661b5 --- /dev/null +++ b/api/http/proxy/access_control.go @@ -0,0 +1,21 @@ +package proxy + +import "github.com/portainer/portainer" + +func canUserAccessResource(userID portainer.UserID, userTeamIDs []portainer.TeamID, resourceControl *portainer.ResourceControl) bool { + for _, authorizedUserAccess := range resourceControl.UserAccesses { + if userID == authorizedUserAccess.UserID { + return true + } + } + + for _, authorizedTeamAccess := range resourceControl.TeamAccesses { + for _, userTeamID := range userTeamIDs { + if userTeamID == authorizedTeamAccess.TeamID { + return true + } + } + } + + return false +} diff --git a/api/http/proxy/containers.go b/api/http/proxy/containers.go new file mode 100644 index 000000000..909a7dc0d --- /dev/null +++ b/api/http/proxy/containers.go @@ -0,0 +1,98 @@ +package proxy + +import ( + "net/http" + + "github.com/portainer/portainer" +) + +const ( + // ErrDockerContainerIdentifierNotFound defines an error raised when Portainer is unable to find a container identifier + ErrDockerContainerIdentifierNotFound = portainer.Error("Docker container identifier not found") + containerIdentifier = "Id" + containerLabelForServiceIdentifier = "com.docker.swarm.service.id" +) + +// containerListOperation extracts the response as a JSON object, loop through the containers array +// decorate and/or filter the containers based on resource controls before rewriting the response +func containerListOperation(request *http.Request, response *http.Response, operationContext *restrictedOperationContext) error { + var err error + // ContainerList response is a JSON array + // https://docs.docker.com/engine/api/v1.28/#operation/ContainerList + responseArray, err := getResponseAsJSONArray(response) + if err != nil { + return err + } + + if operationContext.isAdmin { + responseArray, err = decorateContainerList(responseArray, operationContext.resourceControls) + } else { + responseArray, err = filterContainerList(responseArray, operationContext.resourceControls, operationContext.userID, operationContext.userTeamIDs) + } + if err != nil { + return err + } + + return rewriteResponse(response, responseArray, http.StatusOK) +} + +// containerInspectOperation extracts the response as a JSON object, verify that the user +// has access to the container based on resource control (check are done based on the containerID and optional Swarm service ID) +// and either rewrite an access denied response or a decorated container. +func containerInspectOperation(request *http.Request, response *http.Response, operationContext *restrictedOperationContext) error { + // ContainerInspect response is a JSON object + // https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect + responseObject, err := getResponseAsJSONOBject(response) + if err != nil { + return err + } + + if responseObject[containerIdentifier] == nil { + return ErrDockerContainerIdentifierNotFound + } + containerID := responseObject[containerIdentifier].(string) + + resourceControl := getResourceControlByResourceID(containerID, operationContext.resourceControls) + if resourceControl != nil { + if operationContext.isAdmin || canUserAccessResource(operationContext.userID, operationContext.userTeamIDs, resourceControl) { + responseObject = decorateObject(responseObject, resourceControl) + } else { + return rewriteAccessDeniedResponse(response) + } + } + + containerLabels := extractContainerLabelsFromContainerInspectObject(responseObject) + if containerLabels != nil && containerLabels[containerLabelForServiceIdentifier] != nil { + serviceID := containerLabels[containerLabelForServiceIdentifier].(string) + resourceControl := getResourceControlByResourceID(serviceID, operationContext.resourceControls) + if resourceControl != nil { + if operationContext.isAdmin || canUserAccessResource(operationContext.userID, operationContext.userTeamIDs, resourceControl) { + responseObject = decorateObject(responseObject, resourceControl) + } else { + return rewriteAccessDeniedResponse(response) + } + } + } + + return rewriteResponse(response, responseObject, http.StatusOK) +} + +// extractContainerLabelsFromContainerInspectObject retrieve the Labels of the container if present. +// Container schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect +func extractContainerLabelsFromContainerInspectObject(responseObject map[string]interface{}) map[string]interface{} { + // Labels are stored under Config.Labels + containerConfigObject := extractJSONField(responseObject, "Config") + if containerConfigObject != nil { + containerLabelsObject := extractJSONField(containerConfigObject, "Labels") + return containerLabelsObject + } + return nil +} + +// extractContainerLabelsFromContainerListObject retrieve the Labels of the container if present. +// Container schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList +func extractContainerLabelsFromContainerListObject(responseObject map[string]interface{}) map[string]interface{} { + // Labels are stored under Labels + containerLabelsObject := extractJSONField(responseObject, "Labels") + return containerLabelsObject +} diff --git a/api/http/proxy/decorator.go b/api/http/proxy/decorator.go new file mode 100644 index 000000000..cc35fa7a3 --- /dev/null +++ b/api/http/proxy/decorator.go @@ -0,0 +1,90 @@ +package proxy + +import "github.com/portainer/portainer" + +// decorateVolumeList loops through all volumes and will decorate any volume with an existing resource control. +// Volume object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList +func decorateVolumeList(volumeData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { + decoratedVolumeData := make([]interface{}, 0) + + for _, volume := range volumeData { + + volumeObject := volume.(map[string]interface{}) + if volumeObject[volumeIdentifier] == nil { + return nil, ErrDockerVolumeIdentifierNotFound + } + + volumeID := volumeObject[volumeIdentifier].(string) + resourceControl := getResourceControlByResourceID(volumeID, resourceControls) + if resourceControl != nil { + volumeObject = decorateObject(volumeObject, resourceControl) + } + decoratedVolumeData = append(decoratedVolumeData, volumeObject) + } + + return decoratedVolumeData, nil +} + +// decorateContainerList loops through all containers and will decorate any container with an existing resource control. +// Check is based on the container ID and optional Swarm service ID. +// Container object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList +func decorateContainerList(containerData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { + decoratedContainerData := make([]interface{}, 0) + + for _, container := range containerData { + + containerObject := container.(map[string]interface{}) + if containerObject[containerIdentifier] == nil { + return nil, ErrDockerContainerIdentifierNotFound + } + + containerID := containerObject[containerIdentifier].(string) + resourceControl := getResourceControlByResourceID(containerID, resourceControls) + if resourceControl != nil { + containerObject = decorateObject(containerObject, resourceControl) + } + + containerLabels := extractContainerLabelsFromContainerListObject(containerObject) + if containerLabels != nil && containerLabels[containerLabelForServiceIdentifier] != nil { + serviceID := containerLabels[containerLabelForServiceIdentifier].(string) + resourceControl := getResourceControlByResourceID(serviceID, resourceControls) + if resourceControl != nil { + containerObject = decorateObject(containerObject, resourceControl) + } + } + + decoratedContainerData = append(decoratedContainerData, containerObject) + } + + return decoratedContainerData, nil +} + +// decorateServiceList loops through all services and will decorate any service with an existing resource control. +// Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList +func decorateServiceList(serviceData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { + decoratedServiceData := make([]interface{}, 0) + + for _, service := range serviceData { + + serviceObject := service.(map[string]interface{}) + if serviceObject[serviceIdentifier] == nil { + return nil, ErrDockerServiceIdentifierNotFound + } + + serviceID := serviceObject[serviceIdentifier].(string) + resourceControl := getResourceControlByResourceID(serviceID, resourceControls) + if resourceControl != nil { + serviceObject = decorateObject(serviceObject, resourceControl) + } + decoratedServiceData = append(decoratedServiceData, serviceObject) + } + + return decoratedServiceData, nil +} + +func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} { + metadata := make(map[string]interface{}) + metadata["ResourceControl"] = resourceControl + object["Portainer"] = metadata + return object +} diff --git a/api/http/proxy/factory.go b/api/http/proxy/factory.go new file mode 100644 index 000000000..96d2239d2 --- /dev/null +++ b/api/http/proxy/factory.go @@ -0,0 +1,55 @@ +package proxy + +import ( + "net/http" + "net/http/httputil" + "net/url" + + "github.com/portainer/portainer" + "github.com/portainer/portainer/crypto" +) + +// proxyFactory is a factory to create reverse proxies to Docker endpoints +type proxyFactory struct { + ResourceControlService portainer.ResourceControlService + TeamMembershipService portainer.TeamMembershipService +} + +func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler { + u.Scheme = "http" + return factory.createReverseProxy(u) +} + +func (factory *proxyFactory) newHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) { + u.Scheme = "https" + proxy := factory.createReverseProxy(u) + config, err := crypto.CreateTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath) + if err != nil { + return nil, err + } + + proxy.Transport.(*proxyTransport).dockerTransport.TLSClientConfig = config + return proxy, nil +} + +func (factory *proxyFactory) newSocketProxy(path string) http.Handler { + proxy := &socketProxy{} + transport := &proxyTransport{ + ResourceControlService: factory.ResourceControlService, + TeamMembershipService: factory.TeamMembershipService, + dockerTransport: newSocketTransport(path), + } + proxy.Transport = transport + return proxy +} + +func (factory *proxyFactory) createReverseProxy(u *url.URL) *httputil.ReverseProxy { + proxy := newSingleHostReverseProxyWithHostHeader(u) + transport := &proxyTransport{ + ResourceControlService: factory.ResourceControlService, + TeamMembershipService: factory.TeamMembershipService, + dockerTransport: newHTTPTransport(), + } + proxy.Transport = transport + return proxy +} diff --git a/api/http/proxy/filter.go b/api/http/proxy/filter.go new file mode 100644 index 000000000..02de684b3 --- /dev/null +++ b/api/http/proxy/filter.go @@ -0,0 +1,91 @@ +package proxy + +import "github.com/portainer/portainer" + +// filterVolumeList loops through all volumes, filters volumes without any resource control (public resources) or with +// any resource control giving access to the user (these volumes will be decorated). +// Volume object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList +func filterVolumeList(volumeData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) { + filteredVolumeData := make([]interface{}, 0) + + for _, volume := range volumeData { + volumeObject := volume.(map[string]interface{}) + if volumeObject[volumeIdentifier] == nil { + return nil, ErrDockerVolumeIdentifierNotFound + } + + volumeID := volumeObject[volumeIdentifier].(string) + resourceControl := getResourceControlByResourceID(volumeID, resourceControls) + if resourceControl == nil { + filteredVolumeData = append(filteredVolumeData, volumeObject) + } else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) { + volumeObject = decorateObject(volumeObject, resourceControl) + filteredVolumeData = append(filteredVolumeData, volumeObject) + } + } + + return filteredVolumeData, nil +} + +// filterContainerList loops through all containers, filters containers without any resource control (public resources) or with +// any resource control giving access to the user (check on container ID and optional Swarm service ID, these containers will be decorated). +// Container object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList +func filterContainerList(containerData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) { + filteredContainerData := make([]interface{}, 0) + + for _, container := range containerData { + containerObject := container.(map[string]interface{}) + if containerObject[containerIdentifier] == nil { + return nil, ErrDockerContainerIdentifierNotFound + } + + containerID := containerObject[containerIdentifier].(string) + resourceControl := getResourceControlByResourceID(containerID, resourceControls) + if resourceControl == nil { + // check if container is part of a Swarm service + containerLabels := extractContainerLabelsFromContainerListObject(containerObject) + if containerLabels != nil && containerLabels[containerLabelForServiceIdentifier] != nil { + serviceID := containerLabels[containerLabelForServiceIdentifier].(string) + serviceResourceControl := getResourceControlByResourceID(serviceID, resourceControls) + if serviceResourceControl == nil { + filteredContainerData = append(filteredContainerData, containerObject) + } else if serviceResourceControl != nil && canUserAccessResource(userID, userTeamIDs, serviceResourceControl) { + containerObject = decorateObject(containerObject, serviceResourceControl) + filteredContainerData = append(filteredContainerData, containerObject) + } + } else { + filteredContainerData = append(filteredContainerData, containerObject) + } + } else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) { + containerObject = decorateObject(containerObject, resourceControl) + filteredContainerData = append(filteredContainerData, containerObject) + } + } + + return filteredContainerData, nil +} + +// filterServiceList loops through all services, filters services without any resource control (public resources) or with +// any resource control giving access to the user (these services will be decorated). +// Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList +func filterServiceList(serviceData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) { + filteredServiceData := make([]interface{}, 0) + + for _, service := range serviceData { + serviceObject := service.(map[string]interface{}) + if serviceObject[serviceIdentifier] == nil { + return nil, ErrDockerServiceIdentifierNotFound + } + + serviceID := serviceObject[serviceIdentifier].(string) + resourceControl := getResourceControlByResourceID(serviceID, resourceControls) + if resourceControl == nil { + filteredServiceData = append(filteredServiceData, serviceObject) + } else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) { + serviceObject = decorateObject(serviceObject, resourceControl) + filteredServiceData = append(filteredServiceData, serviceObject) + } + } + + return filteredServiceData, nil +} diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go new file mode 100644 index 000000000..b90596e10 --- /dev/null +++ b/api/http/proxy/manager.go @@ -0,0 +1,68 @@ +package proxy + +import ( + "net/http" + "net/url" + + "github.com/orcaman/concurrent-map" + "github.com/portainer/portainer" +) + +// Manager represents a service used to manage Docker proxies. +type Manager struct { + proxyFactory *proxyFactory + proxies cmap.ConcurrentMap +} + +// NewManager initializes a new proxy Service +func NewManager(resourceControlService portainer.ResourceControlService, teamMembershipService portainer.TeamMembershipService) *Manager { + return &Manager{ + proxies: cmap.New(), + proxyFactory: &proxyFactory{ + ResourceControlService: resourceControlService, + TeamMembershipService: teamMembershipService, + }, + } +} + +// CreateAndRegisterProxy creates a new HTTP reverse proxy and adds it to the registered proxies. +// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy. +func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + var proxy http.Handler + + endpointURL, err := url.Parse(endpoint.URL) + if err != nil { + return nil, err + } + + if endpointURL.Scheme == "tcp" { + if endpoint.TLS { + proxy, err = manager.proxyFactory.newHTTPSProxy(endpointURL, endpoint) + if err != nil { + return nil, err + } + } else { + proxy = manager.proxyFactory.newHTTPProxy(endpointURL) + } + } else { + // Assume unix:// scheme + proxy = manager.proxyFactory.newSocketProxy(endpointURL.Path) + } + + manager.proxies.Set(string(endpoint.ID), proxy) + return proxy, nil +} + +// GetProxy returns the proxy associated to a key +func (manager *Manager) GetProxy(key string) http.Handler { + proxy, ok := manager.proxies.Get(key) + if !ok { + return nil + } + return proxy.(http.Handler) +} + +// DeleteProxy deletes the proxy associated to a key +func (manager *Manager) DeleteProxy(key string) { + manager.proxies.Remove(key) +} diff --git a/api/http/proxy/response.go b/api/http/proxy/response.go new file mode 100644 index 000000000..ece319672 --- /dev/null +++ b/api/http/proxy/response.go @@ -0,0 +1,90 @@ +package proxy + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "strconv" + + "github.com/portainer/portainer" +) + +const ( + // ErrEmptyResponseBody defines an error raised when portainer excepts to parse the body of a HTTP response and there is nothing to parse + ErrEmptyResponseBody = portainer.Error("Empty response body") +) + +func extractJSONField(jsonObject map[string]interface{}, key string) map[string]interface{} { + object := jsonObject[key] + if object != nil { + return object.(map[string]interface{}) + } + return nil +} + +func getResponseAsJSONOBject(response *http.Response) (map[string]interface{}, error) { + responseData, err := getResponseBodyAsGenericJSON(response) + if err != nil { + return nil, err + } + + responseObject := responseData.(map[string]interface{}) + return responseObject, nil +} + +func getResponseAsJSONArray(response *http.Response) ([]interface{}, error) { + responseData, err := getResponseBodyAsGenericJSON(response) + if err != nil { + return nil, err + } + + responseObject := responseData.([]interface{}) + return responseObject, nil +} + +func getResponseBodyAsGenericJSON(response *http.Response) (interface{}, error) { + var data interface{} + if response.Body != nil { + body, err := ioutil.ReadAll(response.Body) + if err != nil { + return nil, err + } + + err = response.Body.Close() + if err != nil { + return nil, err + } + + err = json.Unmarshal(body, &data) + if err != nil { + return nil, err + } + + return data, nil + } + return nil, ErrEmptyResponseBody +} + +func writeAccessDeniedResponse() (*http.Response, error) { + response := &http.Response{} + err := rewriteResponse(response, portainer.ErrResourceAccessDenied, http.StatusForbidden) + return response, err +} + +func rewriteAccessDeniedResponse(response *http.Response) error { + return rewriteResponse(response, portainer.ErrResourceAccessDenied, http.StatusForbidden) +} + +func rewriteResponse(response *http.Response, newResponseData interface{}, statusCode int) error { + jsonData, err := json.Marshal(newResponseData) + if err != nil { + return err + } + body := ioutil.NopCloser(bytes.NewReader(jsonData)) + response.StatusCode = statusCode + response.Body = body + response.ContentLength = int64(len(jsonData)) + response.Header.Set("Content-Length", strconv.Itoa(len(jsonData))) + return nil +} diff --git a/api/http/proxy/reverse_proxy.go b/api/http/proxy/reverse_proxy.go new file mode 100644 index 000000000..4862de9a9 --- /dev/null +++ b/api/http/proxy/reverse_proxy.go @@ -0,0 +1,46 @@ +package proxy + +import ( + "net/http" + "net/http/httputil" + "net/url" + "strings" +) + +// NewSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy +// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host +// HTTP header, which NewSingleHostReverseProxy deliberately preserves. +func newSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy { + targetQuery := target.RawQuery + director := func(req *http.Request) { + req.URL.Scheme = target.Scheme + req.URL.Host = target.Host + req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path) + req.Host = req.URL.Host + if targetQuery == "" || req.URL.RawQuery == "" { + req.URL.RawQuery = targetQuery + req.URL.RawQuery + } else { + req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery + } + if _, ok := req.Header["User-Agent"]; !ok { + // explicitly disable User-Agent so it's not set to default value + req.Header.Set("User-Agent", "") + } + } + return &httputil.ReverseProxy{Director: director} +} + +// singleJoiningSlash from golang.org/src/net/http/httputil/reverseproxy.go +// included here for use in NewSingleHostReverseProxyWithHostHeader +// because its used in NewSingleHostReverseProxy from golang.org/src/net/http/httputil/reverseproxy.go +func singleJoiningSlash(a, b string) string { + aslash := strings.HasSuffix(a, "/") + bslash := strings.HasPrefix(b, "/") + switch { + case aslash && bslash: + return a + b[1:] + case !aslash && !bslash: + return a + "/" + b + } + return a + b +} diff --git a/api/http/proxy/service.go b/api/http/proxy/service.go new file mode 100644 index 000000000..fcf604a84 --- /dev/null +++ b/api/http/proxy/service.go @@ -0,0 +1,64 @@ +package proxy + +import ( + "net/http" + + "github.com/portainer/portainer" +) + +const ( + // ErrDockerServiceIdentifierNotFound defines an error raised when Portainer is unable to find a service identifier + ErrDockerServiceIdentifierNotFound = portainer.Error("Docker service identifier not found") + serviceIdentifier = "ID" +) + +// serviceListOperation extracts the response as a JSON array, loop through the service array +// decorate and/or filter the services based on resource controls before rewriting the response +func serviceListOperation(request *http.Request, response *http.Response, operationContext *restrictedOperationContext) error { + var err error + // ServiceList response is a JSON array + // https://docs.docker.com/engine/api/v1.28/#operation/ServiceList + responseArray, err := getResponseAsJSONArray(response) + if err != nil { + return err + } + + if operationContext.isAdmin { + responseArray, err = decorateServiceList(responseArray, operationContext.resourceControls) + } else { + responseArray, err = filterServiceList(responseArray, operationContext.resourceControls, operationContext.userID, operationContext.userTeamIDs) + } + if err != nil { + return err + } + + return rewriteResponse(response, responseArray, http.StatusOK) +} + +// serviceInspectOperation extracts the response as a JSON object, verify that the user +// has access to the service based on resource control and either rewrite an access denied response +// or a decorated service. +func serviceInspectOperation(request *http.Request, response *http.Response, operationContext *restrictedOperationContext) error { + // ServiceInspect response is a JSON object + // https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect + responseObject, err := getResponseAsJSONOBject(response) + if err != nil { + return err + } + + if responseObject[serviceIdentifier] == nil { + return ErrDockerServiceIdentifierNotFound + } + serviceID := responseObject[serviceIdentifier].(string) + + resourceControl := getResourceControlByResourceID(serviceID, operationContext.resourceControls) + if resourceControl != nil { + if operationContext.isAdmin || canUserAccessResource(operationContext.userID, operationContext.userTeamIDs, resourceControl) { + responseObject = decorateObject(responseObject, resourceControl) + } else { + return rewriteAccessDeniedResponse(response) + } + } + + return rewriteResponse(response, responseObject, http.StatusOK) +} diff --git a/api/http/proxy/socket.go b/api/http/proxy/socket.go new file mode 100644 index 000000000..740010a63 --- /dev/null +++ b/api/http/proxy/socket.go @@ -0,0 +1,40 @@ +package proxy + +// unixSocketHandler represents a handler to proxy HTTP requests via a unix:// socket +import ( + "io" + "net/http" + + httperror "github.com/portainer/portainer/http/error" +) + +type socketProxy struct { + Transport *proxyTransport +} + +func (proxy *socketProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Force URL/domain to http/unixsocket to be able to + // use http.Transport RoundTrip to do the requests via the socket + r.URL.Scheme = "http" + r.URL.Host = "unixsocket" + + res, err := proxy.Transport.proxyDockerRequest(r) + if err != nil { + code := http.StatusInternalServerError + if res != nil && res.StatusCode != 0 { + code = res.StatusCode + } + httperror.WriteErrorResponse(w, err, code, nil) + return + } + defer res.Body.Close() + + for k, vv := range res.Header { + for _, v := range vv { + w.Header().Add(k, v) + } + } + if _, err := io.Copy(w, res.Body); err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, nil) + } +} diff --git a/api/http/proxy/transport.go b/api/http/proxy/transport.go new file mode 100644 index 000000000..ca18ebcb5 --- /dev/null +++ b/api/http/proxy/transport.go @@ -0,0 +1,237 @@ +package proxy + +import ( + "net" + "net/http" + "path" + "strings" + + "github.com/portainer/portainer" + "github.com/portainer/portainer/http/security" +) + +type ( + proxyTransport struct { + dockerTransport *http.Transport + ResourceControlService portainer.ResourceControlService + TeamMembershipService portainer.TeamMembershipService + } + restrictedOperationContext struct { + isAdmin bool + userID portainer.UserID + userTeamIDs []portainer.TeamID + resourceControls []portainer.ResourceControl + } + restrictedOperationRequest func(*http.Request, *http.Response, *restrictedOperationContext) error +) + +func newSocketTransport(socketPath string) *http.Transport { + return &http.Transport{ + Dial: func(proto, addr string) (conn net.Conn, err error) { + return net.Dial("unix", socketPath) + }, + } +} + +func newHTTPTransport() *http.Transport { + return &http.Transport{} +} + +func (p *proxyTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return p.proxyDockerRequest(request) +} + +func (p *proxyTransport) executeDockerRequest(request *http.Request) (*http.Response, error) { + return p.dockerTransport.RoundTrip(request) +} + +func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Response, error) { + path := request.URL.Path + + if strings.HasPrefix(path, "/containers") { + return p.proxyContainerRequest(request) + } else if strings.HasPrefix(path, "/services") { + return p.proxyServiceRequest(request) + } else if strings.HasPrefix(path, "/volumes") { + return p.proxyVolumeRequest(request) + } + + return p.executeDockerRequest(request) +} + +func (p *proxyTransport) proxyContainerRequest(request *http.Request) (*http.Response, error) { + // return p.executeDockerRequest(request) + switch requestPath := request.URL.Path; requestPath { + case "/containers/create": + return p.executeDockerRequest(request) + + case "/containers/prune": + return p.administratorOperation(request) + + case "/containers/json": + return p.rewriteOperation(request, containerListOperation) + + default: + // This section assumes /containers/** + if match, _ := path.Match("/containers/*/*", requestPath); match { + // Handle /containers/{id}/{action} requests + containerID := path.Base(path.Dir(requestPath)) + action := path.Base(requestPath) + + if action == "json" { + return p.rewriteOperation(request, containerInspectOperation) + } + return p.restrictedOperation(request, containerID) + } else if match, _ := path.Match("/containers/*", requestPath); match { + // Handle /containers/{id} requests + containerID := path.Base(requestPath) + return p.restrictedOperation(request, containerID) + } + return p.executeDockerRequest(request) + } +} + +func (p *proxyTransport) proxyServiceRequest(request *http.Request) (*http.Response, error) { + switch requestPath := request.URL.Path; requestPath { + case "/services/create": + return p.executeDockerRequest(request) + + case "/volumes/prune": + return p.administratorOperation(request) + + case "/services": + return p.rewriteOperation(request, serviceListOperation) + + default: + // This section assumes /services/** + if match, _ := path.Match("/services/*/*", requestPath); match { + // Handle /services/{id}/{action} requests + serviceID := path.Base(path.Dir(requestPath)) + return p.restrictedOperation(request, serviceID) + } else if match, _ := path.Match("/services/*", requestPath); match { + // Handle /services/{id} requests + serviceID := path.Base(requestPath) + + if request.Method == http.MethodGet { + return p.rewriteOperation(request, serviceInspectOperation) + } + return p.restrictedOperation(request, serviceID) + } + return p.executeDockerRequest(request) + } +} + +func (p *proxyTransport) proxyVolumeRequest(request *http.Request) (*http.Response, error) { + switch requestPath := request.URL.Path; requestPath { + case "/volumes/create": + return p.executeDockerRequest(request) + + case "/volumes/prune": + return p.administratorOperation(request) + + case "/volumes": + return p.rewriteOperation(request, volumeListOperation) + + default: + // assume /volumes/{name} + if request.Method == http.MethodGet { + return p.rewriteOperation(request, volumeInspectOperation) + } + volumeID := path.Base(requestPath) + return p.restrictedOperation(request, volumeID) + } +} + +// restrictedOperation ensures that the current user has the required authorizations +// before executing the original request. +func (p *proxyTransport) restrictedOperation(request *http.Request, resourceID string) (*http.Response, error) { + var err error + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, err + } + + if tokenData.Role != portainer.AdministratorRole { + + teamMemberships, err := p.TeamMembershipService.TeamMembershipsByUserID(tokenData.ID) + if err != nil { + return nil, err + } + + userTeamIDs := make([]portainer.TeamID, 0) + for _, membership := range teamMemberships { + userTeamIDs = append(userTeamIDs, membership.TeamID) + } + + resourceControls, err := p.ResourceControlService.ResourceControls() + if err != nil { + return nil, err + } + + resourceControl := getResourceControlByResourceID(resourceID, resourceControls) + if resourceControl != nil && !canUserAccessResource(tokenData.ID, userTeamIDs, resourceControl) { + return writeAccessDeniedResponse() + } + } + + return p.executeDockerRequest(request) +} + +// rewriteOperation will create a new operation context with data that will be used +// to decorate the original request's response. +func (p *proxyTransport) rewriteOperation(request *http.Request, operation restrictedOperationRequest) (*http.Response, error) { + var err error + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, err + } + + resourceControls, err := p.ResourceControlService.ResourceControls() + if err != nil { + return nil, err + } + + operationContext := &restrictedOperationContext{ + isAdmin: true, + userID: tokenData.ID, + resourceControls: resourceControls, + } + + if tokenData.Role != portainer.AdministratorRole { + operationContext.isAdmin = false + + teamMemberships, err := p.TeamMembershipService.TeamMembershipsByUserID(tokenData.ID) + if err != nil { + return nil, err + } + + userTeamIDs := make([]portainer.TeamID, 0) + for _, membership := range teamMemberships { + userTeamIDs = append(userTeamIDs, membership.TeamID) + } + operationContext.userTeamIDs = userTeamIDs + } + + response, err := p.executeDockerRequest(request) + if err != nil { + return response, err + } + + err = operation(request, response, operationContext) + return response, err +} + +// administratorOperation ensures that the user has administrator privileges +// before executing the original request. +func (p *proxyTransport) administratorOperation(request *http.Request) (*http.Response, error) { + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, err + } + + if tokenData.Role != portainer.AdministratorRole { + return writeAccessDeniedResponse() + } + + return p.executeDockerRequest(request) +} diff --git a/api/http/proxy/utils.go b/api/http/proxy/utils.go new file mode 100644 index 000000000..36afce97d --- /dev/null +++ b/api/http/proxy/utils.go @@ -0,0 +1,17 @@ +package proxy + +import "github.com/portainer/portainer" + +func getResourceControlByResourceID(resourceID string, resourceControls []portainer.ResourceControl) *portainer.ResourceControl { + for _, resourceControl := range resourceControls { + if resourceID == resourceControl.ResourceID { + return &resourceControl + } + for _, subResourceID := range resourceControl.SubResourceIDs { + if resourceID == subResourceID { + return &resourceControl + } + } + } + return nil +} diff --git a/api/http/proxy/volumes.go b/api/http/proxy/volumes.go new file mode 100644 index 000000000..c39805de8 --- /dev/null +++ b/api/http/proxy/volumes.go @@ -0,0 +1,73 @@ +package proxy + +import ( + "net/http" + + "github.com/portainer/portainer" +) + +const ( + // ErrDockerVolumeIdentifierNotFound defines an error raised when Portainer is unable to find a volume identifier + ErrDockerVolumeIdentifierNotFound = portainer.Error("Docker volume identifier not found") + volumeIdentifier = "Name" +) + +// volumeListOperation extracts the response as a JSON object, loop through the volume array +// decorate and/or filter the volumes based on resource controls before rewriting the response +func volumeListOperation(request *http.Request, response *http.Response, operationContext *restrictedOperationContext) error { + var err error + // VolumeList response is a JSON object + // https://docs.docker.com/engine/api/v1.28/#operation/VolumeList + responseObject, err := getResponseAsJSONOBject(response) + if err != nil { + return err + } + + // The "Volumes" field contains the list of volumes as an array of JSON objects + // Response schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList + if responseObject["Volumes"] != nil { + volumeData := responseObject["Volumes"].([]interface{}) + + if operationContext.isAdmin { + volumeData, err = decorateVolumeList(volumeData, operationContext.resourceControls) + } else { + volumeData, err = filterVolumeList(volumeData, operationContext.resourceControls, operationContext.userID, operationContext.userTeamIDs) + } + if err != nil { + return err + } + + // Overwrite the original volume list + responseObject["Volumes"] = volumeData + } + + return rewriteResponse(response, responseObject, http.StatusOK) +} + +// volumeInspectOperation extracts the response as a JSON object, verify that the user +// has access to the volume based on resource control and either rewrite an access denied response +// or a decorated volume. +func volumeInspectOperation(request *http.Request, response *http.Response, operationContext *restrictedOperationContext) error { + // VolumeInspect response is a JSON object + // https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect + responseObject, err := getResponseAsJSONOBject(response) + if err != nil { + return err + } + + if responseObject[volumeIdentifier] == nil { + return ErrDockerVolumeIdentifierNotFound + } + volumeID := responseObject[volumeIdentifier].(string) + + resourceControl := getResourceControlByResourceID(volumeID, operationContext.resourceControls) + if resourceControl != nil { + if operationContext.isAdmin || canUserAccessResource(operationContext.userID, operationContext.userTeamIDs, resourceControl) { + responseObject = decorateObject(responseObject, resourceControl) + } else { + return rewriteAccessDeniedResponse(response) + } + } + + return rewriteResponse(response, responseObject, http.StatusOK) +} diff --git a/api/http/proxy_transport.go b/api/http/proxy_transport.go deleted file mode 100644 index 34130979a..000000000 --- a/api/http/proxy_transport.go +++ /dev/null @@ -1,664 +0,0 @@ -package http - -import ( - "bytes" - "encoding/json" - "io/ioutil" - "net/http" - "path" - "strconv" - "strings" - - "github.com/portainer/portainer" -) - -type ( - proxyTransport struct { - transport *http.Transport - ResourceControlService portainer.ResourceControlService - } - resourceControlMetadata struct { - OwnerID portainer.UserID `json:"OwnerId"` - } -) - -func (p *proxyTransport) RoundTrip(req *http.Request) (*http.Response, error) { - response, err := p.transport.RoundTrip(req) - if err != nil { - return response, err - } - - err = p.proxyDockerRequests(req, response) - return response, err -} - -func (p *proxyTransport) proxyDockerRequests(request *http.Request, response *http.Response) error { - path := request.URL.Path - - if strings.HasPrefix(path, "/containers") { - return p.handleContainerRequests(request, response) - } else if strings.HasPrefix(path, "/services") { - return p.handleServiceRequests(request, response) - } else if strings.HasPrefix(path, "/volumes") { - return p.handleVolumeRequests(request, response) - } - - return nil -} - -func (p *proxyTransport) handleContainerRequests(request *http.Request, response *http.Response) error { - requestPath := request.URL.Path - - tokenData, err := extractTokenDataFromRequestContext(request) - if err != nil { - return err - } - - if requestPath == "/containers/prune" && tokenData.Role != portainer.AdministratorRole { - return writeAccessDeniedResponse(response) - } - if requestPath == "/containers/json" { - if tokenData.Role == portainer.AdministratorRole { - return p.decorateContainerResponse(response) - } - return p.proxyContainerResponseWithResourceControl(response, tokenData.ID) - } - // /containers/{id}/action - if match, _ := path.Match("/containers/*/*", requestPath); match { - if tokenData.Role != portainer.AdministratorRole { - resourceID := path.Base(path.Dir(requestPath)) - return p.proxyContainerResponseWithAccessControl(response, tokenData.ID, resourceID) - } - } - - return nil -} - -func (p *proxyTransport) handleServiceRequests(request *http.Request, response *http.Response) error { - requestPath := request.URL.Path - - tokenData, err := extractTokenDataFromRequestContext(request) - if err != nil { - return err - } - - if requestPath == "/services" { - if tokenData.Role == portainer.AdministratorRole { - return p.decorateServiceResponse(response) - } - return p.proxyServiceResponseWithResourceControl(response, tokenData.ID) - } - // /services/{id} - if match, _ := path.Match("/services/*", requestPath); match { - if tokenData.Role != portainer.AdministratorRole { - resourceID := path.Base(requestPath) - return p.proxyServiceResponseWithAccessControl(response, tokenData.ID, resourceID) - } - } - // /services/{id}/action - if match, _ := path.Match("/services/*/*", requestPath); match { - if tokenData.Role != portainer.AdministratorRole { - resourceID := path.Base(path.Dir(requestPath)) - return p.proxyServiceResponseWithAccessControl(response, tokenData.ID, resourceID) - } - } - - return nil -} - -func (p *proxyTransport) handleVolumeRequests(request *http.Request, response *http.Response) error { - requestPath := request.URL.Path - - tokenData, err := extractTokenDataFromRequestContext(request) - if err != nil { - return err - } - - if requestPath == "/volumes" { - if tokenData.Role == portainer.AdministratorRole { - return p.decorateVolumeResponse(response) - } - return p.proxyVolumeResponseWithResourceControl(response, tokenData.ID) - } - if requestPath == "/volumes/prune" && tokenData.Role != portainer.AdministratorRole { - return writeAccessDeniedResponse(response) - } - // /volumes/{name} - if match, _ := path.Match("/volumes/*", requestPath); match { - if tokenData.Role != portainer.AdministratorRole { - resourceID := path.Base(requestPath) - return p.proxyVolumeResponseWithAccessControl(response, tokenData.ID, resourceID) - } - } - return nil -} - -func (p *proxyTransport) proxyContainerResponseWithAccessControl(response *http.Response, userID portainer.UserID, resourceID string) error { - rcs, err := p.ResourceControlService.ResourceControls(portainer.ContainerResourceControl) - if err != nil { - return err - } - - userOwnedResources, err := getResourceIDsOwnedByUser(userID, rcs) - if err != nil { - return err - } - - if !isStringInArray(resourceID, userOwnedResources) && isResourceIDInRCs(resourceID, rcs) { - return writeAccessDeniedResponse(response) - } - - return nil -} - -func (p *proxyTransport) proxyServiceResponseWithAccessControl(response *http.Response, userID portainer.UserID, resourceID string) error { - rcs, err := p.ResourceControlService.ResourceControls(portainer.ServiceResourceControl) - if err != nil { - return err - } - - userOwnedResources, err := getResourceIDsOwnedByUser(userID, rcs) - if err != nil { - return err - } - - if !isStringInArray(resourceID, userOwnedResources) && isResourceIDInRCs(resourceID, rcs) { - return writeAccessDeniedResponse(response) - } - return nil -} - -func (p *proxyTransport) proxyVolumeResponseWithAccessControl(response *http.Response, userID portainer.UserID, resourceID string) error { - rcs, err := p.ResourceControlService.ResourceControls(portainer.VolumeResourceControl) - if err != nil { - return err - } - - userOwnedResources, err := getResourceIDsOwnedByUser(userID, rcs) - if err != nil { - return err - } - - if !isStringInArray(resourceID, userOwnedResources) && isResourceIDInRCs(resourceID, rcs) { - return writeAccessDeniedResponse(response) - } - return nil -} - -func (p *proxyTransport) decorateContainerResponse(response *http.Response) error { - responseData, err := getResponseData(response) - if err != nil { - return err - } - - containers, err := p.decorateContainers(responseData) - if err != nil { - return err - } - - err = rewriteContainerResponse(response, containers) - if err != nil { - return err - } - - return nil -} - -func (p *proxyTransport) proxyContainerResponseWithResourceControl(response *http.Response, userID portainer.UserID) error { - responseData, err := getResponseData(response) - if err != nil { - return err - } - - containers, err := p.filterContainers(userID, responseData) - if err != nil { - return err - } - - err = rewriteContainerResponse(response, containers) - if err != nil { - return err - } - - return nil -} - -func (p *proxyTransport) decorateServiceResponse(response *http.Response) error { - responseData, err := getResponseData(response) - if err != nil { - return err - } - - services, err := p.decorateServices(responseData) - if err != nil { - return err - } - - err = rewriteServiceResponse(response, services) - if err != nil { - return err - } - - return nil -} - -func (p *proxyTransport) proxyServiceResponseWithResourceControl(response *http.Response, userID portainer.UserID) error { - responseData, err := getResponseData(response) - if err != nil { - return err - } - - volumes, err := p.filterServices(userID, responseData) - if err != nil { - return err - } - - err = rewriteServiceResponse(response, volumes) - if err != nil { - return err - } - - return nil -} - -func (p *proxyTransport) decorateVolumeResponse(response *http.Response) error { - responseData, err := getResponseData(response) - if err != nil { - return err - } - - volumes, err := p.decorateVolumes(responseData) - if err != nil { - return err - } - - err = rewriteVolumeResponse(response, volumes) - if err != nil { - return err - } - - return nil -} - -func (p *proxyTransport) proxyVolumeResponseWithResourceControl(response *http.Response, userID portainer.UserID) error { - responseData, err := getResponseData(response) - if err != nil { - return err - } - - volumes, err := p.filterVolumes(userID, responseData) - if err != nil { - return err - } - - err = rewriteVolumeResponse(response, volumes) - if err != nil { - return err - } - - return nil -} - -func (p *proxyTransport) decorateContainers(responseData interface{}) ([]interface{}, error) { - responseDataArray := responseData.([]interface{}) - - containerRCs, err := p.ResourceControlService.ResourceControls(portainer.ContainerResourceControl) - if err != nil { - return nil, err - } - - serviceRCs, err := p.ResourceControlService.ResourceControls(portainer.ServiceResourceControl) - if err != nil { - return nil, err - } - - decoratedResources := make([]interface{}, 0) - - for _, container := range responseDataArray { - jsonObject := container.(map[string]interface{}) - containerID := jsonObject["Id"].(string) - containerRC := getRCByResourceID(containerID, containerRCs) - if containerRC != nil { - decoratedObject := decorateWithResourceControlMetadata(jsonObject, containerRC.OwnerID) - decoratedResources = append(decoratedResources, decoratedObject) - continue - } - - containerLabels := jsonObject["Labels"] - if containerLabels != nil { - jsonLabels := containerLabels.(map[string]interface{}) - serviceID := jsonLabels["com.docker.swarm.service.id"] - if serviceID != nil { - serviceRC := getRCByResourceID(serviceID.(string), serviceRCs) - if serviceRC != nil { - decoratedObject := decorateWithResourceControlMetadata(jsonObject, serviceRC.OwnerID) - decoratedResources = append(decoratedResources, decoratedObject) - continue - } - } - } - decoratedResources = append(decoratedResources, container) - } - - return decoratedResources, nil -} - -func (p *proxyTransport) filterContainers(userID portainer.UserID, responseData interface{}) ([]interface{}, error) { - responseDataArray := responseData.([]interface{}) - - containerRCs, err := p.ResourceControlService.ResourceControls(portainer.ContainerResourceControl) - if err != nil { - return nil, err - } - - serviceRCs, err := p.ResourceControlService.ResourceControls(portainer.ServiceResourceControl) - if err != nil { - return nil, err - } - - userOwnedContainerIDs, err := getResourceIDsOwnedByUser(userID, containerRCs) - if err != nil { - return nil, err - } - - userOwnedServiceIDs, err := getResourceIDsOwnedByUser(userID, serviceRCs) - if err != nil { - return nil, err - } - - publicContainers := getPublicContainers(responseDataArray, containerRCs, serviceRCs) - - filteredResources := make([]interface{}, 0) - - for _, container := range responseDataArray { - jsonObject := container.(map[string]interface{}) - containerID := jsonObject["Id"].(string) - if isStringInArray(containerID, userOwnedContainerIDs) { - decoratedObject := decorateWithResourceControlMetadata(jsonObject, userID) - filteredResources = append(filteredResources, decoratedObject) - continue - } - - containerLabels := jsonObject["Labels"] - if containerLabels != nil { - jsonLabels := containerLabels.(map[string]interface{}) - serviceID := jsonLabels["com.docker.swarm.service.id"] - if serviceID != nil && isStringInArray(serviceID.(string), userOwnedServiceIDs) { - decoratedObject := decorateWithResourceControlMetadata(jsonObject, userID) - filteredResources = append(filteredResources, decoratedObject) - } - } - } - - filteredResources = append(filteredResources, publicContainers...) - return filteredResources, nil -} - -func decorateWithResourceControlMetadata(object map[string]interface{}, userID portainer.UserID) map[string]interface{} { - metadata := make(map[string]interface{}) - metadata["ResourceControl"] = resourceControlMetadata{ - OwnerID: userID, - } - object["Portainer"] = metadata - return object -} - -func (p *proxyTransport) decorateServices(responseData interface{}) ([]interface{}, error) { - responseDataArray := responseData.([]interface{}) - - rcs, err := p.ResourceControlService.ResourceControls(portainer.ServiceResourceControl) - if err != nil { - return nil, err - } - - decoratedResources := make([]interface{}, 0) - - for _, service := range responseDataArray { - jsonResource := service.(map[string]interface{}) - resourceID := jsonResource["ID"].(string) - serviceRC := getRCByResourceID(resourceID, rcs) - if serviceRC != nil { - decoratedObject := decorateWithResourceControlMetadata(jsonResource, serviceRC.OwnerID) - decoratedResources = append(decoratedResources, decoratedObject) - continue - } - decoratedResources = append(decoratedResources, service) - } - - return decoratedResources, nil -} - -func (p *proxyTransport) filterServices(userID portainer.UserID, responseData interface{}) ([]interface{}, error) { - responseDataArray := responseData.([]interface{}) - - rcs, err := p.ResourceControlService.ResourceControls(portainer.ServiceResourceControl) - if err != nil { - return nil, err - } - - userOwnedServiceIDs, err := getResourceIDsOwnedByUser(userID, rcs) - if err != nil { - return nil, err - } - - publicServices := getPublicResources(responseDataArray, rcs, "ID") - - filteredResources := make([]interface{}, 0) - - for _, res := range responseDataArray { - jsonResource := res.(map[string]interface{}) - resourceID := jsonResource["ID"].(string) - if isStringInArray(resourceID, userOwnedServiceIDs) { - decoratedObject := decorateWithResourceControlMetadata(jsonResource, userID) - filteredResources = append(filteredResources, decoratedObject) - } - } - - filteredResources = append(filteredResources, publicServices...) - return filteredResources, nil -} - -func (p *proxyTransport) decorateVolumes(responseData interface{}) ([]interface{}, error) { - var responseDataArray []interface{} - jsonObject := responseData.(map[string]interface{}) - if jsonObject["Volumes"] != nil { - responseDataArray = jsonObject["Volumes"].([]interface{}) - } - - rcs, err := p.ResourceControlService.ResourceControls(portainer.VolumeResourceControl) - if err != nil { - return nil, err - } - - decoratedResources := make([]interface{}, 0) - - for _, volume := range responseDataArray { - jsonResource := volume.(map[string]interface{}) - resourceID := jsonResource["Name"].(string) - volumeRC := getRCByResourceID(resourceID, rcs) - if volumeRC != nil { - decoratedObject := decorateWithResourceControlMetadata(jsonResource, volumeRC.OwnerID) - decoratedResources = append(decoratedResources, decoratedObject) - continue - } - decoratedResources = append(decoratedResources, volume) - } - - return decoratedResources, nil -} - -func (p *proxyTransport) filterVolumes(userID portainer.UserID, responseData interface{}) ([]interface{}, error) { - var responseDataArray []interface{} - jsonObject := responseData.(map[string]interface{}) - if jsonObject["Volumes"] != nil { - responseDataArray = jsonObject["Volumes"].([]interface{}) - } - - rcs, err := p.ResourceControlService.ResourceControls(portainer.VolumeResourceControl) - if err != nil { - return nil, err - } - - userOwnedVolumeIDs, err := getResourceIDsOwnedByUser(userID, rcs) - if err != nil { - return nil, err - } - - publicVolumes := getPublicResources(responseDataArray, rcs, "Name") - - filteredResources := make([]interface{}, 0) - - for _, res := range responseDataArray { - jsonResource := res.(map[string]interface{}) - resourceID := jsonResource["Name"].(string) - if isStringInArray(resourceID, userOwnedVolumeIDs) { - decoratedObject := decorateWithResourceControlMetadata(jsonResource, userID) - filteredResources = append(filteredResources, decoratedObject) - } - } - - filteredResources = append(filteredResources, publicVolumes...) - return filteredResources, nil -} - -func getResourceIDsOwnedByUser(userID portainer.UserID, rcs []portainer.ResourceControl) ([]string, error) { - ownedResources := make([]string, 0) - for _, rc := range rcs { - if rc.OwnerID == userID { - ownedResources = append(ownedResources, rc.ResourceID) - } - } - return ownedResources, nil -} - -func getOwnedServiceContainers(responseData []interface{}, serviceRCs []portainer.ResourceControl) []interface{} { - ownedContainers := make([]interface{}, 0) - for _, res := range responseData { - jsonResource := res.(map[string]map[string]interface{}) - swarmServiceID := jsonResource["Labels"]["com.docker.swarm.service.id"] - if swarmServiceID != nil { - resourceID := swarmServiceID.(string) - if isResourceIDInRCs(resourceID, serviceRCs) { - ownedContainers = append(ownedContainers, res) - } - } - } - return ownedContainers -} - -func getPublicContainers(responseData []interface{}, containerRCs []portainer.ResourceControl, serviceRCs []portainer.ResourceControl) []interface{} { - publicContainers := make([]interface{}, 0) - for _, container := range responseData { - jsonObject := container.(map[string]interface{}) - containerID := jsonObject["Id"].(string) - if !isResourceIDInRCs(containerID, containerRCs) { - containerLabels := jsonObject["Labels"] - if containerLabels != nil { - jsonLabels := containerLabels.(map[string]interface{}) - serviceID := jsonLabels["com.docker.swarm.service.id"] - if serviceID == nil { - publicContainers = append(publicContainers, container) - } else if serviceID != nil && !isResourceIDInRCs(serviceID.(string), serviceRCs) { - publicContainers = append(publicContainers, container) - } - } else { - publicContainers = append(publicContainers, container) - } - } - } - - return publicContainers -} - -func getPublicResources(responseData []interface{}, rcs []portainer.ResourceControl, resourceIDKey string) []interface{} { - publicResources := make([]interface{}, 0) - for _, res := range responseData { - jsonResource := res.(map[string]interface{}) - resourceID := jsonResource[resourceIDKey].(string) - if !isResourceIDInRCs(resourceID, rcs) { - publicResources = append(publicResources, res) - } - } - return publicResources -} - -func isStringInArray(target string, array []string) bool { - for _, element := range array { - if element == target { - return true - } - } - return false -} - -func isResourceIDInRCs(resourceID string, rcs []portainer.ResourceControl) bool { - for _, rc := range rcs { - if resourceID == rc.ResourceID { - return true - } - } - return false -} - -func getRCByResourceID(resourceID string, rcs []portainer.ResourceControl) *portainer.ResourceControl { - for _, rc := range rcs { - if resourceID == rc.ResourceID { - return &rc - } - } - return nil -} - -func getResponseData(response *http.Response) (interface{}, error) { - var data interface{} - if response.Body != nil { - body, err := ioutil.ReadAll(response.Body) - if err != nil { - return nil, err - } - - err = response.Body.Close() - if err != nil { - return nil, err - } - - err = json.Unmarshal(body, &data) - if err != nil { - return nil, err - } - - return data, nil - } - return nil, ErrEmptyResponseBody -} - -func writeAccessDeniedResponse(response *http.Response) error { - return rewriteResponse(response, portainer.ErrResourceAccessDenied, 403) -} - -func rewriteContainerResponse(response *http.Response, responseData interface{}) error { - return rewriteResponse(response, responseData, 200) -} - -func rewriteServiceResponse(response *http.Response, responseData interface{}) error { - return rewriteResponse(response, responseData, 200) -} - -func rewriteVolumeResponse(response *http.Response, responseData interface{}) error { - data := map[string]interface{}{} - data["Volumes"] = responseData - return rewriteResponse(response, data, 200) -} - -func rewriteResponse(response *http.Response, newContent interface{}, statusCode int) error { - jsonData, err := json.Marshal(newContent) - if err != nil { - return err - } - body := ioutil.NopCloser(bytes.NewReader(jsonData)) - response.StatusCode = statusCode - response.Body = body - response.ContentLength = int64(len(jsonData)) - response.Header.Set("Content-Length", strconv.Itoa(len(jsonData))) - return nil -} diff --git a/api/http/security/authorization.go b/api/http/security/authorization.go new file mode 100644 index 000000000..b19dad1f6 --- /dev/null +++ b/api/http/security/authorization.go @@ -0,0 +1,123 @@ +package security + +import "github.com/portainer/portainer" + +// AuthorizedResourceControlDeletion ensure that the user can delete a resource control object. +// A non-administrator user cannot delete a resource control where: +// * the AdministratorsOnly flag is set +// * he is not one of the users in the user accesses +// * he is not a member of any team within the team accesses +func AuthorizedResourceControlDeletion(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool { + if context.IsAdmin { + return true + } + + if resourceControl.AdministratorsOnly { + return false + } + + userAccessesCount := len(resourceControl.UserAccesses) + teamAccessesCount := len(resourceControl.TeamAccesses) + + if teamAccessesCount > 0 { + for _, access := range resourceControl.TeamAccesses { + for _, membership := range context.UserMemberships { + if membership.TeamID == access.TeamID && membership.Role == portainer.TeamLeader { + return true + } + } + } + } + + if userAccessesCount > 0 { + for _, access := range resourceControl.UserAccesses { + if access.UserID == context.UserID { + return true + } + } + } + + return false +} + +// AuthorizedResourceControlUpdate ensure that the user can update a resource control object. +// It reuses the creation restrictions and adds extra checks. +// A non-administrator user cannot update a resource control where: +// * he wants to put one or more user in the user accesses +func AuthorizedResourceControlUpdate(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool { + userAccessesCount := len(resourceControl.UserAccesses) + if !context.IsAdmin && userAccessesCount > 0 { + return false + } + + return AuthorizedResourceControlCreation(resourceControl, context) +} + +// AuthorizedResourceControlCreation ensure that the user can create a resource control object. +// A non-administrator user cannot create a resource control where: +// * the AdministratorsOnly flag is set +// * he wants to add more than one user in the user accesses +// * he wants to add a team he is not a member of +func AuthorizedResourceControlCreation(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool { + if context.IsAdmin { + return true + } + + if resourceControl.AdministratorsOnly { + return false + } + + userAccessesCount := len(resourceControl.UserAccesses) + teamAccessesCount := len(resourceControl.TeamAccesses) + if userAccessesCount > 1 || (userAccessesCount == 1 && teamAccessesCount == 1) { + return false + } + + if userAccessesCount == 1 { + access := resourceControl.UserAccesses[0] + if access.UserID == context.UserID { + return true + } + } + + if teamAccessesCount > 0 { + for _, access := range resourceControl.TeamAccesses { + isMember := false + for _, membership := range context.UserMemberships { + if membership.TeamID == access.TeamID { + isMember = true + } + } + if !isMember { + return false + } + } + } + + return true +} + +// AuthorizedTeamManagement ensure that access to the management of the specified team is granted. +// It will check if the user is either administrator or leader of that team. +func AuthorizedTeamManagement(teamID portainer.TeamID, context *RestrictedRequestContext) bool { + if context.IsAdmin { + return true + } + + for _, membership := range context.UserMemberships { + if membership.TeamID == teamID && membership.Role == portainer.TeamLeader { + return true + } + } + + return false +} + +// AuthorizedUserManagement ensure that access to the management of the specified user is granted. +// It will check if the user is either administrator or the owner of the user account. +func AuthorizedUserManagement(userID portainer.UserID, context *RestrictedRequestContext) bool { + if context.IsAdmin || context.UserID == userID { + return true + } + return false +} diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go new file mode 100644 index 000000000..9f7920c6c --- /dev/null +++ b/api/http/security/bouncer.go @@ -0,0 +1,176 @@ +package security + +import ( + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + + "net/http" + "strings" +) + +type ( + // RequestBouncer represents an entity that manages API request accesses + RequestBouncer struct { + jwtService portainer.JWTService + teamMembershipService portainer.TeamMembershipService + authDisabled bool + } + + // RestrictedRequestContext is a data structure containing information + // used in RestrictedAccess + RestrictedRequestContext struct { + IsAdmin bool + IsTeamLeader bool + UserID portainer.UserID + UserMemberships []portainer.TeamMembership + } +) + +// NewRequestBouncer initializes a new RequestBouncer +func NewRequestBouncer(jwtService portainer.JWTService, teamMembershipService portainer.TeamMembershipService, authDisabled bool) *RequestBouncer { + return &RequestBouncer{ + jwtService: jwtService, + teamMembershipService: teamMembershipService, + authDisabled: authDisabled, + } +} + +// PublicAccess defines a security check for public endpoints. +// No authentication is required to access these endpoints. +func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler { + h = mwSecureHeaders(h) + return h +} + +// AuthenticatedAccess defines a security check for private endpoints. +// Authentication is required to access these endpoints. +func (bouncer *RequestBouncer) AuthenticatedAccess(h http.Handler) http.Handler { + h = bouncer.mwCheckAuthentication(h) + h = mwSecureHeaders(h) + return h +} + +// RestrictedAccess defines defines a security check for restricted endpoints. +// Authentication is required to access these endpoints. +// The request context will be enhanced with a RestrictedRequestContext object +// that might be used later to authorize/filter access to resources. +func (bouncer *RequestBouncer) RestrictedAccess(h http.Handler) http.Handler { + h = bouncer.mwUpgradeToRestrictedRequest(h) + h = bouncer.AuthenticatedAccess(h) + return h +} + +// AdministratorAccess defines a chain of middleware for restricted endpoints. +// Authentication as well as administrator role are required to access these endpoints. +func (bouncer *RequestBouncer) AdministratorAccess(h http.Handler) http.Handler { + h = mwCheckAdministratorRole(h) + h = bouncer.AuthenticatedAccess(h) + return h +} + +// mwSecureHeaders provides secure headers middleware for handlers. +func mwSecureHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("X-Content-Type-Options", "nosniff") + w.Header().Add("X-Frame-Options", "DENY") + next.ServeHTTP(w, r) + }) +} + +// mwUpgradeToRestrictedRequest will enhance the current request with +// a new RestrictedRequestContext object. +func (bouncer *RequestBouncer) mwUpgradeToRestrictedRequest(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenData, err := RetrieveTokenData(r) + if err != nil { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) + return + } + + requestContext, err := bouncer.newRestrictedContextRequest(tokenData.ID, tokenData.Role) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, nil) + return + } + + ctx := storeRestrictedRequestContext(r, requestContext) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// mwCheckAdministratorRole check the role of the user associated to the request +func mwCheckAdministratorRole(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenData, err := RetrieveTokenData(r) + if err != nil || tokenData.Role != portainer.AdministratorRole { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) + return + } + + next.ServeHTTP(w, r) + }) +} + +// mwCheckAuthentication provides Authentication middleware for handlers +func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var tokenData *portainer.TokenData + if !bouncer.authDisabled { + var token string + + // Get token from the Authorization header + tokens, ok := r.Header["Authorization"] + if ok && len(tokens) >= 1 { + token = tokens[0] + token = strings.TrimPrefix(token, "Bearer ") + } + + if token == "" { + httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusUnauthorized, nil) + return + } + + var err error + tokenData, err = bouncer.jwtService.ParseAndVerifyToken(token) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusUnauthorized, nil) + return + } + } else { + tokenData = &portainer.TokenData{ + Role: portainer.AdministratorRole, + } + } + + ctx := storeTokenData(r, tokenData) + next.ServeHTTP(w, r.WithContext(ctx)) + return + }) +} + +func (bouncer *RequestBouncer) newRestrictedContextRequest(userID portainer.UserID, userRole portainer.UserRole) (*RestrictedRequestContext, error) { + requestContext := &RestrictedRequestContext{ + IsAdmin: true, + UserID: userID, + } + + if userRole != portainer.AdministratorRole { + requestContext.IsAdmin = false + memberships, err := bouncer.teamMembershipService.TeamMembershipsByUserID(userID) + if err != nil { + return nil, err + } + + isTeamLeader := false + for _, membership := range memberships { + if membership.Role == portainer.TeamLeader { + isTeamLeader = true + } + } + + requestContext.IsTeamLeader = isTeamLeader + requestContext.UserMemberships = memberships + } + + return requestContext, nil +} diff --git a/api/http/security/context.go b/api/http/security/context.go new file mode 100644 index 000000000..4f6141768 --- /dev/null +++ b/api/http/security/context.go @@ -0,0 +1,50 @@ +package security + +import ( + "context" + "net/http" + + "github.com/portainer/portainer" +) + +type ( + contextKey int +) + +const ( + contextAuthenticationKey contextKey = iota + contextRestrictedRequest +) + +// storeTokenData stores a TokenData object inside the request context and returns the enhanced context. +func storeTokenData(request *http.Request, tokenData *portainer.TokenData) context.Context { + return context.WithValue(request.Context(), contextAuthenticationKey, tokenData) +} + +// RetrieveTokenData returns the TokenData object stored in the request context. +func RetrieveTokenData(request *http.Request) (*portainer.TokenData, error) { + contextData := request.Context().Value(contextAuthenticationKey) + if contextData == nil { + return nil, portainer.ErrMissingContextData + } + + tokenData := contextData.(*portainer.TokenData) + return tokenData, nil +} + +// storeRestrictedRequestContext stores a RestrictedRequestContext object inside the request context +// and returns the enhanced context. +func storeRestrictedRequestContext(request *http.Request, requestContext *RestrictedRequestContext) context.Context { + return context.WithValue(request.Context(), contextRestrictedRequest, requestContext) +} + +// RetrieveRestrictedRequestContext returns the RestrictedRequestContext object stored in the request context. +func RetrieveRestrictedRequestContext(request *http.Request) (*RestrictedRequestContext, error) { + contextData := request.Context().Value(contextRestrictedRequest) + if contextData == nil { + return nil, portainer.ErrMissingSecurityContext + } + + requestContext := contextData.(*RestrictedRequestContext) + return requestContext, nil +} diff --git a/api/http/security/filter.go b/api/http/security/filter.go new file mode 100644 index 000000000..ec83a1ebc --- /dev/null +++ b/api/http/security/filter.go @@ -0,0 +1,95 @@ +package security + +import "github.com/portainer/portainer" + +// FilterUserTeams filters teams based on user role. +// non-administrator users only have access to team they are member of. +func FilterUserTeams(teams []portainer.Team, context *RestrictedRequestContext) []portainer.Team { + filteredTeams := teams + + if !context.IsAdmin { + filteredTeams = make([]portainer.Team, 0) + for _, membership := range context.UserMemberships { + for _, team := range teams { + if team.ID == membership.TeamID { + filteredTeams = append(filteredTeams, team) + break + } + } + } + } + + return filteredTeams +} + +// FilterLeaderTeams filters teams based on user role. +// Team leaders only have access to team they lead. +func FilterLeaderTeams(teams []portainer.Team, context *RestrictedRequestContext) []portainer.Team { + filteredTeams := teams + + if context.IsTeamLeader { + filteredTeams = make([]portainer.Team, 0) + for _, membership := range context.UserMemberships { + for _, team := range teams { + if team.ID == membership.TeamID && membership.Role == portainer.TeamLeader { + filteredTeams = append(filteredTeams, team) + break + } + } + } + } + + return filteredTeams +} + +// FilterUsers filters users based on user role. +// Non-administrator users only have access to non-administrator users. +func FilterUsers(users []portainer.User, context *RestrictedRequestContext) []portainer.User { + filteredUsers := users + + if !context.IsAdmin { + filteredUsers = make([]portainer.User, 0) + + for _, user := range users { + if user.Role != portainer.AdministratorRole { + filteredUsers = append(filteredUsers, user) + } + } + } + + return filteredUsers +} + +// FilterEndpoints filters endpoints based on user role and team memberships. +// Non administrator users only have access to authorized endpoints. +func FilterEndpoints(endpoints []portainer.Endpoint, context *RestrictedRequestContext) ([]portainer.Endpoint, error) { + filteredEndpoints := endpoints + + if !context.IsAdmin { + filteredEndpoints = make([]portainer.Endpoint, 0) + + for _, endpoint := range endpoints { + if isEndpointAccessAuthorized(&endpoint, context.UserID, context.UserMemberships) { + filteredEndpoints = append(filteredEndpoints, endpoint) + } + } + } + + return filteredEndpoints, nil +} + +func isEndpointAccessAuthorized(endpoint *portainer.Endpoint, userID portainer.UserID, memberships []portainer.TeamMembership) bool { + for _, authorizedUserID := range endpoint.AuthorizedUsers { + if authorizedUserID == userID { + return true + } + } + for _, membership := range memberships { + for _, authorizedTeamID := range endpoint.AuthorizedTeams { + if membership.TeamID == authorizedTeamID { + return true + } + } + } + return false +} diff --git a/api/http/server.go b/api/http/server.go index 18e37a99a..11d126d33 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -2,6 +2,9 @@ package http import ( "github.com/portainer/portainer" + "github.com/portainer/portainer/http/handler" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/security" "net/http" ) @@ -13,6 +16,8 @@ type Server struct { AuthDisabled bool EndpointManagement bool UserService portainer.UserService + TeamService portainer.TeamService + TeamMembershipService portainer.TeamMembershipService EndpointService portainer.EndpointService ResourceControlService portainer.ResourceControlService CryptoService portainer.CryptoService @@ -20,7 +25,7 @@ type Server struct { FileService portainer.FileService Settings *portainer.Settings TemplatesURL string - Handler *Handler + Handler *handler.Handler SSL bool SSLCert string SSLKey string @@ -28,49 +33,55 @@ type Server struct { // Start starts the HTTP server func (server *Server) Start() error { - middleWareService := &middleWareService{ - jwtService: server.JWTService, - authDisabled: server.AuthDisabled, - } - proxyService := NewProxyService(server.ResourceControlService) + requestBouncer := security.NewRequestBouncer(server.JWTService, server.TeamMembershipService, server.AuthDisabled) + proxyManager := proxy.NewManager(server.ResourceControlService, server.TeamMembershipService) - var authHandler = NewAuthHandler(middleWareService) + var authHandler = handler.NewAuthHandler(requestBouncer, server.AuthDisabled) authHandler.UserService = server.UserService authHandler.CryptoService = server.CryptoService authHandler.JWTService = server.JWTService - authHandler.authDisabled = server.AuthDisabled - var userHandler = NewUserHandler(middleWareService) + var userHandler = handler.NewUserHandler(requestBouncer) userHandler.UserService = server.UserService + userHandler.TeamService = server.TeamService + userHandler.TeamMembershipService = server.TeamMembershipService userHandler.CryptoService = server.CryptoService userHandler.ResourceControlService = server.ResourceControlService - var settingsHandler = NewSettingsHandler(middleWareService) - settingsHandler.settings = server.Settings - var templatesHandler = NewTemplatesHandler(middleWareService) - templatesHandler.containerTemplatesURL = server.TemplatesURL - var dockerHandler = NewDockerHandler(middleWareService, server.ResourceControlService) + var teamHandler = handler.NewTeamHandler(requestBouncer) + teamHandler.TeamService = server.TeamService + teamHandler.TeamMembershipService = server.TeamMembershipService + var teamMembershipHandler = handler.NewTeamMembershipHandler(requestBouncer) + teamMembershipHandler.TeamMembershipService = server.TeamMembershipService + var settingsHandler = handler.NewSettingsHandler(requestBouncer, server.Settings) + var templatesHandler = handler.NewTemplatesHandler(requestBouncer, server.TemplatesURL) + var dockerHandler = handler.NewDockerHandler(requestBouncer) dockerHandler.EndpointService = server.EndpointService - dockerHandler.ProxyService = proxyService - var websocketHandler = NewWebSocketHandler() + dockerHandler.TeamMembershipService = server.TeamMembershipService + dockerHandler.ProxyManager = proxyManager + var websocketHandler = handler.NewWebSocketHandler() websocketHandler.EndpointService = server.EndpointService - var endpointHandler = NewEndpointHandler(middleWareService) - endpointHandler.authorizeEndpointManagement = server.EndpointManagement + var endpointHandler = handler.NewEndpointHandler(requestBouncer, server.EndpointManagement) endpointHandler.EndpointService = server.EndpointService endpointHandler.FileService = server.FileService - endpointHandler.ProxyService = proxyService - var uploadHandler = NewUploadHandler(middleWareService) + endpointHandler.ProxyManager = proxyManager + var resourceHandler = handler.NewResourceHandler(requestBouncer) + resourceHandler.ResourceControlService = server.ResourceControlService + var uploadHandler = handler.NewUploadHandler(requestBouncer) uploadHandler.FileService = server.FileService - var fileHandler = newFileHandler(server.AssetsPath) + var fileHandler = handler.NewFileHandler(server.AssetsPath) - server.Handler = &Handler{ - AuthHandler: authHandler, - UserHandler: userHandler, - EndpointHandler: endpointHandler, - SettingsHandler: settingsHandler, - TemplatesHandler: templatesHandler, - DockerHandler: dockerHandler, - WebSocketHandler: websocketHandler, - FileHandler: fileHandler, - UploadHandler: uploadHandler, + server.Handler = &handler.Handler{ + AuthHandler: authHandler, + UserHandler: userHandler, + TeamHandler: teamHandler, + TeamMembershipHandler: teamMembershipHandler, + EndpointHandler: endpointHandler, + ResourceHandler: resourceHandler, + SettingsHandler: settingsHandler, + TemplatesHandler: templatesHandler, + DockerHandler: dockerHandler, + WebSocketHandler: websocketHandler, + FileHandler: fileHandler, + UploadHandler: uploadHandler, } if server.SSL { diff --git a/api/http/user_handler.go b/api/http/user_handler.go deleted file mode 100644 index b1ba8f684..000000000 --- a/api/http/user_handler.go +++ /dev/null @@ -1,480 +0,0 @@ -package http - -import ( - "strconv" - - "github.com/portainer/portainer" - - "encoding/json" - "log" - "net/http" - "os" - - "github.com/asaskevich/govalidator" - "github.com/gorilla/mux" -) - -// UserHandler represents an HTTP API handler for managing users. -type UserHandler struct { - *mux.Router - Logger *log.Logger - UserService portainer.UserService - ResourceControlService portainer.ResourceControlService - CryptoService portainer.CryptoService -} - -// NewUserHandler returns a new instance of UserHandler. -func NewUserHandler(mw *middleWareService) *UserHandler { - h := &UserHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/users", - mw.administrator(http.HandlerFunc(h.handlePostUsers))).Methods(http.MethodPost) - h.Handle("/users", - mw.administrator(http.HandlerFunc(h.handleGetUsers))).Methods(http.MethodGet) - h.Handle("/users/{id}", - mw.administrator(http.HandlerFunc(h.handleGetUser))).Methods(http.MethodGet) - h.Handle("/users/{id}", - mw.authenticated(http.HandlerFunc(h.handlePutUser))).Methods(http.MethodPut) - h.Handle("/users/{id}", - mw.administrator(http.HandlerFunc(h.handleDeleteUser))).Methods(http.MethodDelete) - h.Handle("/users/{id}/passwd", - mw.authenticated(http.HandlerFunc(h.handlePostUserPasswd))) - h.Handle("/users/{userId}/resources/{resourceType}", - mw.authenticated(http.HandlerFunc(h.handlePostUserResource))).Methods(http.MethodPost) - h.Handle("/users/{userId}/resources/{resourceType}/{resourceId}", - mw.authenticated(http.HandlerFunc(h.handleDeleteUserResource))).Methods(http.MethodDelete) - h.Handle("/users/admin/check", - mw.public(http.HandlerFunc(h.handleGetAdminCheck))) - h.Handle("/users/admin/init", - mw.public(http.HandlerFunc(h.handlePostAdminInit))) - - return h -} - -// handlePostUsers handles POST requests on /users -func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Request) { - var req postUsersRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err := govalidator.ValidateStruct(req) - if err != nil { - Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - var role portainer.UserRole - if req.Role == 1 { - role = portainer.AdministratorRole - } else { - role = portainer.StandardUserRole - } - - user, err := handler.UserService.UserByUsername(req.Username) - if err != nil && err != portainer.ErrUserNotFound { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - if user != nil { - Error(w, portainer.ErrUserAlreadyExists, http.StatusConflict, handler.Logger) - return - } - - user = &portainer.User{ - Username: req.Username, - Role: role, - } - user.Password, err = handler.CryptoService.Hash(req.Password) - if err != nil { - Error(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) - return - } - - err = handler.UserService.CreateUser(user) - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -type postUsersRequest struct { - Username string `valid:"alphanum,required"` - Password string `valid:"required"` - Role int `valid:"required"` -} - -// handleGetUsers handles GET requests on /users -func (handler *UserHandler) handleGetUsers(w http.ResponseWriter, r *http.Request) { - users, err := handler.UserService.Users() - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - for i := range users { - users[i].Password = "" - } - encodeJSON(w, users, handler.Logger) -} - -// handlePostUserPasswd handles POST requests on /users/:id/passwd -func (handler *UserHandler) handlePostUserPasswd(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - handleNotAllowed(w, []string{http.MethodPost}) - return - } - - vars := mux.Vars(r) - id := vars["id"] - - userID, err := strconv.Atoi(id) - if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req postUserPasswdRequest - if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err = govalidator.ValidateStruct(req) - if err != nil { - Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - var password = req.Password - - u, err := handler.UserService.User(portainer.UserID(userID)) - if err == portainer.ErrUserNotFound { - Error(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - valid := true - err = handler.CryptoService.CompareHashAndData(u.Password, password) - if err != nil { - valid = false - } - - encodeJSON(w, &postUserPasswdResponse{Valid: valid}, handler.Logger) -} - -type postUserPasswdRequest struct { - Password string `valid:"required"` -} - -type postUserPasswdResponse struct { - Valid bool `json:"valid"` -} - -// handleGetUser handles GET requests on /users/:id -func (handler *UserHandler) handleGetUser(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - userID, err := strconv.Atoi(id) - if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) - return - } - - user, err := handler.UserService.User(portainer.UserID(userID)) - if err == portainer.ErrUserNotFound { - Error(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - user.Password = "" - encodeJSON(w, &user, handler.Logger) -} - -// handlePutUser handles PUT requests on /users/:id -func (handler *UserHandler) handlePutUser(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - userID, err := strconv.Atoi(id) - if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) - return - } - - tokenData, err := extractTokenDataFromRequestContext(r) - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - } - - if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { - Error(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger) - return - } - - var req putUserRequest - if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err = govalidator.ValidateStruct(req) - if err != nil { - Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - if req.Password == "" && req.Role == 0 { - Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - user, err := handler.UserService.User(portainer.UserID(userID)) - if err == portainer.ErrUserNotFound { - Error(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if req.Password != "" { - user.Password, err = handler.CryptoService.Hash(req.Password) - if err != nil { - Error(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) - return - } - } - - if req.Role != 0 { - if tokenData.Role != portainer.AdministratorRole { - Error(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger) - return - } - if req.Role == 1 { - user.Role = portainer.AdministratorRole - } else { - user.Role = portainer.StandardUserRole - } - } - - err = handler.UserService.UpdateUser(user.ID, user) - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -type putUserRequest struct { - Password string `valid:"-"` - Role int `valid:"-"` -} - -// handlePostAdminInit handles GET requests on /users/admin/check -func (handler *UserHandler) handleGetAdminCheck(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - handleNotAllowed(w, []string{http.MethodGet}) - return - } - - users, err := handler.UserService.UsersByRole(portainer.AdministratorRole) - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - if len(users) == 0 { - Error(w, portainer.ErrUserNotFound, http.StatusNotFound, handler.Logger) - return - } -} - -// handlePostAdminInit handles POST requests on /users/admin/init -func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - handleNotAllowed(w, []string{http.MethodPost}) - return - } - - var req postAdminInitRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err := govalidator.ValidateStruct(req) - if err != nil { - Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - user, err := handler.UserService.UserByUsername("admin") - if err == portainer.ErrUserNotFound { - user := &portainer.User{ - Username: "admin", - Role: portainer.AdministratorRole, - } - user.Password, err = handler.CryptoService.Hash(req.Password) - if err != nil { - Error(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) - return - } - - err = handler.UserService.CreateUser(user) - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } else if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - if user != nil { - Error(w, portainer.ErrAdminAlreadyInitialized, http.StatusForbidden, handler.Logger) - return - } -} - -type postAdminInitRequest struct { - Password string `valid:"required"` -} - -// handleDeleteUser handles DELETE requests on /users/:id -func (handler *UserHandler) handleDeleteUser(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - userID, err := strconv.Atoi(id) - if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) - return - } - - _, err = handler.UserService.User(portainer.UserID(userID)) - - if err == portainer.ErrUserNotFound { - Error(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.UserService.DeleteUser(portainer.UserID(userID)) - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handlePostUserResource handles POST requests on /users/:userId/resources/:resourceType -func (handler *UserHandler) handlePostUserResource(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - userID := vars["userId"] - resourceType := vars["resourceType"] - - uid, err := strconv.Atoi(userID) - if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var rcType portainer.ResourceControlType - if resourceType == "container" { - rcType = portainer.ContainerResourceControl - } else if resourceType == "service" { - rcType = portainer.ServiceResourceControl - } else if resourceType == "volume" { - rcType = portainer.VolumeResourceControl - } else { - Error(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) - return - } - - tokenData, err := extractTokenDataFromRequestContext(r) - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - } - if tokenData.ID != portainer.UserID(uid) { - Error(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - var req postUserResourceRequest - if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err = govalidator.ValidateStruct(req) - if err != nil { - Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - resource := portainer.ResourceControl{ - OwnerID: portainer.UserID(uid), - ResourceID: req.ResourceID, - AccessLevel: portainer.RestrictedResourceAccessLevel, - } - - err = handler.ResourceControlService.CreateResourceControl(req.ResourceID, &resource, rcType) - if err != nil { - Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } -} - -type postUserResourceRequest struct { - ResourceID string `valid:"required"` -} - -// handleDeleteUserResource handles DELETE requests on /users/:userId/resources/:resourceType/:resourceId -func (handler *UserHandler) handleDeleteUserResource(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - userID := vars["userId"] - resourceID := vars["resourceId"] - resourceType := vars["resourceType"] - - uid, err := strconv.Atoi(userID) - if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var rcType portainer.ResourceControlType - if resourceType == "container" { - rcType = portainer.ContainerResourceControl - } else if resourceType == "service" { - rcType = portainer.ServiceResourceControl - } else if resourceType == "volume" { - rcType = portainer.VolumeResourceControl - } else { - Error(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) - return - } - - tokenData, err := extractTokenDataFromRequestContext(r) - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - } - if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(uid) { - Error(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - err = handler.ResourceControlService.DeleteResourceControl(resourceID, rcType) - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} diff --git a/api/portainer.go b/api/portainer.go index 5d6bb280f..8c1bd29b4 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -41,7 +41,7 @@ type ( EndpointManagement bool `json:"endpointManagement"` } - // User represent a user account. + // User represents a user account. User struct { ID UserID `json:"Id"` Username string `json:"Username"` @@ -53,9 +53,32 @@ type ( UserID int // UserRole represents the role of a user. It can be either an administrator - // or a regular user. + // or a regular user UserRole int + // Team represents a list of user accounts. + Team struct { + ID TeamID `json:"Id"` + Name string `json:"Name"` + } + + // TeamID represents a team identifier + TeamID int + + // TeamMembership represents a membership association between a user and a team + TeamMembership struct { + ID TeamMembershipID `json:"Id"` + UserID UserID `json:"UserID"` + TeamID TeamID `json:"TeamID"` + Role MembershipRole `json:"Role"` + } + + // TeamMembershipID represents a team membership identifier + TeamMembershipID int + + // MembershipRole represents the role of a user within a team + MembershipRole int + // TokenData represents the data embedded in a JWT token. TokenData struct { ID UserID @@ -78,21 +101,46 @@ type ( TLSCertPath string `json:"TLSCert,omitempty"` TLSKeyPath string `json:"TLSKey,omitempty"` AuthorizedUsers []UserID `json:"AuthorizedUsers"` + AuthorizedTeams []TeamID `json:"AuthorizedTeams"` } - // ResourceControl represent a reference to a Docker resource with specific controls + // ResourceControlID represents a resource control identifier. + ResourceControlID int + + // ResourceControl represent a reference to a Docker resource with specific access controls ResourceControl struct { - OwnerID UserID `json:"OwnerId"` - ResourceID string `json:"ResourceId"` + ID ResourceControlID `json:"Id"` + ResourceID string `json:"ResourceId"` + SubResourceIDs []string `json:"SubResourceIds"` + Type ResourceControlType `json:"Type"` + AdministratorsOnly bool `json:"AdministratorsOnly"` + + UserAccesses []UserResourceAccess `json:"UserAccesses"` + TeamAccesses []TeamResourceAccess `json:"TeamAccesses"` + + // Deprecated fields + // Deprecated: OwnerID field is deprecated in DBVersion == 2 + OwnerID UserID `json:"OwnerId"` + // Deprecated: AccessLevel field is deprecated in DBVersion == 2 AccessLevel ResourceAccessLevel `json:"AccessLevel"` } - // ResourceControlType represents a type of resource control. - // Can be one of: container, service or volume. + // ResourceControlType represents the type of resource associated to the resource control (volume, container, service). ResourceControlType int - // ResourceAccessLevel represents the level of control associated to a resource for a specific owner. - // Can be one of: full, restricted, limited. + // UserResourceAccess represents the level of control on a resource for a specific user. + UserResourceAccess struct { + UserID UserID `json:"UserId"` + AccessLevel ResourceAccessLevel `json:"AccessLevel"` + } + + // TeamResourceAccess represents the level of control on a resource for a specific team. + TeamResourceAccess struct { + TeamID TeamID `json:"TeamId"` + AccessLevel ResourceAccessLevel `json:"AccessLevel"` + } + + // ResourceAccessLevel represents the level of control associated to a resource. ResourceAccessLevel int // TLSFileType represents a type of TLS file required to connect to a Docker endpoint. @@ -128,6 +176,29 @@ type ( DeleteUser(ID UserID) error } + // TeamService represents a service for managing user data. + TeamService interface { + Team(ID TeamID) (*Team, error) + TeamByName(name string) (*Team, error) + Teams() ([]Team, error) + CreateTeam(team *Team) error + UpdateTeam(ID TeamID, team *Team) error + DeleteTeam(ID TeamID) error + } + + // TeamMembershipService represents a service for managing team membership data. + TeamMembershipService interface { + TeamMembership(ID TeamMembershipID) (*TeamMembership, error) + TeamMemberships() ([]TeamMembership, error) + TeamMembershipsByUserID(userID UserID) ([]TeamMembership, error) + TeamMembershipsByTeamID(teamID TeamID) ([]TeamMembership, error) + CreateTeamMembership(membership *TeamMembership) error + UpdateTeamMembership(ID TeamMembershipID, membership *TeamMembership) error + DeleteTeamMembership(ID TeamMembershipID) error + DeleteTeamMembershipByUserID(userID UserID) error + DeleteTeamMembershipByTeamID(teamID TeamID) error + } + // EndpointService represents a service for managing endpoint data. EndpointService interface { Endpoint(ID EndpointID) (*Endpoint, error) @@ -146,10 +217,12 @@ type ( // ResourceControlService represents a service for managing resource control data. ResourceControlService interface { - ResourceControl(resourceID string, rcType ResourceControlType) (*ResourceControl, error) - ResourceControls(rcType ResourceControlType) ([]ResourceControl, error) - CreateResourceControl(resourceID string, rc *ResourceControl, rcType ResourceControlType) error - DeleteResourceControl(resourceID string, rcType ResourceControlType) error + ResourceControl(ID ResourceControlID) (*ResourceControl, error) + ResourceControlByResourceID(resourceID string) (*ResourceControl, error) + ResourceControls() ([]ResourceControl, error) + CreateResourceControl(rc *ResourceControl) error + UpdateResourceControl(ID ResourceControlID, resourceControl *ResourceControl) error + DeleteResourceControl(ID ResourceControlID) error } // CryptoService represents a service for encrypting/hashing data. @@ -178,10 +251,10 @@ type ( ) const ( - // APIVersion is the version number of Portainer API. + // APIVersion is the version number of the Portainer API. APIVersion = "1.12.4" - // DBVersion is the version number of Portainer database. - DBVersion = 1 + // DBVersion is the version number of the Portainer database. + DBVersion = 2 ) const ( @@ -193,6 +266,14 @@ const ( TLSFileKey ) +const ( + _ MembershipRole = iota + // TeamLeader represents a leader role inside a team + TeamLeader + // TeamMember represents a member role inside a team + TeamMember +) + const ( _ UserRole = iota // AdministratorRole represents an administrator user role @@ -202,17 +283,17 @@ const ( ) const ( - _ ResourceControlType = iota - // ContainerResourceControl represents a resource control for a container - ContainerResourceControl - // ServiceResourceControl represents a resource control for a service - ServiceResourceControl - // VolumeResourceControl represents a resource control for a volume - VolumeResourceControl + _ ResourceAccessLevel = iota + // ReadWriteAccessLevel represents an access level with read-write permissions on a resource + ReadWriteAccessLevel ) const ( - _ ResourceAccessLevel = iota - // RestrictedResourceAccessLevel represents a restricted access level on a resource (private ownership) - RestrictedResourceAccessLevel + _ ResourceControlType = iota + // ContainerResourceControl represents a resource control associated to a Docker container + ContainerResourceControl + // ServiceResourceControl represents a resource control associated to a Docker service + ServiceResourceControl + // VolumeResourceControl represents a resource control associated to a Docker volume + VolumeResourceControl ) diff --git a/app/app.js b/app/app.js index fb3ce0604..36d9f693e 100644 --- a/app/app.js +++ b/app/app.js @@ -5,7 +5,7 @@ angular.module('portainer.helpers', []); angular.module('portainer', [ 'ui.bootstrap', 'ui.router', - 'ui.select', + 'isteven-multi-select', 'ngCookies', 'ngSanitize', 'ngFileUpload', @@ -20,6 +20,8 @@ angular.module('portainer', [ 'portainer.services', 'auth', 'dashboard', + 'common.accesscontrol.panel', + 'common.accesscontrol.form', 'container', 'containerConsole', 'containerLogs', @@ -47,9 +49,12 @@ angular.module('portainer', [ 'stats', 'swarm', 'task', + 'team', + 'teams', 'templates', 'user', 'users', + 'volume', 'volumes']) .config(['$stateProvider', '$urlRouterProvider', '$httpProvider', 'localStorageServiceProvider', 'jwtOptionsProvider', 'AnalyticsProvider', '$uibTooltipProvider', '$compileProvider', function ($stateProvider, $urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider, AnalyticsProvider, $uibTooltipProvider, $compileProvider) { 'use strict'; @@ -508,6 +513,19 @@ angular.module('portainer', [ } } }) + .state('volume', { + url: '^/volumes/:id', + views: { + 'content@': { + templateUrl: 'app/components/volume/volume.html', + controller: 'VolumeController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) .state('users', { url: '/users/', views: { @@ -534,6 +552,32 @@ angular.module('portainer', [ } } }) + .state('teams', { + url: '/teams/', + views: { + 'content@': { + templateUrl: 'app/components/teams/teams.html', + controller: 'TeamsController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('team', { + url: '^/teams/:id', + views: { + 'content@': { + templateUrl: 'app/components/team/team.html', + controller: 'TeamController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) .state('swarm', { url: '/swarm/', views: { @@ -581,6 +625,9 @@ angular.module('portainer', [ .constant('CONFIG_ENDPOINT', 'api/settings') .constant('AUTH_ENDPOINT', 'api/auth') .constant('USERS_ENDPOINT', 'api/users') + .constant('TEAMS_ENDPOINT', 'api/teams') + .constant('TEAM_MEMBERSHIPS_ENDPOINT', 'api/team_memberships') + .constant('RESOURCE_CONTROL_ENDPOINT', 'api/resource_controls') .constant('ENDPOINTS_ENDPOINT', 'api/endpoints') .constant('TEMPLATES_ENDPOINT', 'api/templates') .constant('PAGINATION_MAX_ITEMS', 10) diff --git a/app/components/auth/authController.js b/app/components/auth/authController.js index 39b64d5c4..d6edeff95 100644 --- a/app/components/auth/authController.js +++ b/app/components/auth/authController.js @@ -26,14 +26,14 @@ function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Au .then(function success() { $state.go('dashboard'); }, function error(err) { - Notifications.error("Failure", err, 'Unable to connect to the Docker endpoint'); + Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint'); }); } else { $state.go('endpointInit'); } }, function error(err) { - Notifications.error("Failure", err, 'Unable to retrieve endpoints'); + Notifications.error('Failure', err, 'Unable to retrieve endpoints'); }); } else { Users.checkAdminUser({}, function () {}, @@ -41,7 +41,7 @@ function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Au if (e.status === 404) { $scope.initPassword = true; } else { - Notifications.error("Failure", e, 'Unable to verify administrator account existence'); + Notifications.error('Failure', e, 'Unable to verify administrator account existence'); } }); } @@ -98,7 +98,7 @@ function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Au .then(function success() { $state.go('dashboard'); }, function error(err) { - Notifications.error("Failure", err, 'Unable to connect to the Docker endpoint'); + Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint'); }); } else if (data.length === 0 && userDetails.role === 1) { diff --git a/app/components/common/accessControlForm/accessControlForm.html b/app/components/common/accessControlForm/accessControlForm.html new file mode 100644 index 000000000..70961858e --- /dev/null +++ b/app/components/common/accessControlForm/accessControlForm.html @@ -0,0 +1,126 @@ +
+
+ Access control +
+ +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + + You have not yet created any team. Head over the teams view to manage user teams. + + +
+
+ + +
+
+ + + You have not yet created any user. Head over the users view to manage users. + + +
+
+ +
diff --git a/app/components/common/accessControlForm/accessControlFormController.js b/app/components/common/accessControlForm/accessControlFormController.js new file mode 100644 index 000000000..2656b900e --- /dev/null +++ b/app/components/common/accessControlForm/accessControlFormController.js @@ -0,0 +1,55 @@ +angular.module('common.accesscontrol.form', []) +.controller('AccessControlFormController', ['$q', '$scope', '$state', 'UserService', 'ResourceControlService', 'Notifications', 'Authentication', 'ModalService', 'ControllerDataPipeline', +function ($q, $scope, $state, UserService, ResourceControlService, Notifications, Authentication, ModalService, ControllerDataPipeline) { + + $scope.availableTeams = []; + $scope.availableUsers = []; + + $scope.formValues = { + enableAccessControl: true, + Ownership_Teams: [], + Ownership_Users: [], + Ownership: 'private' + }; + + $scope.synchronizeFormData = function() { + ControllerDataPipeline.setAccessControlFormData($scope.formValues.enableAccessControl, + $scope.formValues.Ownership, $scope.formValues.Ownership_Users, $scope.formValues.Ownership_Teams); + }; + + function initAccessControlForm() { + $('#loadingViewSpinner').show(); + + var userDetails = Authentication.getUserDetails(); + var isAdmin = userDetails.role === 1 ? true: false; + $scope.isAdmin = isAdmin; + + if (isAdmin) { + $scope.formValues.Ownership = 'administrators'; + } + + $q.all({ + availableTeams: UserService.userTeams(userDetails.ID), + availableUsers: isAdmin ? UserService.users(false) : [] + }) + .then(function success(data) { + $scope.availableUsers = data.availableUsers; + + var availableTeams = data.availableTeams; + $scope.availableTeams = availableTeams; + if (!isAdmin && availableTeams.length === 1) { + $scope.formValues.Ownership_Teams = availableTeams; + } + + $scope.synchronizeFormData(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve access control information'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + initAccessControlForm(); +}]); diff --git a/app/components/common/accessControlPanel/accessControlPanel.html b/app/components/common/accessControlPanel/accessControlPanel.html new file mode 100644 index 000000000..c4339d38b --- /dev/null +++ b/app/components/common/accessControlPanel/accessControlPanel.html @@ -0,0 +1,178 @@ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Ownership + + + public + + + + {{ resourceControl.Ownership }} + + + + +
+ + Access control on this resource is inherited from the following service: {{ resourceControl.ResourceId | truncate }} + +
+ + Access control on this resource is inherited from the following container: {{ resourceControl.ResourceId | truncate }} + +
Authorized users + {{user.Username}}{{$last ? '' : ', '}} +
Authorized teams + {{team.Name}}{{$last ? '' : ', '}} +
+ Change ownership +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ Teams + + You have not yet created any team. Head over the teams view to manage user teams. + + +
+ Users + + You have not yet created any user. Head over the users view to manage users. + + +
+
+ Cancel + Update ownership + {{ state.formValidationError }} +
+
+
+
+
+
diff --git a/app/components/common/accessControlPanel/accessControlPanelController.js b/app/components/common/accessControlPanel/accessControlPanelController.js new file mode 100644 index 000000000..8283b6d97 --- /dev/null +++ b/app/components/common/accessControlPanel/accessControlPanelController.js @@ -0,0 +1,158 @@ +angular.module('common.accesscontrol.panel', []) +.controller('AccessControlPanelController', ['$q', '$scope', '$state', 'UserService', 'ResourceControlService', 'Notifications', 'Authentication', 'ModalService', 'ControllerDataPipeline', 'FormValidator', +function ($q, $scope, $state, UserService, ResourceControlService, Notifications, Authentication, ModalService, ControllerDataPipeline, FormValidator) { + + $scope.state = { + displayAccessControlPanel: false, + canEditOwnership: false, + editOwnership: false, + formValidationError: '' + }; + + $scope.formValues = { + Ownership: 'public', + Ownership_Users: [], + Ownership_Teams: [] + }; + + $scope.authorizedUsers = []; + $scope.availableUsers = []; + $scope.authorizedTeams = []; + $scope.availableTeams = []; + + $scope.confirmUpdateOwnership = function (force) { + if (!validateForm()) { + return; + } + ModalService.confirmAccessControlUpdate(function (confirmed) { + if(!confirmed) { return; } + updateOwnership(); + }); + }; + + function processOwnershipFormValues() { + var userIds = []; + angular.forEach($scope.formValues.Ownership_Users, function(user) { + userIds.push(user.Id); + }); + var teamIds = []; + angular.forEach($scope.formValues.Ownership_Teams, function(team) { + teamIds.push(team.Id); + }); + var administratorsOnly = $scope.formValues.Ownership === 'administrators' ? true : false; + + return { + ownership: $scope.formValues.Ownership, + authorizedUserIds: administratorsOnly ? [] : userIds, + authorizedTeamIds: administratorsOnly ? [] : teamIds, + administratorsOnly: administratorsOnly + }; + } + + function validateForm() { + $scope.state.formValidationError = ''; + var error = ''; + + var accessControlData = { + ownership: $scope.formValues.Ownership, + authorizedUsers: $scope.formValues.Ownership_Users, + authorizedTeams: $scope.formValues.Ownership_Teams + }; + var isAdmin = $scope.isAdmin; + error = FormValidator.validateAccessControl(accessControlData, isAdmin); + if (error) { + $scope.state.formValidationError = error; + return false; + } + return true; + } + + function updateOwnership() { + $('#loadingViewSpinner').show(); + + var accessControlData = ControllerDataPipeline.getAccessControlData(); + var resourceId = accessControlData.resourceId; + var ownershipParameters = processOwnershipFormValues(); + + ResourceControlService.applyResourceControlChange(accessControlData.resourceType, resourceId, + $scope.resourceControl, ownershipParameters) + .then(function success(data) { + Notifications.success('Access control successfully updated'); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update access control'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + function initAccessControlPanel() { + $('#loadingViewSpinner').show(); + + var userDetails = Authentication.getUserDetails(); + var isAdmin = userDetails.role === 1 ? true: false; + var userId = userDetails.ID; + $scope.isAdmin = isAdmin; + + var accessControlData = ControllerDataPipeline.getAccessControlData(); + var resourceControl = accessControlData.resourceControl; + $scope.resourceType = accessControlData.resourceType; + $scope.resourceControl = resourceControl; + + if (isAdmin) { + if (resourceControl) { + $scope.formValues.Ownership = resourceControl.Ownership === 'private' ? 'restricted' : resourceControl.Ownership; + } else { + $scope.formValues.Ownership = 'public'; + } + } else { + $scope.formValues.Ownership = 'public'; + } + + ResourceControlService.retrieveOwnershipDetails(resourceControl) + .then(function success(data) { + $scope.authorizedUsers = data.authorizedUsers; + $scope.authorizedTeams = data.authorizedTeams; + return ResourceControlService.retrieveUserPermissionsOnResource(userId, isAdmin, resourceControl); + }) + .then(function success(data) { + $scope.state.canEditOwnership = data.isPartOfRestrictedUsers || data.isLeaderOfAnyRestrictedTeams; + $scope.state.canChangeOwnershipToTeam = data.isPartOfRestrictedUsers; + + return $q.all({ + availableUsers: isAdmin ? UserService.users(false) : [], + availableTeams: isAdmin || data.isPartOfRestrictedUsers ? UserService.userTeams(userId) : [] + }); + }) + .then(function success(data) { + $scope.availableUsers = data.availableUsers; + angular.forEach($scope.availableUsers, function(user) { + var found = _.find($scope.authorizedUsers, { Id: user.Id }); + if (found) { + user.selected = true; + } + }); + $scope.availableTeams = data.availableTeams; + angular.forEach(data.availableTeams, function(team) { + var found = _.find($scope.authorizedTeams, { Id: team.Id }); + if (found) { + team.selected = true; + } + }); + if (data.availableTeams.length === 1) { + $scope.formValues.Ownership_Teams.push(data.availableTeams[0]); + } + $scope.state.displayAccessControlPanel = true; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve access control information'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + initAccessControlPanel(); +}]); diff --git a/app/components/container/container.html b/app/components/container/container.html index d87fab614..735fb8f1a 100644 --- a/app/components/container/container.html +++ b/app/components/container/container.html @@ -87,6 +87,8 @@ +
+
diff --git a/app/components/container/containerController.js b/app/components/container/containerController.js index 66b3b25cc..d739a6c04 100644 --- a/app/components/container/containerController.js +++ b/app/components/container/containerController.js @@ -1,6 +1,6 @@ angular.module('container', []) -.controller('ContainerController', ['$scope', '$state','$stateParams', '$filter', 'Container', 'ContainerCommit', 'ImageHelper', 'Network', 'Notifications', 'Pagination', 'ModalService', -function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, ImageHelper, Network, Notifications, Pagination, ModalService) { +.controller('ContainerController', ['$scope', '$state','$stateParams', '$filter', 'Container', 'ContainerCommit', 'ContainerService', 'ImageHelper', 'Network', 'Notifications', 'Pagination', 'ModalService', 'ControllerDataPipeline', +function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, ContainerService, ImageHelper, Network, Notifications, Pagination, ModalService, ControllerDataPipeline) { $scope.activityTime = 0; $scope.portBindings = []; $scope.config = { @@ -17,25 +17,27 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima var update = function () { $('#loadingViewSpinner').show(); Container.get({id: $stateParams.id}, function (d) { - $scope.container = d; + var container = new ContainerDetailsViewModel(d); + $scope.container = container; + ControllerDataPipeline.setAccessControlData('container', $stateParams.id, container.ResourceControl); $scope.container.edit = false; - $scope.container.newContainerName = $filter('trimcontainername')(d.Name); + $scope.container.newContainerName = $filter('trimcontainername')(container.Name); - if (d.State.Running) { - $scope.activityTime = moment.duration(moment(d.State.StartedAt).utc().diff(moment().utc())).humanize(); - } else if (d.State.Status === "created") { - $scope.activityTime = moment.duration(moment(d.Created).utc().diff(moment().utc())).humanize(); + if (container.State.Running) { + $scope.activityTime = moment.duration(moment(container.State.StartedAt).utc().diff(moment().utc())).humanize(); + } else if (container.State.Status === 'created') { + $scope.activityTime = moment.duration(moment(container.Created).utc().diff(moment().utc())).humanize(); } else { - $scope.activityTime = moment.duration(moment().utc().diff(moment(d.State.FinishedAt).utc())).humanize(); + $scope.activityTime = moment.duration(moment().utc().diff(moment(container.State.FinishedAt).utc())).humanize(); } $scope.portBindings = []; - if (d.NetworkSettings.Ports) { - angular.forEach(Object.keys(d.NetworkSettings.Ports), function(portMapping) { - if (d.NetworkSettings.Ports[portMapping]) { + if (container.NetworkSettings.Ports) { + angular.forEach(Object.keys(container.NetworkSettings.Ports), function(portMapping) { + if (container.NetworkSettings.Ports[portMapping]) { var mapping = {}; mapping.container = portMapping; - mapping.host = d.NetworkSettings.Ports[portMapping][0].HostIp + ':' + d.NetworkSettings.Ports[portMapping][0].HostPort; + mapping.host = container.NetworkSettings.Ports[portMapping][0].HostIp + ':' + container.NetworkSettings.Ports[portMapping][0].HostPort; $scope.portBindings.push(mapping); } }); @@ -43,7 +45,7 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima $('#loadingViewSpinner').hide(); }, function (e) { $('#loadingViewSpinner').hide(); - Notifications.error("Failure", e, "Unable to retrieve container info"); + Notifications.error('Failure', e, 'Unable to retrieve container info'); }); }; @@ -51,10 +53,10 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima $('#loadingViewSpinner').show(); Container.start({id: $scope.container.Id}, {}, function (d) { update(); - Notifications.success("Container started", $stateParams.id); + Notifications.success('Container started', $stateParams.id); }, function (e) { update(); - Notifications.error("Failure", e, "Unable to start container"); + Notifications.error('Failure', e, 'Unable to start container'); }); }; @@ -62,10 +64,10 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima $('#loadingViewSpinner').show(); Container.stop({id: $stateParams.id}, function (d) { update(); - Notifications.success("Container stopped", $stateParams.id); + Notifications.success('Container stopped', $stateParams.id); }, function (e) { update(); - Notifications.error("Failure", e, "Unable to stop container"); + Notifications.error('Failure', e, 'Unable to stop container'); }); }; @@ -73,10 +75,10 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima $('#loadingViewSpinner').show(); Container.kill({id: $stateParams.id}, function (d) { update(); - Notifications.success("Container killed", $stateParams.id); + Notifications.success('Container killed', $stateParams.id); }, function (e) { update(); - Notifications.error("Failure", e, "Unable to kill container"); + Notifications.error('Failure', e, 'Unable to kill container'); }); }; @@ -88,11 +90,11 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima ContainerCommit.commit({id: $stateParams.id, tag: imageConfig.tag, repo: imageConfig.repo}, function (d) { $('#createImageSpinner').hide(); update(); - Notifications.success("Container commited", $stateParams.id); + Notifications.success('Container commited', $stateParams.id); }, function (e) { $('#createImageSpinner').hide(); update(); - Notifications.error("Failure", e, "Unable to commit container"); + Notifications.error('Failure', e, 'Unable to commit container'); }); }; @@ -100,10 +102,10 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima $('#loadingViewSpinner').show(); Container.pause({id: $stateParams.id}, function (d) { update(); - Notifications.success("Container paused", $stateParams.id); + Notifications.success('Container paused', $stateParams.id); }, function (e) { update(); - Notifications.error("Failure", e, "Unable to pause container"); + Notifications.error('Failure', e, 'Unable to pause container'); }); }; @@ -111,10 +113,10 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima $('#loadingViewSpinner').show(); Container.unpause({id: $stateParams.id}, function (d) { update(); - Notifications.success("Container unpaused", $stateParams.id); + Notifications.success('Container unpaused', $stateParams.id); }, function (e) { update(); - Notifications.error("Failure", e, "Unable to unpause container"); + Notifications.error('Failure', e, 'Unable to unpause container'); }); }; @@ -138,18 +140,16 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima $scope.remove = function(cleanAssociatedVolumes) { $('#loadingViewSpinner').show(); - Container.remove({id: $stateParams.id, v: (cleanAssociatedVolumes) ? 1 : 0, force: true}, function (d) { - if (d.message) { - $('#loadingViewSpinner').hide(); - Notifications.error("Failure", d, "Unable to remove container"); - } - else { - $state.go('containers', {}, {reload: true}); - Notifications.success("Container removed", $stateParams.id); - } - }, function (e) { - update(); - Notifications.error("Failure", e, "Unable to remove container"); + ContainerService.remove($scope.container, cleanAssociatedVolumes) + .then(function success() { + Notifications.success('Container successfully removed'); + $state.go('containers', {}, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove container'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); }); }; @@ -157,24 +157,24 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima $('#loadingViewSpinner').show(); Container.restart({id: $stateParams.id}, function (d) { update(); - Notifications.success("Container restarted", $stateParams.id); + Notifications.success('Container restarted', $stateParams.id); }, function (e) { update(); - Notifications.error("Failure", e, "Unable to restart container"); + Notifications.error('Failure', e, 'Unable to restart container'); }); }; $scope.renameContainer = function () { Container.rename({id: $stateParams.id, 'name': $scope.container.newContainerName}, function (d) { - if (d.message) { + if (container.message) { $scope.container.newContainerName = $scope.container.Name; - Notifications.error("Unable to rename container", {}, d.message); + Notifications.error('Unable to rename container', {}, container.message); } else { $scope.container.Name = $scope.container.newContainerName; - Notifications.success("Container successfully renamed", d.name); + Notifications.success('Container successfully renamed', container.name); } }, function (e) { - Notifications.error("Failure", e, 'Unable to rename container'); + Notifications.error('Failure', e, 'Unable to rename container'); }); $scope.container.edit = false; }; @@ -182,17 +182,17 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima $scope.containerLeaveNetwork = function containerLeaveNetwork(container, networkId) { $('#loadingViewSpinner').show(); Network.disconnect({id: networkId}, { Container: $stateParams.id, Force: false }, function (d) { - if (d.message) { + if (container.message) { $('#loadingViewSpinner').hide(); - Notifications.error("Error", d, "Unable to disconnect container from network"); + Notifications.error('Error', d, 'Unable to disconnect container from network'); } else { $('#loadingViewSpinner').hide(); - Notifications.success("Container left network", $stateParams.id); + Notifications.success('Container left network', $stateParams.id); $state.go('container', {id: $stateParams.id}, {reload: true}); } }, function (e) { $('#loadingViewSpinner').hide(); - Notifications.error("Failure", e, "Unable to disconnect container from network"); + Notifications.error('Failure', e, 'Unable to disconnect container from network'); }); }; diff --git a/app/components/containerConsole/containerConsoleController.js b/app/components/containerConsole/containerConsoleController.js index 9b731f9a7..d00881c6f 100644 --- a/app/components/containerConsole/containerConsoleController.js +++ b/app/components/containerConsole/containerConsoleController.js @@ -17,7 +17,7 @@ function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, Endp Container.get({id: $stateParams.id}, function(d) { $scope.container = d; if (d.message) { - Notifications.error("Error", d, 'Unable to retrieve container details'); + Notifications.error('Error', d, 'Unable to retrieve container details'); $('#loadingViewSpinner').hide(); } else { Image.get({id: d.Image}, function(imgData) { @@ -26,12 +26,12 @@ function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, Endp $scope.state.loaded = true; $('#loadingViewSpinner').hide(); }, function (e) { - Notifications.error("Failure", e, 'Unable to retrieve image details'); + Notifications.error('Failure', e, 'Unable to retrieve image details'); $('#loadingViewSpinner').hide(); }); } }, function (e) { - Notifications.error("Failure", e, 'Unable to retrieve container details'); + Notifications.error('Failure', e, 'Unable to retrieve container details'); $('#loadingViewSpinner').hide(); }); @@ -45,13 +45,13 @@ function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, Endp AttachStdout: true, AttachStderr: true, Tty: true, - Cmd: $scope.state.command.replace(" ", ",").split(",") + Cmd: $scope.state.command.replace(' ', ',').split(',') }; Container.exec(execConfig, function(d) { if (d.message) { $('#loadConsoleSpinner').hide(); - Notifications.error("Error", {}, d.message); + Notifications.error('Error', {}, d.message); } else { var execId = d.Id; resizeTTY(execId, termHeight, termWidth); @@ -65,7 +65,7 @@ function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, Endp } }, function (e) { $('#loadConsoleSpinner').hide(); - Notifications.error("Failure", e, 'Unable to start an exec instance'); + Notifications.error('Failure', e, 'Unable to start an exec instance'); }); }; @@ -86,7 +86,7 @@ function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, Endp Notifications.error('Error', {}, 'Unable to resize TTY'); } }, function (e) { - Notifications.error("Failure", {}, 'Unable to resize TTY'); + Notifications.error('Failure', {}, 'Unable to resize TTY'); }); }, 2000); diff --git a/app/components/containerLogs/containerLogsController.js b/app/components/containerLogs/containerLogsController.js index cf92fbb72..86c2eb398 100644 --- a/app/components/containerLogs/containerLogsController.js +++ b/app/components/containerLogs/containerLogsController.js @@ -14,7 +14,7 @@ function ($scope, $stateParams, $anchorScroll, ContainerLogs, Container) { $('#loadingViewSpinner').hide(); }, function (e) { $('#loadingViewSpinner').hide(); - Notifications.error("Failure", e, "Unable to retrieve container info"); + Notifications.error('Failure', e, 'Unable to retrieve container info'); }); function getLogs() { @@ -60,7 +60,7 @@ function ($scope, $stateParams, $anchorScroll, ContainerLogs, Container) { getLogs(); var logIntervalId = window.setInterval(getLogs, 5000); - $scope.$on("$destroy", function () { + $scope.$on('$destroy', function () { // clearing interval when view changes clearInterval(logIntervalId); }); diff --git a/app/components/containers/containers.html b/app/components/containers/containers.html index 2f85dd83e..9d5a86722 100644 --- a/app/components/containers/containers.html +++ b/app/components/containers/containers.html @@ -91,10 +91,10 @@ - + Ownership - - + + @@ -118,34 +118,9 @@ - - - - - Public service - - - Public - - - - - - Private service - - - Private - Switch to public - - - - - - Private service (owner: {{ container.Owner }}) - - - Private (owner: {{ container.Owner }}) - Switch to public - + + + {{ container.ResourceControl.Ownership ? container.ResourceControl.Ownership : container.ResourceControl.Ownership = 'public' }} diff --git a/app/components/containers/containersController.js b/app/components/containers/containersController.js index 3d11631f2..01c50acb9 100644 --- a/app/components/containers/containersController.js +++ b/app/components/containers/containersController.js @@ -1,6 +1,6 @@ angular.module('containers', []) - .controller('ContainersController', ['$q', '$scope', '$filter', 'Container', 'ContainerHelper', 'Info', 'Settings', 'Notifications', 'Config', 'Pagination', 'EntityListService', 'ModalService', 'Authentication', 'ResourceControlService', 'UserService', 'EndpointProvider', - function ($q, $scope, $filter, Container, ContainerHelper, Info, Settings, Notifications, Config, Pagination, EntityListService, ModalService, Authentication, ResourceControlService, UserService, EndpointProvider) { + .controller('ContainersController', ['$q', '$scope', '$filter', 'Container', 'ContainerService', 'ContainerHelper', 'Info', 'Settings', 'Notifications', 'Config', 'Pagination', 'EntityListService', 'ModalService', 'ResourceControlService', 'EndpointProvider', + function ($q, $scope, $filter, Container, ContainerService, ContainerHelper, Info, Settings, Notifications, Config, Pagination, EntityListService, ModalService, ResourceControlService, EndpointProvider) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('containers'); $scope.state.displayAll = Settings.displayAll; @@ -20,51 +20,8 @@ angular.module('containers', []) $scope.cleanAssociatedVolumes = false; - function removeContainerResourceControl(container) { - volumeResourceControlQueries = []; - angular.forEach(container.Mounts, function (volume) { - volumeResourceControlQueries.push(ResourceControlService.removeVolumeResourceControl(container.Metadata.ResourceControl.OwnerId, volume.Name)); - }); - - $q.all(volumeResourceControlQueries) - .then(function success() { - return ResourceControlService.removeContainerResourceControl(container.Metadata.ResourceControl.OwnerId, container.Id); - }) - .then(function success() { - delete container.Metadata.ResourceControl; - Notifications.success('Ownership changed to public', container.Id); - }) - .catch(function error(err) { - Notifications.error("Failure", err, "Unable to change container ownership"); - }); - } - - $scope.switchOwnership = function(container) { - ModalService.confirmContainerOwnershipChange(function (confirmed) { - if(!confirmed) { return; } - removeContainerResourceControl(container); - }); - }; - - function mapUsersToContainers(users) { - angular.forEach($scope.containers, function (container) { - if (container.Metadata) { - var containerRC = container.Metadata.ResourceControl; - if (containerRC && containerRC.OwnerId !== $scope.user.ID) { - angular.forEach(users, function (user) { - if (containerRC.OwnerId === user.Id) { - container.Owner = user.Username; - } - }); - } - } - }); - } - var update = function (data) { $('#loadContainersSpinner').show(); - var userDetails = Authentication.getUserDetails(); - $scope.user = userDetails; $scope.state.selectedItemCount = 0; Container.query(data, function (d) { var containers = d; @@ -87,23 +44,10 @@ angular.module('containers', []) } return model; }); - if (userDetails.role === 1) { - UserService.users() - .then(function success(data) { - mapUsersToContainers(data); - }) - .catch(function error(err) { - Notifications.error("Failure", err, "Unable to retrieve users"); - }) - .finally(function final() { - $('#loadContainersSpinner').hide(); - }); - } else { - $('#loadContainersSpinner').hide(); - } + $('#loadContainersSpinner').hide(); }, function (e) { $('#loadContainersSpinner').hide(); - Notifications.error("Failure", e, "Unable to retrieve containers"); + Notifications.error('Failure', e, 'Unable to retrieve containers'); $scope.containers = []; }); }; @@ -123,56 +67,44 @@ angular.module('containers', []) counter = counter + 1; if (action === Container.start) { action({id: c.Id}, {}, function (d) { - Notifications.success("Container " + msg, c.Id); + Notifications.success('Container ' + msg, c.Id); complete(); }, function (e) { - Notifications.error("Failure", e, "Unable to start container"); + Notifications.error('Failure', e, 'Unable to start container'); complete(); }); } else if (action === Container.remove) { - action({id: c.Id, v: ($scope.cleanAssociatedVolumes) ? 1 : 0, force: true}, function (d) { - if (d.message) { - Notifications.error("Error", d, "Unable to remove container"); - } - else { - if (c.Metadata && c.Metadata.ResourceControl) { - ResourceControlService.removeContainerResourceControl(c.Metadata.ResourceControl.OwnerId, c.Id) - .then(function success() { - Notifications.success("Container " + msg, c.Id); - }) - .catch(function error(err) { - Notifications.error("Failure", err, "Unable to remove container ownership"); - }); - } else { - Notifications.success("Container " + msg, c.Id); - } - } - complete(); - }, function (e) { - Notifications.error("Failure", e, 'Unable to remove container'); + ContainerService.remove(c, $scope.cleanAssociatedVolumes) + .then(function success() { + Notifications.success('Container successfully removed'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove container'); + }) + .finally(function final() { complete(); }); } else if (action === Container.pause) { action({id: c.Id}, function (d) { if (d.message) { - Notifications.success("Container is already paused", c.Id); + Notifications.success('Container is already paused', c.Id); } else { - Notifications.success("Container " + msg, c.Id); + Notifications.success('Container ' + msg, c.Id); } complete(); }, function (e) { - Notifications.error("Failure", e, 'Unable to pause container'); + Notifications.error('Failure', e, 'Unable to pause container'); complete(); }); } else { action({id: c.Id}, function (d) { - Notifications.success("Container " + msg, c.Id); + Notifications.success('Container ' + msg, c.Id); complete(); }, function (e) { - Notifications.error("Failure", e, 'An error occured'); + Notifications.error('Failure', e, 'An error occured'); complete(); }); @@ -207,31 +139,31 @@ angular.module('containers', []) }; $scope.startAction = function () { - batch($scope.containers, Container.start, "Started"); + batch($scope.containers, Container.start, 'Started'); }; $scope.stopAction = function () { - batch($scope.containers, Container.stop, "Stopped"); + batch($scope.containers, Container.stop, 'Stopped'); }; $scope.restartAction = function () { - batch($scope.containers, Container.restart, "Restarted"); + batch($scope.containers, Container.restart, 'Restarted'); }; $scope.killAction = function () { - batch($scope.containers, Container.kill, "Killed"); + batch($scope.containers, Container.kill, 'Killed'); }; $scope.pauseAction = function () { - batch($scope.containers, Container.pause, "Paused"); + batch($scope.containers, Container.pause, 'Paused'); }; $scope.unpauseAction = function () { - batch($scope.containers, Container.unpause, "Unpaused"); + batch($scope.containers, Container.unpause, 'Unpaused'); }; $scope.removeAction = function () { - batch($scope.containers, Container.remove, "Removed"); + batch($scope.containers, Container.remove, 'Removed'); }; $scope.confirmRemoveAction = function () { diff --git a/app/components/createContainer/createContainerController.js b/app/components/createContainer/createContainerController.js index 09808ad01..3f8bfe73a 100644 --- a/app/components/createContainer/createContainerController.js +++ b/app/components/createContainer/createContainerController.js @@ -1,11 +1,10 @@ // @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services. // See app/components/templates/templatesController.js as a reference. angular.module('createContainer', []) -.controller('CreateContainerController', ['$scope', '$state', '$stateParams', '$filter', 'Config', 'Info', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'Network', 'ResourceControlService', 'Authentication', 'Notifications', -function ($scope, $state, $stateParams, $filter, Config, Info, Container, ContainerHelper, Image, ImageHelper, Volume, Network, ResourceControlService, Authentication, Notifications) { +.controller('CreateContainerController', ['$q', '$scope', '$state', '$stateParams', '$filter', 'Config', 'Info', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'Network', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'ControllerDataPipeline', 'FormValidator', +function ($q, $scope, $state, $stateParams, $filter, Config, Info, Container, ContainerHelper, Image, ImageHelper, Volume, Network, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, ControllerDataPipeline, FormValidator) { $scope.formValues = { - Ownership: $scope.applicationState.application.authentication ? 'private' : '', alwaysPull: true, Console: 'none', Volumes: [], @@ -17,7 +16,9 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai IPv6: '' }; - $scope.imageConfig = {}; + $scope.state = { + formValidationError: '' + }; $scope.config = { Image: '', @@ -81,7 +82,7 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai $scope.removeExtraHost = function(index) { $scope.formValues.ExtraHosts.splice(index, 1); }; - + $scope.addDevice = function() { $scope.config.HostConfig.Devices.push({ pathOnHost: '', pathInContainer: '' }); }; @@ -90,98 +91,6 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai $scope.config.HostConfig.Devices.splice(index, 1); }; - Config.$promise.then(function (c) { - var containersToHideLabels = c.hiddenLabels; - - Volume.query({}, function (d) { - $scope.availableVolumes = d.Volumes; - }, function (e) { - Notifications.error("Failure", e, "Unable to retrieve volumes"); - }); - - Network.query({}, function (d) { - var networks = d; - if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM' || $scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') { - networks = d.filter(function (network) { - if (network.Scope === 'global') { - return network; - } - }); - $scope.globalNetworkCount = networks.length; - networks.push({Name: "bridge"}); - networks.push({Name: "host"}); - networks.push({Name: "none"}); - } - networks.push({Name: "container"}); - $scope.availableNetworks = networks; - if (!_.find(networks, {'Name': 'bridge'})) { - $scope.config.HostConfig.NetworkMode = 'nat'; - } - }, function (e) { - Notifications.error("Failure", e, "Unable to retrieve networks"); - }); - - Container.query({}, function (d) { - var containers = d; - if (containersToHideLabels) { - containers = ContainerHelper.hideContainers(d, containersToHideLabels); - } - $scope.runningContainers = containers; - }, function(e) { - Notifications.error("Failure", e, "Unable to retrieve running containers"); - }); - }); - - function startContainer(containerID) { - Container.start({id: containerID}, {}, function (cd) { - if (cd.message) { - $('#createContainerSpinner').hide(); - Notifications.error('Error', {}, cd.message); - } else { - $('#createContainerSpinner').hide(); - Notifications.success('Container Started', containerID); - $state.go('containers', {}, {reload: true}); - } - }, function (e) { - $('#createContainerSpinner').hide(); - Notifications.error("Failure", e, 'Unable to start container'); - }); - } - - function createContainer(config) { - Container.create(config, function (d) { - if (d.message) { - $('#createContainerSpinner').hide(); - Notifications.error('Error', {}, d.message); - } else { - if ($scope.formValues.Ownership === 'private') { - ResourceControlService.setContainerResourceControl(Authentication.getUserDetails().ID, d.Id) - .then(function success() { - startContainer(d.Id); - }) - .catch(function error(err) { - $('#createContainerSpinner').hide(); - Notifications.error("Failure", err, 'Unable to apply resource control on container'); - }); - } else { - startContainer(d.Id); - } - } - }, function (e) { - $('#createContainerSpinner').hide(); - Notifications.error("Failure", e, 'Unable to create container'); - }); - } - - function pullImageAndCreateContainer(config) { - Image.create($scope.imageConfig, function (data) { - createContainer(config); - }, function (e) { - $('#createContainerSpinner').hide(); - Notifications.error('Failure', e, 'Unable to pull image'); - }); - } - function prepareImageConfig(config) { var image = config.Image; var registry = $scope.formValues.Registry; @@ -194,7 +103,7 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai var bindings = {}; config.HostConfig.PortBindings.forEach(function (portBinding) { if (portBinding.containerPort) { - var key = portBinding.containerPort + "/" + portBinding.protocol; + var key = portBinding.containerPort + '/' + portBinding.protocol; var binding = {}; if (portBinding.hostPort && portBinding.hostPort.indexOf(':') > -1) { var hostAndPort = portBinding.hostPort.split(':'); @@ -230,7 +139,7 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai var env = []; config.Env.forEach(function (v) { if (v.name && v.value) { - env.push(v.name + "=" + v.value); + env.push(v.name + '=' + v.value); } }); config.Env = env; @@ -295,7 +204,7 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai }); config.Labels = labels; } - + function prepareDevices(config) { var path = []; config.HostConfig.Devices.forEach(function (p) { @@ -303,10 +212,10 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai if(p.pathInContainer === '') { p.pathInContainer = p.pathOnHost; } - path.push({PathOnHost:p.pathOnHost,PathInContainer:p.pathInContainer,CgroupPermissions:'rwm'}); + path.push({PathOnHost:p.pathOnHost,PathInContainer:p.pathInContainer,CgroupPermissions:'rwm'}); } }); - config.HostConfig.Devices = path; + config.HostConfig.Devices = path; } function prepareConfiguration() { @@ -323,13 +232,100 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai return config; } - $scope.create = function () { - var config = prepareConfiguration(); - $('#createContainerSpinner').show(); - if ($scope.formValues.alwaysPull) { - pullImageAndCreateContainer(config); - } else { - createContainer(config); + function initView() { + Config.$promise.then(function (c) { + var containersToHideLabels = c.hiddenLabels; + + Volume.query({}, function (d) { + $scope.availableVolumes = d.Volumes; + }, function (e) { + Notifications.error('Failure', e, 'Unable to retrieve volumes'); + }); + + Network.query({}, function (d) { + var networks = d; + if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM' || $scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') { + networks = d.filter(function (network) { + if (network.Scope === 'global') { + return network; + } + }); + $scope.globalNetworkCount = networks.length; + networks.push({Name: 'bridge'}); + networks.push({Name: 'host'}); + networks.push({Name: 'none'}); + } + networks.push({Name: 'container'}); + $scope.availableNetworks = networks; + if (!_.find(networks, {'Name': 'bridge'})) { + $scope.config.HostConfig.NetworkMode = 'nat'; + } + }, function (e) { + Notifications.error('Failure', e, 'Unable to retrieve networks'); + }); + + Container.query({}, function (d) { + var containers = d; + if (containersToHideLabels) { + containers = ContainerHelper.hideContainers(d, containersToHideLabels); + } + $scope.runningContainers = containers; + }, function(e) { + Notifications.error('Failure', e, 'Unable to retrieve running containers'); + }); + }); + } + + function validateForm(accessControlData, isAdmin) { + $scope.state.formValidationError = ''; + var error = ''; + error = FormValidator.validateAccessControl(accessControlData, isAdmin); + + if (error) { + $scope.state.formValidationError = error; + return false; } + return true; + } + + $scope.create = function () { + $('#createContainerSpinner').show(); + + var accessControlData = ControllerDataPipeline.getAccessControlFormData(); + var userDetails = Authentication.getUserDetails(); + var isAdmin = userDetails.role === 1 ? true : false; + + if (!validateForm(accessControlData, isAdmin)) { + $('#createContainerSpinner').hide(); + return; + } + + var config = prepareConfiguration(); + createContainer(config, accessControlData); }; + + function createContainer(config, accessControlData) { + $q.when($scope.formValues.alwaysPull ? ImageService.pullImage($scope.config.Image, $scope.formValues.Registry) : null) + .then(function success() { + return ContainerService.createAndStartContainer(config); + }) + .then(function success(data) { + var containerIdentifier = data.Id; + var userId = Authentication.getUserDetails().ID; + return ResourceControlService.applyResourceControl('container', containerIdentifier, userId, accessControlData, []); + }) + .then(function success() { + Notifications.success('Container successfully created'); + $state.go('containers', {}, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create container'); + }) + .finally(function final() { + $('#createContainerSpinner').hide(); + }); + } + + initView(); + }]); diff --git a/app/components/createContainer/createcontainer.html b/app/components/createContainer/createcontainer.html index 530ad0f02..e931126c9 100644 --- a/app/components/createContainer/createcontainer.html +++ b/app/components/createContainer/createcontainer.html @@ -107,29 +107,9 @@
-
- Access control -
- -
-
- -
- - -
-
-
- + +
+
Actions @@ -139,6 +119,7 @@ Cancel + {{ state.formValidationError }}
@@ -532,7 +513,7 @@ - + diff --git a/app/components/createNetwork/createNetworkController.js b/app/components/createNetwork/createNetworkController.js index 14a8b2143..10aaf9049 100644 --- a/app/components/createNetwork/createNetworkController.js +++ b/app/components/createNetwork/createNetworkController.js @@ -44,13 +44,13 @@ function ($scope, $state, Notifications, Network) { $('#createNetworkSpinner').hide(); Notifications.error('Unable to create network', {}, d.message); } else { - Notifications.success("Network created", d.Id); + Notifications.success('Network created', d.Id); $('#createNetworkSpinner').hide(); $state.go('networks', {}, {reload: true}); } }, function (e) { $('#createNetworkSpinner').hide(); - Notifications.error("Failure", e, 'Unable to create network'); + Notifications.error('Failure', e, 'Unable to create network'); }); } diff --git a/app/components/createService/createServiceController.js b/app/components/createService/createServiceController.js index a6eb33590..02924bf44 100644 --- a/app/components/createService/createServiceController.js +++ b/app/components/createService/createServiceController.js @@ -1,11 +1,10 @@ // @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services. // See app/components/templates/templatesController.js as a reference. angular.module('createService', []) -.controller('CreateServiceController', ['$scope', '$state', 'Service', 'ServiceHelper', 'Volume', 'Network', 'ImageHelper', 'Authentication', 'ResourceControlService', 'Notifications', -function ($scope, $state, Service, ServiceHelper, Volume, Network, ImageHelper, Authentication, ResourceControlService, Notifications) { +.controller('CreateServiceController', ['$scope', '$state', 'Service', 'ServiceHelper', 'Volume', 'Network', 'ImageHelper', 'Authentication', 'ResourceControlService', 'Notifications', 'ControllerDataPipeline', 'FormValidator', +function ($scope, $state, Service, ServiceHelper, Volume, Network, ImageHelper, Authentication, ResourceControlService, Notifications, ControllerDataPipeline, FormValidator) { $scope.formValues = { - Ownership: $scope.applicationState.application.authentication ? 'private' : '', Name: '', Image: '', Registry: '', @@ -28,6 +27,10 @@ function ($scope, $state, Service, ServiceHelper, Volume, Network, ImageHelper, FailureAction: 'pause' }; + $scope.state = { + formValidationError: '' + }; + $scope.addPortBinding = function() { $scope.formValues.Ports.push({ PublishedPort: '', TargetPort: '', Protocol: 'tcp', PublishMode: 'ingress' }); }; @@ -121,7 +124,7 @@ function ($scope, $state, Service, ServiceHelper, Volume, Network, ImageHelper, } function commandToArray(cmd) { - var tokens = [].concat.apply([], cmd.split('"').map(function(v,i) { + var tokens = [].concat.apply([], cmd.split('\'').map(function(v,i) { return i%2 ? v : v.split(' '); })).filter(Boolean); return tokens; @@ -146,7 +149,7 @@ function ($scope, $state, Service, ServiceHelper, Volume, Network, ImageHelper, var env = []; input.Env.forEach(function (v) { if (v.name) { - env.push(v.name + "=" + v.value); + env.push(v.name + '=' + v.value); } }); config.TaskTemplate.ContainerSpec.Env = env; @@ -231,49 +234,70 @@ function ($scope, $state, Service, ServiceHelper, Volume, Network, ImageHelper, return config; } - function createNewService(config) { - Service.create(config, function (d) { - if ($scope.formValues.Ownership === 'private') { - ResourceControlService.setServiceResourceControl(Authentication.getUserDetails().ID, d.ID) - .then(function success() { - $('#createServiceSpinner').hide(); - Notifications.success('Service created', d.ID); - $state.go('services', {}, {reload: true}); - }) - .catch(function error(err) { - $('#createContainerSpinner').hide(); - Notifications.error("Failure", err, 'Unable to apply resource control on service'); - }); - } else { - $('#createServiceSpinner').hide(); - Notifications.success('Service created', d.ID); - $state.go('services', {}, {reload: true}); - } - }, function (e) { + function createNewService(config, accessControlData) { + Service.create(config).$promise + .then(function success(data) { + var serviceIdentifier = data.ID; + var userId = Authentication.getUserDetails().ID; + return ResourceControlService.applyResourceControl('service', serviceIdentifier, userId, accessControlData, []); + }) + .then(function success() { + Notifications.success('Service successfully created'); + $state.go('services', {}, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create service'); + }) + .finally(function final() { $('#createServiceSpinner').hide(); - Notifications.error("Failure", e, 'Unable to create service'); }); } + function validateForm(accessControlData, isAdmin) { + $scope.state.formValidationError = ''; + var error = ''; + error = FormValidator.validateAccessControl(accessControlData, isAdmin); + + if (error) { + $scope.state.formValidationError = error; + return false; + } + return true; + } + $scope.create = function createService() { $('#createServiceSpinner').show(); + + var accessControlData = ControllerDataPipeline.getAccessControlFormData(); + var userDetails = Authentication.getUserDetails(); + var isAdmin = userDetails.role === 1 ? true : false; + + if (!validateForm(accessControlData, isAdmin)) { + $('#createServiceSpinner').hide(); + return; + } + var config = prepareConfiguration(); - createNewService(config); + createNewService(config, accessControlData); }; - Volume.query({}, function (d) { - $scope.availableVolumes = d.Volumes; - }, function (e) { - Notifications.error("Failure", e, "Unable to retrieve volumes"); - }); - - Network.query({}, function (d) { - $scope.availableNetworks = d.filter(function (network) { - if (network.Scope === 'swarm') { - return network; - } + function initView() { + Volume.query({}, function (d) { + $scope.availableVolumes = d.Volumes; + }, function (e) { + Notifications.error('Failure', e, 'Unable to retrieve volumes'); }); - }, function (e) { - Notifications.error("Failure", e, "Unable to retrieve networks"); - }); + + Network.query({}, function (d) { + $scope.availableNetworks = d.filter(function (network) { + if (network.Scope === 'swarm') { + return network; + } + }); + }, function (e) { + Notifications.error('Failure', e, 'Unable to retrieve networks'); + }); + } + + initView(); }]); diff --git a/app/components/createService/createservice.html b/app/components/createService/createservice.html index 941804e9e..8dd1c637c 100644 --- a/app/components/createService/createservice.html +++ b/app/components/createService/createservice.html @@ -108,29 +108,9 @@ -
- Access control -
- -
-
- -
- - -
-
-
- + +
+
Actions @@ -140,6 +120,7 @@ Cancel + {{ state.formValidationError }}
@@ -251,7 +232,7 @@
- +
-
- Access control -
- -
-
- -
- - -
-
-
- + +
+
Actions @@ -96,6 +76,7 @@ Cancel + {{ state.formValidationError }}
diff --git a/app/components/dashboard/dashboardController.js b/app/components/dashboard/dashboardController.js index 3ece5956d..89f38ce00 100644 --- a/app/components/dashboard/dashboardController.js +++ b/app/components/dashboard/dashboardController.js @@ -82,7 +82,7 @@ function ($scope, $q, Config, Container, ContainerHelper, Image, Network, Volume $('#loadingViewSpinner').hide(); }, function(e) { $('#loadingViewSpinner').hide(); - Notifications.error("Failure", e, "Unable to load dashboard data"); + Notifications.error('Failure', e, 'Unable to load dashboard data'); }); } diff --git a/app/components/docker/dockerController.js b/app/components/docker/dockerController.js index 727724d7f..56b80455c 100644 --- a/app/components/docker/dockerController.js +++ b/app/components/docker/dockerController.js @@ -14,11 +14,11 @@ function ($scope, Info, Version, Notifications) { $scope.state.loaded = true; $('#loadingViewSpinner').hide(); }, function (e) { - Notifications.error("Failure", e, 'Unable to retrieve engine details'); + Notifications.error('Failure', e, 'Unable to retrieve engine details'); $('#loadingViewSpinner').hide(); }); }, function (e) { - Notifications.error("Failure", e, 'Unable to retrieve engine information'); + Notifications.error('Failure', e, 'Unable to retrieve engine information'); $('#loadingViewSpinner').hide(); }); }]); diff --git a/app/components/endpoint/endpointController.js b/app/components/endpoint/endpointController.js index 4ef7c0aba..81444370e 100644 --- a/app/components/endpoint/endpointController.js +++ b/app/components/endpoint/endpointController.js @@ -32,7 +32,7 @@ function ($scope, $state, $stateParams, $filter, EndpointService, Notifications) EndpointService.updateEndpoint(ID, endpointParams) .then(function success(data) { - Notifications.success("Endpoint updated", $scope.endpoint.Name); + Notifications.success('Endpoint updated', $scope.endpoint.Name); $state.go('endpoints'); }, function error(err) { $scope.state.error = err.msg; @@ -48,7 +48,7 @@ function ($scope, $state, $stateParams, $filter, EndpointService, Notifications) EndpointService.endpoint($stateParams.id).then(function success(data) { $('#loadingViewSpinner').hide(); $scope.endpoint = data; - if (data.URL.indexOf("unix://") === 0) { + if (data.URL.indexOf('unix://') === 0) { $scope.endpointType = 'local'; } else { $scope.endpointType = 'remote'; @@ -59,7 +59,7 @@ function ($scope, $state, $stateParams, $filter, EndpointService, Notifications) $scope.formValues.TLSKey = data.TLSKey; }, function error(err) { $('#loadingViewSpinner').hide(); - Notifications.error("Failure", err, "Unable to retrieve endpoint details"); + Notifications.error('Failure', err, 'Unable to retrieve endpoint details'); }); } diff --git a/app/components/endpointAccess/endpointAccess.html b/app/components/endpointAccess/endpointAccess.html index 042280431..98ee856dc 100644 --- a/app/components/endpointAccess/endpointAccess.html +++ b/app/components/endpointAccess/endpointAccess.html @@ -29,8 +29,8 @@ - You can select which user can access this endpoint by moving them to the authorized users table. Simply click - on a user entry to move it from one table to the other. + You can select which user or team can access this endpoint by moving them to the authorized accesses table. Simply click + on a user or team entry to move it from one table to the other. @@ -44,10 +44,10 @@
- +
Items per page: - @@ -58,7 +58,7 @@
- +
@@ -70,38 +70,38 @@ - + Name - - + + - - Role - - + + Type + + - - {{ user.Username }} + + {{ user.Name }} - {{ user.RoleName }} - + + {{ user.Type }} - + Loading... - - No users. + + No user or team available. -
+
@@ -110,10 +110,10 @@
- +
Items per page: - @@ -124,7 +124,7 @@
- +
@@ -136,39 +136,39 @@ - + Name - - + + - - Role - - + + Type + + - - {{ user.Username }} + + {{ user.Name }} - {{ user.RoleName }} - + + {{ user.Type }} - + Loading... - - No authorized users. + + No authorized user or team. -
- +
+
diff --git a/app/components/endpointAccess/endpointAccessController.js b/app/components/endpointAccess/endpointAccessController.js index 8ec92e926..549664be6 100644 --- a/app/components/endpointAccess/endpointAccessController.js +++ b/app/components/endpointAccess/endpointAccessController.js @@ -1,148 +1,192 @@ angular.module('endpointAccess', []) -.controller('EndpointAccessController', ['$q', '$scope', '$state', '$stateParams', '$filter', 'EndpointService', 'UserService', 'Pagination', 'Notifications', -function ($q, $scope, $state, $stateParams, $filter, EndpointService, UserService, Pagination, Notifications) { +.controller('EndpointAccessController', ['$q', '$scope', '$state', '$stateParams', '$filter', 'EndpointService', 'UserService', 'TeamService', 'Pagination', 'Notifications', +function ($q, $scope, $state, $stateParams, $filter, EndpointService, UserService, TeamService, Pagination, Notifications) { $scope.state = { - pagination_count_users: Pagination.getPaginationCount('endpoint_access_users'), - pagination_count_authorizedUsers: Pagination.getPaginationCount('endpoint_access_authorizedUsers') + pagination_count_accesses: Pagination.getPaginationCount('endpoint_access_accesses'), + pagination_count_authorizedAccesses: Pagination.getPaginationCount('endpoint_access_authorizedAccesses') }; - $scope.sortTypeUsers = 'Username'; - $scope.sortReverseUsers = true; + $scope.sortTypeAccesses = 'Type'; + $scope.sortReverseAccesses = false; - $scope.orderUsers = function(sortType) { - $scope.sortReverseUsers = ($scope.sortTypeUsers === sortType) ? !$scope.sortReverseUsers : false; - $scope.sortTypeUsers = sortType; + $scope.orderAccesses = function(sortType) { + $scope.sortReverseAccesses = ($scope.sortTypeAccesses === sortType) ? !$scope.sortReverseAccesses : false; + $scope.sortTypeAccesses = sortType; }; - $scope.changePaginationCountUsers = function() { - Pagination.setPaginationCount('endpoint_access_users', $scope.state.pagination_count_users); + $scope.changePaginationCountAccesses = function() { + Pagination.setPaginationCount('endpoint_access_accesses', $scope.state.pagination_count_accesses); }; - $scope.sortTypeAuthorizedUsers = 'Username'; - $scope.sortReverseAuthorizedUsers = true; + $scope.sortTypeAuthorizedAccesses = 'Type'; + $scope.sortReverseAuthorizedAccesses = false; - $scope.orderAuthorizedUsers = function(sortType) { - $scope.sortReverseAuthorizedUsers = ($scope.sortTypeAuthorizedUsers === sortType) ? !$scope.sortReverseAuthorizedUsers : false; - $scope.sortTypeAuthorizedUsers = sortType; + $scope.orderAuthorizedAccesses = function(sortType) { + $scope.sortReverseAuthorizedAccesses = ($scope.sortTypeAuthorizedAccesses === sortType) ? !$scope.sortReverseAuthorizedAccesses : false; + $scope.sortTypeAuthorizedAccesses = sortType; }; - $scope.changePaginationCountAuthorizedUsers = function() { - Pagination.setPaginationCount('endpoint_access_authorizedUsers', $scope.state.pagination_count_authorizedUsers); + $scope.changePaginationCountAuthorizedAccesses = function() { + Pagination.setPaginationCount('endpoint_access_authorizedAccesses', $scope.state.pagination_count_authorizedAccesses); }; - $scope.authorizeAllUsers = function() { - var authorizedUserIDs = []; - angular.forEach($scope.authorizedUsers, function (user) { - authorizedUserIDs.push(user.Id); - }); - angular.forEach($scope.users, function (user) { - authorizedUserIDs.push(user.Id); - }); - EndpointService.updateAuthorizedUsers($stateParams.id, authorizedUserIDs) - .then(function success(data) { - $scope.authorizedUsers = $scope.authorizedUsers.concat($scope.users); - $scope.users = []; - Notifications.success('Access granted for all users'); - }) - .catch(function error(err) { - Notifications.error("Failure", err, "Unable to update endpoint permissions"); - }); - }; - - $scope.unauthorizeAllUsers = function() { - EndpointService.updateAuthorizedUsers($stateParams.id, []) - .then(function success(data) { - $scope.users = $scope.users.concat($scope.authorizedUsers); - $scope.authorizedUsers = []; - Notifications.success('Access removed for all users'); - }) - .catch(function error(err) { - Notifications.error("Failure", err, "Unable to update endpoint permissions"); - }); - }; - - $scope.authorizeUser = function(user) { - var authorizedUserIDs = []; - angular.forEach($scope.authorizedUsers, function (u) { - authorizedUserIDs.push(u.Id); - }); - authorizedUserIDs.push(user.Id); - EndpointService.updateAuthorizedUsers($stateParams.id, authorizedUserIDs) - .then(function success(data) { - removeUserFromArray(user.Id, $scope.users); - $scope.authorizedUsers.push(user); - Notifications.success('Access granted for user', user.Username); - }) - .catch(function error(err) { - Notifications.error("Failure", err, "Unable to update endpoint permissions"); - }); - }; - - $scope.unauthorizeUser = function(user) { - var authorizedUserIDs = $scope.authorizedUsers.filter(function (u) { - if (u.Id !== user.Id) { - return u; + $scope.authorizeAllAccesses = function() { + var authorizedUsers = []; + var authorizedTeams = []; + angular.forEach($scope.authorizedAccesses, function (a) { + if (a.Type === 'user') { + authorizedUsers.push(a.Id); + } else if (a.Type === 'team') { + authorizedTeams.push(a.Id); } - }).map(function (u) { - return u.Id; }); - EndpointService.updateAuthorizedUsers($stateParams.id, authorizedUserIDs) + angular.forEach($scope.accesses, function (a) { + if (a.Type === 'user') { + authorizedUsers.push(a.Id); + } else if (a.Type === 'team') { + authorizedTeams.push(a.Id); + } + }); + + EndpointService.updateAccess($stateParams.id, authorizedUsers, authorizedTeams) .then(function success(data) { - removeUserFromArray(user.Id, $scope.authorizedUsers); - $scope.users.push(user); - Notifications.success('Access removed for user', user.Username); + $scope.authorizedAccesses = $scope.authorizedAccesses.concat($scope.accesses); + $scope.accesses = []; + Notifications.success('Endpoint accesses successfully updated'); }) .catch(function error(err) { - Notifications.error("Failure", err, "Unable to update endpoint permissions"); + Notifications.error('Failure', err, 'Unable to update endpoint accesses'); }); }; - function getEndpointAndUsers(endpointID) { + $scope.unauthorizeAllAccesses = function() { + EndpointService.updateAccess($stateParams.id, [], []) + .then(function success(data) { + $scope.accesses = $scope.accesses.concat($scope.authorizedAccesses); + $scope.authorizedAccesses = []; + Notifications.success('Endpoint accesses successfully updated'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update endpoint accesses'); + }); + }; + + $scope.authorizeAccess = function(access) { + var authorizedUsers = []; + var authorizedTeams = []; + angular.forEach($scope.authorizedAccesses, function (a) { + if (a.Type === 'user') { + authorizedUsers.push(a.Id); + } else if (a.Type === 'team') { + authorizedTeams.push(a.Id); + } + }); + + if (access.Type === 'user') { + authorizedUsers.push(access.Id); + } else if (access.Type === 'team') { + authorizedTeams.push(access.Id); + } + + EndpointService.updateAccess($stateParams.id, authorizedUsers, authorizedTeams) + .then(function success(data) { + removeAccessFromArray(access, $scope.accesses); + $scope.authorizedAccesses.push(access); + Notifications.success('Endpoint accesses successfully updated', access.Name); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update endpoint accesses'); + }); + }; + + $scope.unauthorizeAccess = function(access) { + var authorizedUsers = []; + var authorizedTeams = []; + angular.forEach($scope.authorizedAccesses, function (a) { + if (a.Type === 'user') { + authorizedUsers.push(a.Id); + } else if (a.Type === 'team') { + authorizedTeams.push(a.Id); + } + }); + + if (access.Type === 'user') { + _.remove(authorizedUsers, function(n) { + return n === access.Id; + }); + } else if (access.Type === 'team') { + _.remove(authorizedTeams, function(n) { + return n === access.Id; + }); + } + + EndpointService.updateAccess($stateParams.id, authorizedUsers, authorizedTeams) + .then(function success(data) { + removeAccessFromArray(access, $scope.authorizedAccesses); + $scope.accesses.push(access); + Notifications.success('Endpoint accesses successfully updated', access.Name); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update endpoint accesses'); + }); + }; + + function initView() { $('#loadingViewSpinner').show(); $q.all({ endpoint: EndpointService.endpoint($stateParams.id), - users: UserService.users(), + users: UserService.users(false), + teams: TeamService.teams() }) .then(function success(data) { $scope.endpoint = data.endpoint; - $scope.users = data.users.filter(function (user) { - if (user.Role !== 1) { - return user; - } - }).map(function (user) { - return new UserViewModel(user); + $scope.accesses = []; + var users = data.users.map(function (user) { + return new EndpointAccessUserViewModel(user); }); - $scope.authorizedUsers = []; + var teams = data.teams.map(function (team) { + return new EndpointAccessTeamViewModel(team); + }); + $scope.accesses = $scope.accesses.concat(users, teams); + $scope.authorizedAccesses = []; angular.forEach($scope.endpoint.AuthorizedUsers, function(userID) { - for (var i = 0, l = $scope.users.length; i < l; i++) { - if ($scope.users[i].Id === userID) { - $scope.authorizedUsers.push($scope.users[i]); - $scope.users.splice(i, 1); + for (var i = 0, l = $scope.accesses.length; i < l; i++) { + if ($scope.accesses[i].Type === 'user' && $scope.accesses[i].Id === userID) { + $scope.authorizedAccesses.push($scope.accesses[i]); + $scope.accesses.splice(i, 1); + return; + } + } + }); + angular.forEach($scope.endpoint.AuthorizedTeams, function(teamID) { + for (var i = 0, l = $scope.accesses.length; i < l; i++) { + if ($scope.accesses[i].Type === 'team' && $scope.accesses[i].Id === teamID) { + $scope.authorizedAccesses.push($scope.accesses[i]); + $scope.accesses.splice(i, 1); return; } } }); }) .catch(function error(err) { - $scope.templates = []; - $scope.users = []; - $scope.authorizedUsers = []; - Notifications.error("Failure", err, "Unable to retrieve endpoint details"); + $scope.accesses = []; + $scope.authorizedAccesses = []; + Notifications.error('Failure', err, 'Unable to retrieve endpoint details'); }) .finally(function final(){ $('#loadingViewSpinner').hide(); }); } - function removeUserFromArray(id, users) { - for (var i = 0, l = users.length; i < l; i++) { - if (users[i].Id === id) { - users.splice(i, 1); + function removeAccessFromArray(access, accesses) { + for (var i = 0, l = accesses.length; i < l; i++) { + if (access.Type === accesses[i].Type && access.Id === accesses[i].Id) { + accesses.splice(i, 1); return; } } } - getEndpointAndUsers($stateParams.id); + initView(); }]); diff --git a/app/components/endpointInit/endpointInitController.js b/app/components/endpointInit/endpointInitController.js index 7977de3ee..c99a9a86c 100644 --- a/app/components/endpointInit/endpointInitController.js +++ b/app/components/endpointInit/endpointInitController.js @@ -7,7 +7,7 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif }; $scope.formValues = { - endpointType: "remote", + endpointType: 'remote', Name: '', URL: '', TLS: false, @@ -46,8 +46,8 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif $scope.createLocalEndpoint = function() { $('#initEndpointSpinner').show(); $scope.state.error = ''; - var name = "local"; - var URL = "unix:///var/run/docker.sock"; + var name = 'local'; + var URL = 'unix:///var/run/docker.sock'; var TLS = false; EndpointService.createLocalEndpoint(name, URL, TLS, true) diff --git a/app/components/endpoints/endpointsController.js b/app/components/endpoints/endpointsController.js index f9d0082f2..3e11a2f49 100644 --- a/app/components/endpoints/endpointsController.js +++ b/app/components/endpoints/endpointsController.js @@ -59,7 +59,7 @@ function ($scope, $state, EndpointService, EndpointProvider, Notifications, Pagi var TLSCertFile = $scope.formValues.TLSCert; var TLSKeyFile = $scope.formValues.TLSKey; EndpointService.createRemoteEndpoint(name, URL, PublicURL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile, false).then(function success(data) { - Notifications.success("Endpoint created", name); + Notifications.success('Endpoint created', name); $state.reload(); }, function error(err) { $scope.state.uploadInProgress = false; @@ -84,12 +84,12 @@ function ($scope, $state, EndpointService, EndpointProvider, Notifications, Pagi if (endpoint.Checked) { counter = counter + 1; EndpointService.deleteEndpoint(endpoint.Id).then(function success(data) { - Notifications.success("Endpoint deleted", endpoint.Name); + Notifications.success('Endpoint deleted', endpoint.Name); var index = $scope.endpoints.indexOf(endpoint); $scope.endpoints.splice(index, 1); complete(); }, function error(err) { - Notifications.error("Failure", err, 'Unable to remove endpoint'); + Notifications.error('Failure', err, 'Unable to remove endpoint'); complete(); }); } @@ -104,7 +104,7 @@ function ($scope, $state, EndpointService, EndpointProvider, Notifications, Pagi $scope.activeEndpointID = EndpointProvider.endpointID(); }) .catch(function error(err) { - Notifications.error("Failure", err, "Unable to retrieve endpoints"); + Notifications.error('Failure', err, 'Unable to retrieve endpoints'); $scope.endpoints = []; }) .finally(function final() { diff --git a/app/components/events/eventsController.js b/app/components/events/eventsController.js index 41c2a8340..c0027376f 100644 --- a/app/components/events/eventsController.js +++ b/app/components/events/eventsController.js @@ -27,6 +27,6 @@ function ($scope, Notifications, Events, Pagination) { }, function (e) { $('#loadEventsSpinner').hide(); - Notifications.error("Failure", e, "Unable to load events"); + Notifications.error('Failure', e, 'Unable to load events'); }); }]); diff --git a/app/components/images/imagesController.js b/app/components/images/imagesController.js index 6a12eb7f2..70918b82d 100644 --- a/app/components/images/imagesController.js +++ b/app/components/images/imagesController.js @@ -47,7 +47,7 @@ function ($scope, $state, Config, ImageService, Notifications, Pagination, Modal $state.reload(); }) .catch(function error(err) { - Notifications.error("Failure", err, "Unable to pull image"); + Notifications.error('Failure', err, 'Unable to pull image'); }) .finally(function final() { $('#pullImageSpinner').hide(); @@ -76,12 +76,12 @@ function ($scope, $state, Config, ImageService, Notifications, Pagination, Modal counter = counter + 1; ImageService.deleteImage(i.Id, force) .then(function success(data) { - Notifications.success("Image deleted", i.Id); + Notifications.success('Image deleted', i.Id); var index = $scope.images.indexOf(i); $scope.images.splice(index, 1); }) .catch(function error(err) { - Notifications.error("Failure", err, 'Unable to remove image'); + Notifications.error('Failure', err, 'Unable to remove image'); }) .finally(function final() { complete(); @@ -97,7 +97,7 @@ function ($scope, $state, Config, ImageService, Notifications, Pagination, Modal $scope.images = data; }) .catch(function error(err) { - Notifications.error("Failure", err, "Unable to retrieve images"); + Notifications.error('Failure', err, 'Unable to retrieve images'); $scope.images = []; }) .finally(function final() { diff --git a/app/components/network/networkController.js b/app/components/network/networkController.js index 4c89f343a..f6b44df84 100644 --- a/app/components/network/networkController.js +++ b/app/components/network/networkController.js @@ -7,15 +7,15 @@ function ($scope, $state, $stateParams, $filter, Config, Network, Container, Con Network.remove({id: $stateParams.id}, function (d) { if (d.message) { $('#loadingViewSpinner').hide(); - Notifications.error("Error", d, "Unable to remove network"); + Notifications.error('Error', d, 'Unable to remove network'); } else { $('#loadingViewSpinner').hide(); - Notifications.success("Network removed", $stateParams.id); + Notifications.success('Network removed', $stateParams.id); $state.go('networks', {}); } }, function (e) { $('#loadingViewSpinner').hide(); - Notifications.error("Failure", e, "Unable to remove network"); + Notifications.error('Failure', e, 'Unable to remove network'); }); }; @@ -24,15 +24,15 @@ function ($scope, $state, $stateParams, $filter, Config, Network, Container, Con Network.disconnect({id: $stateParams.id}, { Container: containerId, Force: false }, function (d) { if (d.message) { $('#loadingViewSpinner').hide(); - Notifications.error("Error", d, "Unable to disconnect container from network"); + Notifications.error('Error', d, 'Unable to disconnect container from network'); } else { $('#loadingViewSpinner').hide(); - Notifications.success("Container left network", $stateParams.id); + Notifications.success('Container left network', $stateParams.id); $state.go('network', {id: network.Id}, {reload: true}); } }, function (e) { $('#loadingViewSpinner').hide(); - Notifications.error("Failure", e, "Unable to disconnect container from network"); + Notifications.error('Failure', e, 'Unable to disconnect container from network'); }); }; @@ -43,7 +43,7 @@ function ($scope, $state, $stateParams, $filter, Config, Network, Container, Con getContainersInNetwork(data); }, function error(err) { $('#loadingViewSpinner').hide(); - Notifications.error("Failure", err, "Unable to retrieve network info"); + Notifications.error('Failure', err, 'Unable to retrieve network info'); }); } @@ -77,7 +77,7 @@ function ($scope, $state, $stateParams, $filter, Config, Network, Container, Con $('#loadingViewSpinner').hide(); }, function error(err) { $('#loadingViewSpinner').hide(); - Notifications.error("Failure", err, "Unable to retrieve containers in network"); + Notifications.error('Failure', err, 'Unable to retrieve containers in network'); }); } else { Container.query({ @@ -87,7 +87,7 @@ function ($scope, $state, $stateParams, $filter, Config, Network, Container, Con $('#loadingViewSpinner').hide(); }, function error(err) { $('#loadingViewSpinner').hide(); - Notifications.error("Failure", err, "Unable to retrieve containers in network"); + Notifications.error('Failure', err, 'Unable to retrieve containers in network'); }); } } diff --git a/app/components/networks/networksController.js b/app/components/networks/networksController.js index 472081ef3..2e8e6e9ed 100644 --- a/app/components/networks/networksController.js +++ b/app/components/networks/networksController.js @@ -36,13 +36,13 @@ function ($scope, $state, Network, Config, Notifications, Pagination) { $('#createNetworkSpinner').hide(); Notifications.error('Unable to create network', {}, d.message); } else { - Notifications.success("Network created", d.Id); + Notifications.success('Network created', d.Id); $('#createNetworkSpinner').hide(); $state.reload(); } }, function (e) { $('#createNetworkSpinner').hide(); - Notifications.error("Failure", e, 'Unable to create network'); + Notifications.error('Failure', e, 'Unable to create network'); }); }; @@ -82,15 +82,15 @@ function ($scope, $state, Network, Config, Notifications, Pagination) { counter = counter + 1; Network.remove({id: network.Id}, function (d) { if (d.message) { - Notifications.error("Error", d, "Unable to remove network"); + Notifications.error('Error', d, 'Unable to remove network'); } else { - Notifications.success("Network removed", network.Id); + Notifications.success('Network removed', network.Id); var index = $scope.networks.indexOf(network); $scope.networks.splice(index, 1); } complete(); }, function (e) { - Notifications.error("Failure", e, 'Unable to remove network'); + Notifications.error('Failure', e, 'Unable to remove network'); complete(); }); } @@ -104,7 +104,7 @@ function ($scope, $state, Network, Config, Notifications, Pagination) { $('#loadNetworksSpinner').hide(); }, function (e) { $('#loadNetworksSpinner').hide(); - Notifications.error("Failure", e, "Unable to retrieve networks"); + Notifications.error('Failure', e, 'Unable to retrieve networks'); $scope.networks = []; }); } diff --git a/app/components/node/node.html b/app/components/node/node.html index b4075cb04..480009e57 100644 --- a/app/components/node/node.html +++ b/app/components/node/node.html @@ -239,10 +239,10 @@ - + Image - - + + @@ -257,10 +257,10 @@ {{ task.Id }} - {{ task.Status }} - {{ task.Slot }} - {{ task.Image }} - {{ task.Updated|getisodate }} + {{ task.Status.State }} + {{ task.Slot ? task.Slot : '-' }} + {{ task.Spec.ContainerSpec.Image | hideshasum }} + {{ task.Updated | getisodate }} diff --git a/app/components/node/nodeController.js b/app/components/node/nodeController.js index c09b7c604..a417c462d 100644 --- a/app/components/node/nodeController.js +++ b/app/components/node/nodeController.js @@ -1,3 +1,5 @@ +// @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services. +// See app/components/templates/templatesController.js as a reference. angular.module('node', []) .controller('NodeController', ['$scope', '$state', '$stateParams', 'LabelHelper', 'Node', 'NodeHelper', 'Task', 'Pagination', 'Notifications', function ($scope, $state, $stateParams, LabelHelper, Node, NodeHelper, Task, Pagination, Notifications) { @@ -6,7 +8,6 @@ function ($scope, $state, $stateParams, LabelHelper, Node, NodeHelper, Task, Pag $scope.state.pagination_count = Pagination.getPaginationCount('node_tasks'); $scope.loading = true; $scope.tasks = []; - $scope.displayNode = false; $scope.sortType = 'Status'; $scope.sortReverse = false; @@ -68,11 +69,11 @@ function ($scope, $state, $stateParams, LabelHelper, Node, NodeHelper, Task, Pag Node.update({ id: node.Id, version: node.Version }, config, function (data) { $('#loadServicesSpinner').hide(); - Notifications.success("Node successfully updated", "Node updated"); + Notifications.success('Node successfully updated', 'Node updated'); $state.go('node', {id: node.Id}, {reload: true}); }, function (e) { $('#loadServicesSpinner').hide(); - Notifications.error("Failure", e, "Failed to update node"); + Notifications.error('Failure', e, 'Failed to update node'); }); }; @@ -81,7 +82,7 @@ function ($scope, $state, $stateParams, LabelHelper, Node, NodeHelper, Task, Pag if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') { Node.get({ id: $stateParams.id}, function(d) { if (d.message) { - Notifications.error("Failure", e, "Unable to inspect the node"); + Notifications.error('Failure', e, 'Unable to inspect the node'); } else { var node = new NodeViewModel(d); originalNode = angular.copy(node); @@ -99,10 +100,10 @@ function ($scope, $state, $stateParams, LabelHelper, Node, NodeHelper, Task, Pag if (node) { Task.query({filters: {node: [node.ID]}}, function (tasks) { $scope.tasks = tasks.map(function (task) { - return new TaskViewModel(task, [node]); + return new TaskViewModel(task); }); }, function (e) { - Notifications.error("Failure", e, "Unable to retrieve tasks associated to the node"); + Notifications.error('Failure', e, 'Unable to retrieve tasks associated to the node'); }); } } diff --git a/app/components/service/includes/tasks.html b/app/components/service/includes/tasks.html index 48dc1b6f9..51d8fdf9f 100644 --- a/app/components/service/includes/tasks.html +++ b/app/components/service/includes/tasks.html @@ -1,4 +1,4 @@ -
+
@@ -24,14 +24,14 @@ - + Slot - + Node @@ -50,10 +50,10 @@ {{ task.Id }} - {{ task.Status }} - {{ task.Slot }} - {{ task.Node }} - {{ task.Updated|getisodate }} + {{ task.Status.State }} + {{ task.Slot }} + {{ task.NodeId | tasknodename: nodes }} + {{ task.Updated | getisodate }} diff --git a/app/components/service/service.html b/app/components/service/service.html index a00ff7ced..a49e9a44a 100644 --- a/app/components/service/service.html +++ b/app/components/service/service.html @@ -116,6 +116,8 @@
+
+

diff --git a/app/components/service/serviceController.js b/app/components/service/serviceController.js index 18a2bec01..20a283538 100644 --- a/app/components/service/serviceController.js +++ b/app/components/service/serviceController.js @@ -1,12 +1,10 @@ angular.module('service', []) -.controller('ServiceController', ['$scope', '$stateParams', '$state', '$location', '$anchorScroll', 'Service', 'ServiceHelper', 'Task', 'Node', 'Notifications', 'Pagination', 'ModalService', -function ($scope, $stateParams, $state, $location, $anchorScroll, Service, ServiceHelper, Task, Node, Notifications, Pagination, ModalService) { +.controller('ServiceController', ['$q', '$scope', '$stateParams', '$state', '$location', '$anchorScroll', 'ServiceService', 'Service', 'ServiceHelper', 'TaskService', 'NodeService', 'Notifications', 'Pagination', 'ModalService', 'ControllerDataPipeline', +function ($q, $scope, $stateParams, $state, $location, $anchorScroll, ServiceService, Service, ServiceHelper, TaskService, NodeService, Notifications, Pagination, ModalService, ControllerDataPipeline) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('service_tasks'); - $scope.service = {}; $scope.tasks = []; - $scope.displayNode = false; $scope.sortType = 'Status'; $scope.sortReverse = false; @@ -213,12 +211,12 @@ function ($scope, $stateParams, $state, $location, $anchorScroll, Service, Servi Service.update({ id: service.Id, version: service.Version }, config, function (data) { $('#loadingViewSpinner').hide(); - Notifications.success("Service successfully updated", "Service updated"); + Notifications.success('Service successfully updated', 'Service updated'); $scope.cancelChanges({}); - fetchServiceDetails(); + initView(); }, function (e) { $('#loadingViewSpinner').hide(); - Notifications.error("Failure", e, "Unable to update service"); + Notifications.error('Failure', e, 'Unable to update service'); }); }; @@ -234,18 +232,16 @@ function ($scope, $stateParams, $state, $location, $anchorScroll, Service, Servi function removeService() { $('#loadingViewSpinner').show(); - Service.remove({id: $stateParams.id}, function (d) { - if (d.message) { - $('#loadingViewSpinner').hide(); - Notifications.error("Error", d, "Unable to remove service"); - } else { - $('#loadingViewSpinner').hide(); - Notifications.success("Service removed", $stateParams.id); - $state.go('services', {}); - } - }, function (e) { + ServiceService.remove($scope.service) + .then(function success(data) { + Notifications.success('Service successfully deleted'); + $state.go('services', {}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove service'); + }) + .finally(function final() { $('#loadingViewSpinner').hide(); - Notifications.error("Failure", e, "Unable to remove service"); }); } @@ -258,10 +254,12 @@ function ($scope, $stateParams, $state, $location, $anchorScroll, Service, Servi service.ServiceConstraints = translateConstraintsToKeyValue(service.Constraints); } - function fetchServiceDetails() { + function initView() { $('#loadingViewSpinner').show(); - Service.get({id: $stateParams.id}, function (d) { - var service = new ServiceViewModel(d); + + ServiceService.service($stateParams.id) + .then(function success(data) { + var service = data; $scope.isUpdating = $scope.lastVersion >= service.Version; if (!$scope.isUpdating) { $scope.lastVersion = service.Version; @@ -269,29 +267,23 @@ function ($scope, $stateParams, $state, $location, $anchorScroll, Service, Servi translateServiceArrays(service); $scope.service = service; + ControllerDataPipeline.setAccessControlData('service', $stateParams.id, service.ResourceControl); originalService = angular.copy(service); - Task.query({filters: {service: [service.Name]}}, function (tasks) { - Node.query({}, function (nodes) { - $scope.displayNode = true; - $scope.tasks = tasks.map(function (task) { - return new TaskViewModel(task, nodes); - }); - $('#loadingViewSpinner').hide(); - }, function (e) { - $('#loadingViewSpinner').hide(); - $scope.tasks = tasks.map(function (task) { - return new TaskViewModel(task, null); - }); - Notifications.error("Failure", e, "Unable to retrieve node information"); - }); - }, function (e) { - $('#loadingViewSpinner').hide(); - Notifications.error("Failure", e, "Unable to retrieve tasks associated to the service"); + return $q.all({ + tasks: TaskService.serviceTasks(service.Name), + nodes: NodeService.nodes() }); - }, function (e) { + }) + .then(function success(data) { + $scope.tasks = data.tasks; + $scope.nodes = data.nodes; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve service details'); + }) + .finally(function final() { $('#loadingViewSpinner').hide(); - Notifications.error("Failure", e, "Unable to retrieve service details"); }); } @@ -382,5 +374,5 @@ function ($scope, $stateParams, $state, $location, $anchorScroll, Service, Servi return []; } - fetchServiceDetails(); + initView(); }]); diff --git a/app/components/services/services.html b/app/components/services/services.html index 486a47cd0..945c6db40 100644 --- a/app/components/services/services.html +++ b/app/components/services/services.html @@ -73,10 +73,10 @@ - + Ownership - - + + @@ -109,24 +109,9 @@ {{ service.UpdatedAt|getisodate }} - - - - Private - - - Private (owner: {{ service.Owner }}) - - Switch to public - - - - Private - Switch to public - - - - Public + + + {{ service.ResourceControl.Ownership ? service.ResourceControl.Ownership : service.ResourceControl.Ownership = 'public' }} diff --git a/app/components/services/servicesController.js b/app/components/services/servicesController.js index 1cdf61da9..0a75cd441 100644 --- a/app/components/services/servicesController.js +++ b/app/components/services/servicesController.js @@ -1,40 +1,12 @@ angular.module('services', []) -.controller('ServicesController', ['$q', '$scope', '$stateParams', '$state', 'Service', 'ServiceHelper', 'Notifications', 'Pagination', 'Task', 'Node', 'NodeHelper', 'Authentication', 'UserService', 'ModalService', 'ResourceControlService', -function ($q, $scope, $stateParams, $state, Service, ServiceHelper, Notifications, Pagination, Task, Node, NodeHelper, Authentication, UserService, ModalService, ResourceControlService) { +.controller('ServicesController', ['$q', '$scope', '$stateParams', '$state', 'Service', 'ServiceService', 'ServiceHelper', 'Notifications', 'Pagination', 'Task', 'Node', 'NodeHelper', 'ModalService', 'ResourceControlService', +function ($q, $scope, $stateParams, $state, Service, ServiceService, ServiceHelper, Notifications, Pagination, Task, Node, NodeHelper, ModalService, ResourceControlService) { $scope.state = {}; $scope.state.selectedItemCount = 0; $scope.state.pagination_count = Pagination.getPaginationCount('services'); $scope.sortType = 'Name'; $scope.sortReverse = false; - function removeServiceResourceControl(service) { - volumeResourceControlQueries = []; - angular.forEach(service.Mounts, function (mount) { - if (mount.Type === 'volume') { - volumeResourceControlQueries.push(ResourceControlService.removeVolumeResourceControl(service.Metadata.ResourceControl.OwnerId, mount.Source)); - } - }); - - $q.all(volumeResourceControlQueries) - .then(function success() { - return ResourceControlService.removeServiceResourceControl(service.Metadata.ResourceControl.OwnerId, service.Id); - }) - .then(function success() { - delete service.Metadata.ResourceControl; - Notifications.success('Ownership changed to public', service.Id); - }) - .catch(function error(err) { - Notifications.error("Failure", err, "Unable to change service ownership"); - }); - } - - $scope.switchOwnership = function(volume) { - ModalService.confirmServiceOwnershipChange(function (confirmed) { - if(!confirmed) { return; } - removeServiceResourceControl(volume); - }); - }; - $scope.changePaginationCount = function() { Pagination.setPaginationCount('services', $scope.state.pagination_count); }; @@ -58,13 +30,13 @@ function ($q, $scope, $stateParams, $state, Service, ServiceHelper, Notification config.Mode.Replicated.Replicas = service.Replicas; Service.update({ id: service.Id, version: service.Version }, config, function (data) { $('#loadServicesSpinner').hide(); - Notifications.success("Service successfully scaled", "New replica count: " + service.Replicas); + Notifications.success('Service successfully scaled', 'New replica count: ' + service.Replicas); $state.reload(); }, function (e) { $('#loadServicesSpinner').hide(); service.Scale = false; service.Replicas = service.ReplicaCount; - Notifications.error("Failure", e, "Unable to scale service"); + Notifications.error('Failure', e, 'Unable to scale service'); }); }; @@ -90,40 +62,22 @@ function ($q, $scope, $stateParams, $state, Service, ServiceHelper, Notification angular.forEach($scope.services, function (service) { if (service.Checked) { counter = counter + 1; - Service.remove({id: service.Id}, function (d) { - if (d.message) { - $('#loadServicesSpinner').hide(); - Notifications.error("Unable to remove service", {}, d[0].message); - } else { - if (service.Metadata && service.Metadata.ResourceControl) { - ResourceControlService.removeServiceResourceControl(service.Metadata.ResourceControl.OwnerId, service.Id) - .then(function success() { - Notifications.success("Service deleted", service.Id); - var index = $scope.services.indexOf(service); - $scope.services.splice(index, 1); - }) - .catch(function error(err) { - Notifications.error("Failure", err, "Unable to remove service ownership"); - }); - } else { - Notifications.success("Service deleted", service.Id); - var index = $scope.services.indexOf(service); - $scope.services.splice(index, 1); - } - } - complete(); - }, function (e) { - Notifications.error("Failure", e, 'Unable to remove service'); + ServiceService.remove(service) + .then(function success(data) { + Notifications.success('Service successfully deleted'); + var index = $scope.services.indexOf(service); + $scope.services.splice(index, 1); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove service'); + }) + .finally(function final() { complete(); }); } }); } - // $scope.removeAction = function () { - // - // }; - function mapUsersToServices(users) { angular.forEach($scope.services, function (service) { if (service.Metadata) { @@ -139,46 +93,33 @@ function ($q, $scope, $stateParams, $state, Service, ServiceHelper, Notification }); } - function fetchServices() { + function initView() { $('#loadServicesSpinner').show(); - - var userDetails = Authentication.getUserDetails(); - $scope.user = userDetails; - $q.all({ services: Service.query({}).$promise, tasks: Task.query({filters: {'desired-state': ['running']}}).$promise, - nodes: Node.query({}).$promise, + nodes: Node.query({}).$promise }) .then(function success(data) { $scope.swarmManagerIP = NodeHelper.getManagerIP(data.nodes); $scope.services = data.services.map(function (service) { var serviceTasks = data.tasks.filter(function (task) { - return task.ServiceID === service.ID && task.Status.State === "running"; + return task.ServiceID === service.ID && task.Status.State === 'running'; }); var taskNodes = data.nodes.filter(function (node) { return node.Spec.Availability === 'active' && node.Status.State === 'ready'; }); return new ServiceViewModel(service, serviceTasks, taskNodes); }); - if (userDetails.role === 1) { - UserService.users() - .then(function success(data) { - mapUsersToServices(data); - }) - .finally(function final() { - $('#loadServicesSpinner').hide(); - }); - } }) .catch(function error(err) { $scope.services = []; - Notifications.error("Failure", err, "Unable to retrieve services"); + Notifications.error('Failure', err, 'Unable to retrieve services'); }) .finally(function final() { $('#loadServicesSpinner').hide(); }); } - fetchServices(); + initView(); }]); diff --git a/app/components/settings/settings.html b/app/components/settings/settings.html index bc63db071..f0b235b46 100644 --- a/app/components/settings/settings.html +++ b/app/components/settings/settings.html @@ -21,11 +21,11 @@
-
-

- - Your new password must be at least 8 characters long -

+
+
+ + Current password is not valid +
@@ -38,6 +38,12 @@
+
+
+ + Your new password must be at least 8 characters long +
+
@@ -51,14 +57,9 @@
-
+
-
-

- Current password is not valid -

-
diff --git a/app/components/settings/settingsController.js b/app/components/settings/settingsController.js index 010634800..602a5612f 100644 --- a/app/components/settings/settingsController.js +++ b/app/components/settings/settingsController.js @@ -15,14 +15,14 @@ function ($scope, $state, $sanitize, Authentication, UserService, Notifications) UserService.updateUserPassword(userID, currentPassword, newPassword) .then(function success() { - Notifications.success("Success", "Password successfully updated"); + Notifications.success('Success', 'Password successfully updated'); $state.reload(); }) .catch(function error(err) { if (err.invalidPassword) { $scope.invalidPassword = true; } else { - Notifications.error("Failure", err, err.msg); + Notifications.error('Failure', err, err.msg); } }); }; diff --git a/app/components/sidebar/sidebar.html b/app/components/sidebar/sidebar.html index 85d60dc83..9a30b6b38 100644 --- a/app/components/sidebar/sidebar.html +++ b/app/components/sidebar/sidebar.html @@ -49,14 +49,16 @@ - - - - diff --git a/app/components/sidebar/sidebarController.js b/app/components/sidebar/sidebarController.js index 5bd466c70..350d82605 100644 --- a/app/components/sidebar/sidebarController.js +++ b/app/components/sidebar/sidebarController.js @@ -1,13 +1,13 @@ angular.module('sidebar', []) -.controller('SidebarController', ['$scope', '$state', 'Settings', 'Config', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications', 'Authentication', -function ($scope, $state, Settings, Config, EndpointService, StateManager, EndpointProvider, Notifications, Authentication) { +.controller('SidebarController', ['$q', '$scope', '$state', 'Settings', 'Config', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications', 'Authentication', 'UserService', +function ($q, $scope, $state, Settings, Config, EndpointService, StateManager, EndpointProvider, Notifications, Authentication, UserService) { Config.$promise.then(function (c) { $scope.logo = c.logo; }); $scope.uiVersion = Settings.uiVersion; - $scope.userRole = Authentication.getUserDetails().role; + $scope.endpoints = []; $scope.switchEndpoint = function(endpoint) { var activeEndpointID = EndpointProvider.endpointID(); @@ -27,22 +27,47 @@ function ($scope, $state, Settings, Config, EndpointService, StateManager, Endpo }); }; - function fetchEndpoints() { - EndpointService.endpoints() - .then(function success(data) { - $scope.endpoints = data; - var activeEndpointID = EndpointProvider.endpointID(); - angular.forEach($scope.endpoints, function (endpoint) { - if (endpoint.Id === activeEndpointID) { - $scope.activeEndpoint = endpoint; - EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); - } - }); - }) - .catch(function error(err) { - $scope.endpoints = []; + function setActiveEndpoint(endpoints) { + var activeEndpointID = EndpointProvider.endpointID(); + angular.forEach(endpoints, function (endpoint) { + if (endpoint.Id === activeEndpointID) { + $scope.activeEndpoint = endpoint; + EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); + } }); } - fetchEndpoints(); + function checkPermissions(memberships) { + var isLeader = false; + angular.forEach(memberships, function(membership) { + if (membership.Role === 1) { + isLeader = true; + } + }); + $scope.isTeamLeader = isLeader; + } + + function initView() { + EndpointService.endpoints() + .then(function success(data) { + var endpoints = data; + $scope.endpoints = endpoints; + setActiveEndpoint(endpoints); + + if (StateManager.getState().application.authentication) { + var userDetails = Authentication.getUserDetails(); + var isAdmin = userDetails.role === 1 ? true: false; + $scope.isAdmin = isAdmin; + return $q.when(!isAdmin ? UserService.userMemberships(userDetails.ID) : []); + } + }) + .then(function success(data) { + checkPermissions(data); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve endpoints'); + }); + } + + initView(); }]); diff --git a/app/components/stats/statsController.js b/app/components/stats/statsController.js index a9977bf19..d5a76616e 100644 --- a/app/components/stats/statsController.js +++ b/app/components/stats/statsController.js @@ -42,33 +42,33 @@ function (Pagination, $scope, Notifications, $timeout, Container, ContainerTop, networkRxData.push(0); } var cpuDataset = { // CPU Usage - fillColor: "rgba(151,187,205,0.5)", - strokeColor: "rgba(151,187,205,1)", - pointColor: "rgba(151,187,205,1)", - pointStrokeColor: "#fff", + fillColor: 'rgba(151,187,205,0.5)', + strokeColor: 'rgba(151,187,205,1)', + pointColor: 'rgba(151,187,205,1)', + pointStrokeColor: '#fff', data: cpuData }; var memoryDataset = { - fillColor: "rgba(151,187,205,0.5)", - strokeColor: "rgba(151,187,205,1)", - pointColor: "rgba(151,187,205,1)", - pointStrokeColor: "#fff", + fillColor: 'rgba(151,187,205,0.5)', + strokeColor: 'rgba(151,187,205,1)', + pointColor: 'rgba(151,187,205,1)', + pointStrokeColor: '#fff', data: memoryData }; var networkRxDataset = { - label: "Rx Bytes", - fillColor: "rgba(151,187,205,0.5)", - strokeColor: "rgba(151,187,205,1)", - pointColor: "rgba(151,187,205,1)", - pointStrokeColor: "#fff", + label: 'Rx Bytes', + fillColor: 'rgba(151,187,205,0.5)', + strokeColor: 'rgba(151,187,205,1)', + pointColor: 'rgba(151,187,205,1)', + pointStrokeColor: '#fff', data: networkRxData }; var networkTxDataset = { - label: "Tx Bytes", - fillColor: "rgba(255,180,174,0.5)", - strokeColor: "rgba(255,180,174,1)", - pointColor: "rgba(255,180,174,1)", - pointStrokeColor: "#fff", + label: 'Tx Bytes', + fillColor: 'rgba(255,180,174,0.5)', + strokeColor: 'rgba(255,180,174,1)', + pointColor: 'rgba(255,180,174,1)', + pointStrokeColor: '#fff', data: networkTxData }; var networkLegendData = [ @@ -87,7 +87,7 @@ function (Pagination, $scope, Notifications, $timeout, Container, ContainerTop, legend($('#network-legend').get(0), networkLegendData); Chart.defaults.global.animationSteps = 30; // Lower from 60 to ease CPU load. - var cpuChart = new Chart($('#cpu-stats-chart').get(0).getContext("2d")).Line({ + var cpuChart = new Chart($('#cpu-stats-chart').get(0).getContext('2d')).Line({ labels: cpuLabels, datasets: [cpuDataset] }, { @@ -108,7 +108,7 @@ function (Pagination, $scope, Notifications, $timeout, Container, ContainerTop, //scaleStepWidth: Math.ceil(initialStats.memory_stats.limit / 10), //scaleStartValue: 0 }); - var networkChart = new Chart($('#network-stats-chart').get(0).getContext("2d")).Line({ + var networkChart = new Chart($('#network-stats-chart').get(0).getContext('2d')).Line({ labels: networkLabels, datasets: [networkRxDataset, networkTxDataset] }, { @@ -211,7 +211,7 @@ function (Pagination, $scope, Notifications, $timeout, Container, ContainerTop, Container.get({id: $stateParams.id}, function (d) { $scope.container = d; }, function (e) { - Notifications.error("Failure", e, "Unable to retrieve container info"); + Notifications.error('Failure', e, 'Unable to retrieve container info'); }); $scope.getTop(); }]); diff --git a/app/components/task/task.html b/app/components/task/task.html index 57dd9883d..a657fa63d 100644 --- a/app/components/task/task.html +++ b/app/components/task/task.html @@ -2,12 +2,12 @@ - - Services > {{ serviceName }} > {{ task.ID }} + + Services > {{ service.Name }} > {{ task.Id }} -
+
@@ -16,7 +16,7 @@ ID - {{ task.ID }} + {{ task.Id }} State @@ -28,15 +28,15 @@ Image - {{ task.Spec.ContainerSpec.Image }} + {{ task.Spec.ContainerSpec.Image | hideshasum }} - + Slot {{ task.Slot }} Created - {{ task.CreatedAt|getisodate }} + {{ task.Created|getisodate }} Container ID diff --git a/app/components/task/taskController.js b/app/components/task/taskController.js index c705449d6..819c8c0d6 100644 --- a/app/components/task/taskController.js +++ b/app/components/task/taskController.js @@ -1,29 +1,26 @@ angular.module('task', []) -.controller('TaskController', ['$scope', '$stateParams', '$state', 'Task', 'Service', 'Notifications', -function ($scope, $stateParams, $state, Task, Service, Notifications) { +.controller('TaskController', ['$scope', '$stateParams', 'TaskService', 'Service', 'Notifications', +function ($scope, $stateParams, TaskService, Service, Notifications) { - $scope.task = {}; - $scope.serviceName = 'service'; - $scope.isTaskRunning = false; - - function fetchTaskDetails() { + function initView() { $('#loadingViewSpinner').show(); - Task.get({id: $stateParams.id}, function (d) { - $scope.task = d; - fetchAssociatedServiceDetails(d.ServiceID); + TaskService.task($stateParams.id) + .then(function success(data) { + var task = data; + $scope.task = task; + return Service.get({ id: task.ServiceId }).$promise; + }) + .then(function success(data) { + var service = new ServiceViewModel(data); + $scope.service = service; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve task details'); + }) + .finally(function final() { $('#loadingViewSpinner').hide(); - }, function (e) { - Notifications.error("Failure", e, "Unable to retrieve task details"); }); } - function fetchAssociatedServiceDetails(serviceId) { - Service.get({id: serviceId}, function (d) { - $scope.serviceName = d.Spec.Name; - }, function (e) { - Notifications.error("Failure", e, "Unable to retrieve associated service details"); - }); - } - - fetchTaskDetails(); + initView(); }]); diff --git a/app/components/team/team.html b/app/components/team/team.html new file mode 100644 index 000000000..b11fdd125 --- /dev/null +++ b/app/components/team/team.html @@ -0,0 +1,176 @@ + + + + + + Teams > {{ team.Name }} + + + +
+
+ + + + + + + + + + + + + + + + + + +
Name + {{ team.Name }} + +
Leaders{{ leaderCount }}
Total users in team{{ teamMembers.length }}
+
+
+
+
+ +
+
+ + +
+ Items per page: + +
+
+ +
+ +
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + +
+ + Name + + + +
+ {{ user.Username }} + + Add + +
Loading...
No users.
+
+ +
+
+
+
+
+
+ + +
+ Items per page: + +
+
+ +
+ +
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + +
+ + Name + + + + + + Team Role + + + +
+ {{ user.Username }} + + Remove + + + + + {{ user.TeamRole }} + + Leader + Member + +
Loading...
No team members.
+
+ +
+
+
+
+
+
diff --git a/app/components/team/teamController.js b/app/components/team/teamController.js new file mode 100644 index 000000000..694432253 --- /dev/null +++ b/app/components/team/teamController.js @@ -0,0 +1,229 @@ +angular.module('team', []) +.controller('TeamController', ['$q', '$scope', '$state', '$stateParams', 'TeamService', 'UserService', 'TeamMembershipService', 'ModalService', 'Notifications', 'Pagination', 'Authentication', +function ($q, $scope, $state, $stateParams, TeamService, UserService, TeamMembershipService, ModalService, Notifications, Pagination, Authentication) { + + $scope.state = { + pagination_count_users: Pagination.getPaginationCount('team_available_users'), + pagination_count_members: Pagination.getPaginationCount('team_members') + }; + $scope.sortTypeUsers = 'Username'; + $scope.sortReverseUsers = true; + $scope.users = []; + $scope.teamMembers = []; + $scope.leaderCount = 0; + + $scope.orderUsers = function(sortType) { + $scope.sortReverseUsers = ($scope.sortTypeUsers === sortType) ? !$scope.sortReverseUsers : false; + $scope.sortTypeUsers = sortType; + }; + + $scope.changePaginationCountUsers = function() { + Pagination.setPaginationCount('team_available_users', $scope.state.pagination_count_users); + }; + + $scope.sortTypeGroupMembers = 'TeamRole'; + $scope.sortReverseGroupMembers = false; + + $scope.orderGroupMembers = function(sortType) { + $scope.sortReverseGroupMembers = ($scope.sortTypeGroupMembers === sortType) ? !$scope.sortReverseGroupMembers : false; + $scope.sortTypeGroupMembers = sortType; + }; + + $scope.changePaginationCountGroupMembers = function() { + Pagination.setPaginationCount('team_members', $scope.state.pagination_count_members); + }; + + $scope.deleteTeam = function() { + ModalService.confirmDeletion( + 'Do you want to delete this team? Users in this team will not be deleted.', + function onConfirm(confirmed) { + if(!confirmed) { return; } + deleteTeam(); + } + ); + }; + + $scope.promoteToLeader = function(user) { + $('#loadingViewSpinner').show(); + TeamMembershipService.updateMembership(user.MembershipId, user.Id, $scope.team.Id, 1) + .then(function success(data) { + $scope.leaderCount++; + user.TeamRole = 'Leader'; + Notifications.success('User is now team leader', user.Username); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update user role'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + }; + + $scope.demoteToMember = function(user) { + $('#loadingViewSpinner').show(); + TeamMembershipService.updateMembership(user.MembershipId, user.Id, $scope.team.Id, 2) + .then(function success(data) { + user.TeamRole = 'Member'; + $scope.leaderCount--; + Notifications.success('User is now team member', user.Username); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update user role'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + }; + + $scope.addAllUsers = function() { + $('#loadingViewSpinner').show(); + var teamMembershipQueries = []; + angular.forEach($scope.users, function (user) { + teamMembershipQueries.push(TeamMembershipService.createMembership(user.Id, $scope.team.Id, 2)); + }); + $q.all(teamMembershipQueries) + .then(function success(data) { + var users = $scope.users; + for (var i = 0; i < users.length; i++) { + var user = users[i]; + user.MembershipId = data[i].Id; + user.TeamRole = 'Member'; + } + $scope.teamMembers = $scope.teamMembers.concat(users); + $scope.users = []; + Notifications.success('All users successfully added'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update team members'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + }; + + $scope.addUser = function(user) { + $('#loadingViewSpinner').show(); + TeamMembershipService.createMembership(user.Id, $scope.team.Id, 2) + .then(function success(data) { + removeUserFromArray(user.Id, $scope.users); + user.TeamRole = 'Member'; + user.MembershipId = data.Id; + $scope.teamMembers.push(user); + Notifications.success('User added to team', user.Username); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update team members'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + }; + + $scope.removeAllUsers = function() { + $('#loadingViewSpinner').show(); + var teamMembershipQueries = []; + angular.forEach($scope.teamMembers, function (user) { + teamMembershipQueries.push(TeamMembershipService.deleteMembership(user.MembershipId)); + }); + $q.all(teamMembershipQueries) + .then(function success(data) { + $scope.users = $scope.users.concat($scope.teamMembers); + $scope.teamMembers = []; + Notifications.success('All users successfully removed'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update team members'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + }; + + $scope.removeUser = function(user) { + $('#loadingViewSpinner').show(); + TeamMembershipService.deleteMembership(user.MembershipId) + .then(function success() { + removeUserFromArray(user.Id, $scope.teamMembers); + $scope.users.push(user); + Notifications.success('User removed from team', user.Username); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update team members'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + }; + + function deleteTeam() { + $('#loadingViewSpinner').show(); + TeamService.deleteTeam($scope.team.Id) + .then(function success(data) { + Notifications.success('Team successfully deleted', $scope.team.Name); + $state.go('teams'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove team'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + function removeUserFromArray(id, users) { + for (var i = 0, l = users.length; i < l; i++) { + if (users[i].Id === id) { + users.splice(i, 1); + return; + } + } + } + + function assignUsersAndMembers(users, memberships) { + for (var i = 0; i < users.length; i++) { + var user = users[i]; + var member = false; + for (var j = 0; j < memberships.length; j++) { + var membership = memberships[j]; + if (user.Id === membership.UserId) { + member = true; + if (membership.Role === 1) { + user.TeamRole = 'Leader'; + $scope.leaderCount++; + } else { + user.TeamRole = 'Member'; + } + user.MembershipId = membership.Id; + $scope.teamMembers.push(user); + break; + } + } + if (!member) { + $scope.users.push(user); + } + } + } + + function initView() { + $('#loadingViewSpinner').show(); + $scope.isAdmin = Authentication.getUserDetails().role === 1 ? true: false; + $q.all({ + team: TeamService.team($stateParams.id), + users: UserService.users(false), + memberships: TeamService.userMemberships($stateParams.id) + }) + .then(function success(data) { + var users = data.users; + $scope.team = data.team; + assignUsersAndMembers(users, data.memberships); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve team details'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + initView(); +}]); diff --git a/app/components/teams/teams.html b/app/components/teams/teams.html new file mode 100644 index 000000000..99fd2b8ad --- /dev/null +++ b/app/components/teams/teams.html @@ -0,0 +1,130 @@ + + + + + + + + Teams management + + +
+
+ + + + +
+ +
+ +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+
+ + + + {{ state.teamCreationError }} + +
+
+
+
+
+
+
+ +
+
+ + +
+ Items per page: + +
+
+ +
+ +
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + Name + + + +
{{ team.Name }} + Edit +
Loading...
No teams available.
+
+ +
+
+
+ +
+
diff --git a/app/components/teams/teamsController.js b/app/components/teams/teamsController.js new file mode 100644 index 000000000..413091460 --- /dev/null +++ b/app/components/teams/teamsController.js @@ -0,0 +1,140 @@ +angular.module('teams', []) +.controller('TeamsController', ['$q', '$scope', '$state', 'TeamService', 'UserService', 'TeamMembershipService', 'ModalService', 'Notifications', 'Pagination', 'Authentication', +function ($q, $scope, $state, TeamService, UserService, TeamMembershipService, ModalService, Notifications, Pagination, Authentication) { + $scope.state = { + userGroupGroupCreationError: '', + selectedItemCount: 0, + validName: false, + pagination_count: Pagination.getPaginationCount('teams') + }; + $scope.sortType = 'Name'; + $scope.sortReverse = false; + + $scope.formValues = { + Name: '', + Leaders: [] + }; + + $scope.order = function(sortType) { + $scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false; + $scope.sortType = sortType; + }; + + $scope.changePaginationCount = function() { + Pagination.setPaginationCount('teams', $scope.state.pagination_count); + }; + + $scope.selectItems = function (allSelected) { + angular.forEach($scope.state.filteredTeams, function (team) { + if (team.Checked !== allSelected) { + team.Checked = allSelected; + $scope.selectItem(team); + } + }); + }; + + $scope.selectItem = function (item) { + if (item.Checked) { + $scope.state.selectedItemCount++; + } else { + $scope.state.selectedItemCount--; + } + }; + + $scope.checkNameValidity = function() { + var valid = true; + for (var i = 0; i < $scope.teams.length; i++) { + if ($scope.formValues.Name === $scope.teams[i].Name) { + valid = false; + break; + } + } + $scope.state.validName = valid; + $scope.state.teamCreationError = valid ? '' : 'Team name already existing'; + }; + + $scope.addTeam = function() { + $('#createTeamSpinner').show(); + $scope.state.teamCreationError = ''; + var teamName = $scope.formValues.Name; + var leaderIds = []; + angular.forEach($scope.formValues.Leaders, function(user) { + leaderIds.push(user.Id); + }); + + TeamService.createTeam(teamName, leaderIds) + .then(function success(data) { + Notifications.success('Team successfully created', teamName); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create team'); + }) + .finally(function final() { + $('#createTeamSpinner').hide(); + }); + }; + + function deleteSelectedTeams() { + $('#loadingViewSpinner').show(); + var counter = 0; + var complete = function () { + counter = counter - 1; + if (counter === 0) { + $('#loadingViewSpinner').hide(); + } + }; + angular.forEach($scope.teams, function (team) { + if (team.Checked) { + counter = counter + 1; + TeamService.deleteTeam(team.Id) + .then(function success(data) { + var index = $scope.teams.indexOf(team); + $scope.teams.splice(index, 1); + Notifications.success('Team successfully deleted', team.Name); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove team'); + }) + .finally(function final() { + complete(); + }); + } + }); + } + + $scope.removeAction = function () { + ModalService.confirmDeletion( + 'Do you want to delete the selected team(s)? Users in the team(s) will not be deleted.', + function onConfirm(confirmed) { + if(!confirmed) { return; } + deleteSelectedTeams(); + } + ); + }; + + function initView() { + $('#loadingViewSpinner').show(); + var userDetails = Authentication.getUserDetails(); + var isAdmin = userDetails.role === 1 ? true: false; + $scope.isAdmin = isAdmin; + $q.all({ + users: UserService.users(false), + teams: isAdmin ? TeamService.teams() : UserService.userLeadingTeams(userDetails.ID) + }) + .then(function success(data) { + $scope.teams = data.teams; + $scope.users = data.users; + }) + .catch(function error(err) { + $scope.teams = []; + $scope.users = []; + Notifications.error('Failure', err, 'Unable to retrieve teams'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + initView(); +}]); diff --git a/app/components/templates/templates.html b/app/components/templates/templates.html index 784ef3271..4cadd180c 100644 --- a/app/components/templates/templates.html +++ b/app/components/templates/templates.html @@ -67,21 +67,9 @@
- -
-
- -
- - -
-
-
- - + +
+
diff --git a/app/components/templates/templatesController.js b/app/components/templates/templatesController.js index 9cec4c602..57fb2515a 100644 --- a/app/components/templates/templatesController.js +++ b/app/components/templates/templatesController.js @@ -1,18 +1,19 @@ angular.module('templates', []) -.controller('TemplatesController', ['$scope', '$q', '$state', '$stateParams', '$anchorScroll', '$filter', 'Config', 'ContainerService', 'ContainerHelper', 'ImageService', 'NetworkService', 'TemplateService', 'TemplateHelper', 'VolumeService', 'Notifications', 'Pagination', 'ResourceControlService', 'Authentication', -function ($scope, $q, $state, $stateParams, $anchorScroll, $filter, Config, ContainerService, ContainerHelper, ImageService, NetworkService, TemplateService, TemplateHelper, VolumeService, Notifications, Pagination, ResourceControlService, Authentication) { +.controller('TemplatesController', ['$scope', '$q', '$state', '$stateParams', '$anchorScroll', '$filter', 'Config', 'ContainerService', 'ContainerHelper', 'ImageService', 'NetworkService', 'TemplateService', 'TemplateHelper', 'VolumeService', 'Notifications', 'Pagination', 'ResourceControlService', 'Authentication', 'ControllerDataPipeline', 'FormValidator', +function ($scope, $q, $state, $stateParams, $anchorScroll, $filter, Config, ContainerService, ContainerHelper, ImageService, NetworkService, TemplateService, TemplateHelper, VolumeService, Notifications, Pagination, ResourceControlService, Authentication, ControllerDataPipeline, FormValidator) { $scope.state = { selectedTemplate: null, showAdvancedOptions: false, hideDescriptions: $stateParams.hide_descriptions, pagination_count: Pagination.getPaginationCount('templates'), + formValidationError: '', filters: { Categories: '!', Platform: '!' } }; + $scope.formValues = { - Ownership: $scope.applicationState.application.authentication ? 'private' : '', network: '', name: '' }; @@ -37,38 +38,55 @@ function ($scope, $q, $state, $stateParams, $anchorScroll, $filter, Config, Cont $scope.state.selectedTemplate.Ports.splice(index, 1); }; + function validateForm(accessControlData, isAdmin) { + $scope.state.formValidationError = ''; + var error = ''; + error = FormValidator.validateAccessControl(accessControlData, isAdmin); + + if (error) { + $scope.state.formValidationError = error; + return false; + } + return true; + } + $scope.createTemplate = function() { $('#createContainerSpinner').show(); + + var userDetails = Authentication.getUserDetails(); + var accessControlData = ControllerDataPipeline.getAccessControlFormData(); + var isAdmin = userDetails.role === 1 ? true : false; + + if (!validateForm(accessControlData, isAdmin)) { + $('#createContainerSpinner').hide(); + return; + } + var template = $scope.state.selectedTemplate; var templateConfiguration = createTemplateConfiguration(template); var generatedVolumeCount = TemplateHelper.determineRequiredGeneratedVolumeCount(template.Volumes); + var generatedVolumeIds = []; VolumeService.createXAutoGeneratedLocalVolumes(generatedVolumeCount) .then(function success(data) { var volumeResourceControlQueries = []; - if ($scope.formValues.Ownership === 'private') { - angular.forEach(data, function (volume) { - volumeResourceControlQueries.push(ResourceControlService.setVolumeResourceControl(Authentication.getUserDetails().ID, volume.Name)); - }); - } - TemplateService.updateContainerConfigurationWithVolumes(templateConfiguration, template, data); - return $q.all(volumeResourceControlQueries) - .then(function success() { - return ImageService.pullImage(template.Image, template.Registry); + angular.forEach(data, function (volume) { + var volumeId = volume.Id; + generatedVolumeIds.push(volumeId); }); + TemplateService.updateContainerConfigurationWithVolumes(templateConfiguration, template, data); + return ImageService.pullImage(template.Image, template.Registry); }) .then(function success(data) { return ContainerService.createAndStartContainer(templateConfiguration); }) .then(function success(data) { - Notifications.success('Container started', data.Id); - if ($scope.formValues.Ownership === 'private') { - ResourceControlService.setContainerResourceControl(Authentication.getUserDetails().ID, data.Id) - .then(function success(data) { - $state.go('containers', {}, {reload: true}); - }); - } else { - $state.go('containers', {}, {reload: true}); - } + var containerIdentifier = data.Id; + var userId = userDetails.ID; + return ResourceControlService.applyResourceControl('container', containerIdentifier, userId, accessControlData, generatedVolumeIds); + }) + .then(function success() { + Notifications.success('Container successfully created'); + $state.go('containers', {}, {reload: true}); }) .catch(function error(err) { Notifications.error('Failure', err, err.msg); diff --git a/app/components/user/user.html b/app/components/user/user.html index 425d25cdf..0f1bc95cd 100644 --- a/app/components/user/user.html +++ b/app/components/user/user.html @@ -15,12 +15,13 @@ - + + +
Name {{ user.Username }}
@@ -71,11 +95,6 @@
-
-

- {{ state.updatePasswordError }} -

-
diff --git a/app/components/user/userController.js b/app/components/user/userController.js index 6c0040432..348457c51 100644 --- a/app/components/user/userController.js +++ b/app/components/user/userController.js @@ -1,15 +1,15 @@ angular.module('user', []) -.controller('UserController', ['$scope', '$state', '$stateParams', 'UserService', 'ModalService', 'Notifications', -function ($scope, $state, $stateParams, UserService, ModalService, Notifications) { +.controller('UserController', ['$q', '$scope', '$state', '$stateParams', 'UserService', 'ModalService', 'Notifications', +function ($q, $scope, $state, $stateParams, UserService, ModalService, Notifications) { $scope.state = { - updatePasswordError: '', + updatePasswordError: '' }; $scope.formValues = { newPassword: '', confirmPassword: '', - Administrator: false, + Administrator: false }; $scope.deleteUser = function() { @@ -32,7 +32,7 @@ function ($scope, $state, $stateParams, UserService, ModalService, Notifications $state.reload(); }) .catch(function error(err) { - Notifications.error("Failure", err, 'Unable to update user permissions'); + Notifications.error('Failure', err, 'Unable to update user permissions'); }) .finally(function final() { $('#loadingViewSpinner').hide(); @@ -47,7 +47,7 @@ function ($scope, $state, $stateParams, UserService, ModalService, Notifications $state.reload(); }) .catch(function error(err) { - $scope.state.updatePasswordError = 'Unable to update password'; + Notifications.error('Failure', err, 'Unable to update user password'); }) .finally(function final() { $('#loadingViewSpinner').hide(); @@ -62,28 +62,30 @@ function ($scope, $state, $stateParams, UserService, ModalService, Notifications $state.go('users'); }) .catch(function error(err) { - Notifications.error("Failure", err, 'Unable to remove user'); + Notifications.error('Failure', err, 'Unable to remove user'); }) .finally(function final() { $('#loadingViewSpinner').hide(); }); } - function getUser() { + function initView() { $('#loadingViewSpinner').show(); - UserService.user($stateParams.id) + $q.all({ + user: UserService.user($stateParams.id) + }) .then(function success(data) { - var user = new UserViewModel(data); + var user = data.user; $scope.user = user; - $scope.formValues.Administrator = user.RoleId === 1 ? true : false; + $scope.formValues.Administrator = user.Role === 1 ? true : false; }) .catch(function error(err) { - Notifications.error("Failure", err, 'Unable to retrieve user information'); + Notifications.error('Failure', err, 'Unable to retrieve user information'); }) .finally(function final() { $('#loadingViewSpinner').hide(); }); } - getUser(); + initView(); }]); diff --git a/app/components/users/users.html b/app/components/users/users.html index 9db1ff585..e53ddec03 100644 --- a/app/components/users/users.html +++ b/app/components/users/users.html @@ -49,8 +49,8 @@
- -
+ +
- -
+ + +
- Note: non-administrator users do not have access to any endpoint by default. Head over the endpoints view to manage their accesses. + + + You have not yet created any team. Head over the teams view to manage user teams. + + +
+
+ +
+
+ + Note: non-administrator users with no team do not have access to any endpoint by default. Head over the endpoints view to manage their accesses. +
@@ -98,7 +124,7 @@
-
+
@@ -110,7 +136,7 @@ - - + - + - diff --git a/app/components/users/usersController.js b/app/components/users/usersController.js index 6e35233bf..c9c940ec7 100644 --- a/app/components/users/usersController.js +++ b/app/components/users/usersController.js @@ -1,6 +1,6 @@ angular.module('users', []) -.controller('UsersController', ['$scope', '$state', 'UserService', 'ModalService', 'Notifications', 'Pagination', -function ($scope, $state, UserService, ModalService, Notifications, Pagination) { +.controller('UsersController', ['$q', '$scope', '$state', 'UserService', 'TeamService', 'TeamMembershipService', 'ModalService', 'Notifications', 'Pagination', 'Authentication', +function ($q, $scope, $state, UserService, TeamService, TeamMembershipService, ModalService, Notifications, Pagination, Authentication) { $scope.state = { userCreationError: '', selectedItemCount: 0, @@ -15,6 +15,7 @@ function ($scope, $state, UserService, ModalService, Notifications, Pagination) Password: '', ConfirmPassword: '', Administrator: false, + Teams: [] }; $scope.order = function(sortType) { @@ -56,20 +57,25 @@ function ($scope, $state, UserService, ModalService, Notifications, Pagination) }; $scope.addUser = function() { + $('#createUserSpinner').show(); $scope.state.userCreationError = ''; var username = $scope.formValues.Username; var password = $scope.formValues.Password; var role = $scope.formValues.Administrator ? 1 : 2; - UserService.createUser(username, password, role) + var teamIds = []; + angular.forEach($scope.formValues.Teams, function(team) { + teamIds.push(team.Id); + }); + UserService.createUser(username, password, role, teamIds) .then(function success(data) { - Notifications.success("User created", username); + Notifications.success('User successfully created', username); $state.reload(); }) .catch(function error(err) { - $scope.state.userCreationError = err.msg; + Notifications.error('Failure', err, 'Unable to create user'); }) .finally(function final() { - + $('#createUserSpinner').hide(); }); }; @@ -92,7 +98,7 @@ function ($scope, $state, UserService, ModalService, Notifications, Pagination) Notifications.success('User successfully deleted', user.Username); }) .catch(function error(err) { - Notifications.error("Failure", err, 'Unable to remove user'); + Notifications.error('Failure', err, 'Unable to remove user'); }) .finally(function final() { complete(); @@ -111,22 +117,46 @@ function ($scope, $state, UserService, ModalService, Notifications, Pagination) ); }; - function fetchUsers() { + function assignTeamLeaders(users, memberships) { + for (var i = 0; i < users.length; i++) { + var user = users[i]; + user.isTeamLeader = false; + for (var j = 0; j < memberships.length; j++) { + var membership = memberships[j]; + if (user.Id === membership.UserId && membership.Role === 1) { + user.isTeamLeader = true; + user.RoleName = 'team leader'; + break; + } + } + } + } + + function initView() { $('#loadUsersSpinner').show(); - UserService.users() + var userDetails = Authentication.getUserDetails(); + var isAdmin = userDetails.role === 1 ? true: false; + $scope.isAdmin = isAdmin; + $q.all({ + users: UserService.users(true), + teams: isAdmin ? TeamService.teams() : UserService.userLeadingTeams(userDetails.ID), + memberships: TeamMembershipService.memberships() + }) .then(function success(data) { - $scope.users = data.map(function(user) { - return new UserViewModel(user); - }); + var users = data.users; + assignTeamLeaders(users, data.memberships); + $scope.users = users; + $scope.teams = data.teams; }) .catch(function error(err) { - Notifications.error("Failure", err, "Unable to retrieve users"); + Notifications.error('Failure', err, 'Unable to retrieve users and teams'); $scope.users = []; + $scope.teams = []; }) .finally(function final() { $('#loadUsersSpinner').hide(); }); } - fetchUsers(); + initView(); }]); diff --git a/app/components/volume/volume.html b/app/components/volume/volume.html new file mode 100644 index 000000000..87eeca0f6 --- /dev/null +++ b/app/components/volume/volume.html @@ -0,0 +1,68 @@ + + + + + + Volumes > {{ volume.Id }} + + + +
+
+ + + +
+ @@ -127,18 +153,20 @@
{{ user.Username }} + + + {{ user.RoleName }} - + Edit
+ + + + + + + + + + + + + + + + + + +
ID + {{ volume.Id }} + +
Mount path{{ volume.Mountpoint }}
Driver{{ volume.Driver }}
Labels + + + + + +
{{ k }}{{ v }}
+
+ + +
+
+ +
+ +
+
+ + + + + + + + + + +
{{ key }}{{ value }}
+
+
+
+
diff --git a/app/components/volume/volumeController.js b/app/components/volume/volumeController.js new file mode 100644 index 000000000..1a0b5cc01 --- /dev/null +++ b/app/components/volume/volumeController.js @@ -0,0 +1,37 @@ +angular.module('volume', []) +.controller('VolumeController', ['$scope', '$state', '$stateParams', 'VolumeService', 'Notifications', 'ControllerDataPipeline', +function ($scope, $state, $stateParams, VolumeService, Notifications, ControllerDataPipeline) { + + $scope.removeVolume = function removeVolume() { + $('#loadingViewSpinner').show(); + VolumeService.remove($scope.volume) + .then(function success(data) { + Notifications.success('Volume successfully removed', $stateParams.id); + $state.go('volumes', {}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove volume'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + }; + + function initView() { + $('#loadingViewSpinner').show(); + VolumeService.volume($stateParams.id) + .then(function success(data) { + var volume = data; + ControllerDataPipeline.setAccessControlData('volume', volume.Id, volume.ResourceControl); + $scope.volume = volume; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve volume details'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + initView(); +}]); diff --git a/app/components/volumes/volumes.html b/app/components/volumes/volumes.html index 6a6fcbd42..92b318b16 100644 --- a/app/components/volumes/volumes.html +++ b/app/components/volumes/volumes.html @@ -40,10 +40,10 @@ - + Name - - + + @@ -53,18 +53,11 @@ - - - Mountpoint - - - - - + Ownership - - + + @@ -72,28 +65,12 @@ - {{ volume.Name|truncate:50 }} + {{ volume.Id|truncate:50 }} {{ volume.Driver }} - {{ volume.Mountpoint }} - - - - Private - - - Private (owner: {{ volume.Owner }}) - - Switch to public - - - - Private - Switch to public - - - - Public + + + {{ volume.ResourceControl.Ownership ? volume.ResourceControl.Ownership : volume.ResourceControl.Ownership = 'public' }} diff --git a/app/components/volumes/volumesController.js b/app/components/volumes/volumesController.js index 7ec5774ad..89f3bd0c3 100644 --- a/app/components/volumes/volumesController.js +++ b/app/components/volumes/volumesController.js @@ -1,32 +1,11 @@ angular.module('volumes', []) -.controller('VolumesController', ['$scope', '$state', 'Volume', 'Notifications', 'Pagination', 'ModalService', 'Authentication', 'ResourceControlService', 'UserService', -function ($scope, $state, Volume, Notifications, Pagination, ModalService, Authentication, ResourceControlService, UserService) { +.controller('VolumesController', ['$q', '$scope', 'VolumeService', 'Notifications', 'Pagination', +function ($q, $scope, VolumeService, Notifications, Pagination) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('volumes'); $scope.state.selectedItemCount = 0; - $scope.sortType = 'Name'; - $scope.sortReverse = true; - $scope.config = { - Name: '' - }; - - function removeVolumeResourceControl(volume) { - ResourceControlService.removeVolumeResourceControl(volume.Metadata.ResourceControl.OwnerId, volume.Name) - .then(function success() { - delete volume.Metadata.ResourceControl; - Notifications.success('Ownership changed to public', volume.Name); - }) - .catch(function error(err) { - Notifications.error("Failure", err, "Unable to change volume ownership"); - }); - } - - $scope.switchOwnership = function(volume) { - ModalService.confirmVolumeOwnershipChange(function (confirmed) { - if(!confirmed) { return; } - removeVolumeResourceControl(volume); - }); - }; + $scope.sortType = 'Id'; + $scope.sortReverse = false; $scope.changePaginationCount = function() { Pagination.setPaginationCount('volumes', $scope.state.pagination_count); @@ -57,88 +36,46 @@ function ($scope, $state, Volume, Notifications, Pagination, ModalService, Authe $scope.removeAction = function () { $('#loadVolumesSpinner').show(); var counter = 0; + var complete = function () { counter = counter - 1; if (counter === 0) { $('#loadVolumesSpinner').hide(); } }; + angular.forEach($scope.volumes, function (volume) { if (volume.Checked) { counter = counter + 1; - Volume.remove({name: volume.Name}, function (d) { - if (d.message) { - Notifications.error("Unable to remove volume", {}, d.message); - } else { - if (volume.Metadata && volume.Metadata.ResourceControl) { - ResourceControlService.removeVolumeResourceControl(volume.Metadata.ResourceControl.OwnerId, volume.Name) - .then(function success() { - Notifications.success("Volume deleted", volume.Name); - var index = $scope.volumes.indexOf(volume); - $scope.volumes.splice(index, 1); - }) - .catch(function error(err) { - Notifications.error("Failure", err, "Unable to remove volume ownership"); - }); - } else { - Notifications.success("Volume deleted", volume.Name); - var index = $scope.volumes.indexOf(volume); - $scope.volumes.splice(index, 1); - } - } - complete(); - }, function (e) { - Notifications.error("Failure", e, "Unable to remove volume"); + VolumeService.remove(volume) + .then(function success() { + Notifications.success('Volume deleted', volume.Id); + var index = $scope.volumes.indexOf(volume); + $scope.volumes.splice(index, 1); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove volume'); + }) + .finally(function final() { complete(); }); } }); }; - function mapUsersToVolumes(users) { - angular.forEach($scope.volumes, function (volume) { - if (volume.Metadata) { - var volumeRC = volume.Metadata.ResourceControl; - if (volumeRC && volumeRC.OwnerId !== $scope.user.ID) { - angular.forEach(users, function (user) { - if (volumeRC.OwnerId === user.Id) { - volume.Owner = user.Username; - } - }); - } - } - }); - } - - function fetchVolumes() { + function initView() { $('#loadVolumesSpinner').show(); - var userDetails = Authentication.getUserDetails(); - $scope.user = userDetails; - - Volume.query({}, function (d) { - var volumes = d.Volumes || []; - $scope.volumes = volumes.map(function (v) { - return new VolumeViewModel(v); - }); - if (userDetails.role === 1) { - UserService.users() - .then(function success(data) { - mapUsersToVolumes(data); - }) - .catch(function error(err) { - Notifications.error("Failure", err, "Unable to retrieve users"); - }) - .finally(function final() { - $('#loadVolumesSpinner').hide(); - }); - } else { - $('#loadVolumesSpinner').hide(); - } - }, function (e) { - $('#loadVolumesSpinner').hide(); - Notifications.error("Failure", e, "Unable to retrieve volumes"); + VolumeService.volumes() + .then(function success(data) { + $scope.volumes = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve volumes'); $scope.volumes = []; + }) + .finally(function final() { + $('#loadVolumesSpinner').hide(); }); } - fetchVolumes(); + initView(); }]); diff --git a/app/directives/header-content.js b/app/directives/header-content.js index e372e28a4..8636c8ebe 100644 --- a/app/directives/header-content.js +++ b/app/directives/header-content.js @@ -7,7 +7,7 @@ angular link: function (scope, iElement, iAttrs) { scope.username = Authentication.getUserDetails().username; }, - template: '', + template: '', restrict: 'E' }; return directive; diff --git a/app/directives/header.js b/app/directives/header.js index 016a25a0c..18451ce9b 100644 --- a/app/directives/header.js +++ b/app/directives/header.js @@ -3,7 +3,7 @@ angular .directive('rdHeader', function rdHeader() { var directive = { scope: { - "ngModel": "=" + 'ngModel': '=' }, transclude: true, template: '
', diff --git a/app/filters/filters.js b/app/filters/filters.js index 396e35d45..5f0569cd4 100644 --- a/app/filters/filters.js +++ b/app/filters/filters.js @@ -246,4 +246,29 @@ angular.module('portainer.filters', []) } return ''; }; +}) +.filter('ownershipicon', function () { + 'use strict'; + return function (ownership) { + switch (ownership) { + case 'private': + return 'fa fa-eye-slash'; + case 'administrators': + return 'fa fa-eye-slash'; + case 'restricted': + return 'fa fa-users'; + default: + return 'fa fa-eye'; + } + }; +}) +.filter('tasknodename', function () { + 'use strict'; + return function (nodeId, nodes) { + var node = _.find(nodes, { Id: nodeId }); + if (node) { + return node.Hostname; + } + return ''; + }; }); diff --git a/app/helpers/infoHelper.js b/app/helpers/infoHelper.js index 5f2ad14cd..fd3aa511a 100644 --- a/app/helpers/infoHelper.js +++ b/app/helpers/infoHelper.js @@ -8,21 +8,21 @@ angular.module('portainer.helpers') role: '' }; if (_.startsWith(info.ServerVersion, 'swarm')) { - mode.provider = "DOCKER_SWARM"; + mode.provider = 'DOCKER_SWARM'; if (info.SystemStatus[0][1] === 'primary') { - mode.role = "PRIMARY"; + mode.role = 'PRIMARY'; } else { - mode.role = "REPLICA"; + mode.role = 'REPLICA'; } } else { if (!info.Swarm || _.isEmpty(info.Swarm.NodeID)) { - mode.provider = "DOCKER_STANDALONE"; + mode.provider = 'DOCKER_STANDALONE'; } else { - mode.provider = "DOCKER_SWARM_MODE"; + mode.provider = 'DOCKER_SWARM_MODE'; if (info.Swarm.ControlAvailable) { - mode.role = "MANAGER"; + mode.role = 'MANAGER'; } else { - mode.role = "WORKER"; + mode.role = 'WORKER'; } } } diff --git a/app/helpers/nodeHelper.js b/app/helpers/nodeHelper.js index 748f8aa91..b40903cc8 100644 --- a/app/helpers/nodeHelper.js +++ b/app/helpers/nodeHelper.js @@ -13,10 +13,10 @@ angular.module('portainer.helpers') getManagerIP: function(nodes) { var managerIp; for (var n in nodes) { - if (undefined === nodes[n].ManagerStatus || nodes[n].ManagerStatus.Reachability !== "reachable") { + if (undefined === nodes[n].ManagerStatus || nodes[n].ManagerStatus.Reachability !== 'reachable') { continue; } - managerIp = nodes[n].ManagerStatus.Addr.split(":")[0]; + managerIp = nodes[n].ManagerStatus.Addr.split(':')[0]; } return managerIp; } diff --git a/app/helpers/resourceControlHelper.js b/app/helpers/resourceControlHelper.js new file mode 100644 index 000000000..b5ca4d1c7 --- /dev/null +++ b/app/helpers/resourceControlHelper.js @@ -0,0 +1,42 @@ +angular.module('portainer.helpers') +.factory('ResourceControlHelper', [function ResourceControlHelperFactory() { + 'use strict'; + var helper = {}; + + helper.retrieveAuthorizedUsers = function(resourceControl, users) { + var authorizedUserNames = []; + angular.forEach(resourceControl.UserAccesses, function(access) { + var user = _.find(users, { Id: access.UserId }); + if (user) { + authorizedUserNames.push(user); + } + }); + return authorizedUserNames; + }; + + helper.retrieveAuthorizedTeams = function(resourceControl, teams) { + var authorizedTeamNames = []; + angular.forEach(resourceControl.TeamAccesses, function(access) { + var team = _.find(teams, { Id: access.TeamId }); + if (team) { + authorizedTeamNames.push(team); + } + }); + return authorizedTeamNames; + }; + + helper.isLeaderOfAnyRestrictedTeams = function(userMemberships, resourceControl) { + var isTeamLeader = false; + for (var i = 0; i < userMemberships.length; i++) { + var membership = userMemberships[i]; + var found = _.find(resourceControl.TeamAccesses, { TeamId :membership.TeamId }); + if (found && membership.Role === 1) { + isTeamLeader = true; + break; + } + } + return isTeamLeader; + }; + + return helper; +}]); diff --git a/app/helpers/templateHelper.js b/app/helpers/templateHelper.js index a97bdd978..a4f626fd5 100644 --- a/app/helpers/templateHelper.js +++ b/app/helpers/templateHelper.js @@ -83,7 +83,7 @@ angular.module('portainer.helpers') if (volume.containerPath) { var binding; if (volume.type === 'auto') { - binding = generatedVolumesPile.pop().Name + ':' + volume.containerPath; + binding = generatedVolumesPile.pop().Id + ':' + volume.containerPath; } else if (volume.type !== 'auto' && volume.name) { binding = volume.name + ':' + volume.containerPath; } diff --git a/app/helpers/userHelper.js b/app/helpers/userHelper.js new file mode 100644 index 000000000..5962b1bb5 --- /dev/null +++ b/app/helpers/userHelper.js @@ -0,0 +1,15 @@ +angular.module('portainer.helpers') +.factory('UserHelper', [function UserHelperFactory() { + 'use strict'; + var helper = {}; + + helper.filterNonAdministratorUsers = function(users) { + return users.filter(function (user) { + if (user.Role !== 1) { + return user; + } + }); + }; + + return helper; +}]); diff --git a/app/models/api/endpointAccess.js b/app/models/api/endpointAccess.js new file mode 100644 index 000000000..d592522f4 --- /dev/null +++ b/app/models/api/endpointAccess.js @@ -0,0 +1,11 @@ +function EndpointAccessUserViewModel(data) { + this.Id = data.Id; + this.Name = data.Username; + this.Type = 'user'; +} + +function EndpointAccessTeamViewModel(data) { + this.Id = data.Id; + this.Name = data.Name; + this.Type = 'team'; +} diff --git a/app/models/api/resourceControl.js b/app/models/api/resourceControl.js new file mode 100644 index 000000000..f7e08c3f1 --- /dev/null +++ b/app/models/api/resourceControl.js @@ -0,0 +1,19 @@ +function ResourceControlViewModel(data) { + this.Id = data.Id; + this.Type = data.Type; + this.ResourceId = data.ResourceId; + this.UserAccesses = data.UserAccesses; + this.TeamAccesses = data.TeamAccesses; + this.AdministratorsOnly = data.AdministratorsOnly; + this.Ownership = determineOwnership(this); +} + +function determineOwnership(resourceControl) { + if (resourceControl.AdministratorsOnly) { + return 'administrators'; + } else if (resourceControl.UserAccesses.length === 1 && resourceControl.TeamAccesses.length === 0) { + return 'private'; + } else if (resourceControl.UserAccesses.length > 1 || resourceControl.TeamAccesses.length > 0) { + return 'restricted'; + } +} diff --git a/app/models/api/team.js b/app/models/api/team.js new file mode 100644 index 000000000..447d2c852 --- /dev/null +++ b/app/models/api/team.js @@ -0,0 +1,5 @@ +function TeamViewModel(data) { + this.Id = data.Id; + this.Name = data.Name; + this.Checked = false; +} diff --git a/app/models/api/teamMembership.js b/app/models/api/teamMembership.js new file mode 100644 index 000000000..0d2454547 --- /dev/null +++ b/app/models/api/teamMembership.js @@ -0,0 +1,6 @@ +function TeamMembershipModel(data) { + this.Id = data.Id; + this.UserId = data.UserID; + this.TeamId = data.TeamID; + this.Role = data.Role; +} diff --git a/app/models/template.js b/app/models/api/template.js similarity index 100% rename from app/models/template.js rename to app/models/api/template.js diff --git a/app/models/templateLinuxServer.js b/app/models/api/templateLinuxServer.js similarity index 100% rename from app/models/templateLinuxServer.js rename to app/models/api/templateLinuxServer.js diff --git a/app/models/user.js b/app/models/api/user.js similarity index 62% rename from app/models/user.js rename to app/models/api/user.js index 7b021284b..1177fc137 100644 --- a/app/models/user.js +++ b/app/models/api/user.js @@ -1,11 +1,11 @@ function UserViewModel(data) { this.Id = data.Id; this.Username = data.Username; - this.RoleId = data.Role; + this.Role = data.Role; if (data.Role === 1) { - this.RoleName = "administrator"; + this.RoleName = 'administrator'; } else { - this.RoleName = "user"; + this.RoleName = 'user'; } this.Checked = false; } diff --git a/app/models/container.js b/app/models/docker/container.js similarity index 85% rename from app/models/container.js rename to app/models/docker/container.js index bb4a183f8..8041a24e3 100644 --- a/app/models/container.js +++ b/app/models/docker/container.js @@ -20,11 +20,8 @@ function ContainerViewModel(data) { } } if (data.Portainer) { - this.Metadata = {}; if (data.Portainer.ResourceControl) { - this.Metadata.ResourceControl = { - OwnerId: data.Portainer.ResourceControl.OwnerId - }; + this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); } } } diff --git a/app/models/docker/containerDetails.js b/app/models/docker/containerDetails.js new file mode 100644 index 000000000..58ecd17d7 --- /dev/null +++ b/app/models/docker/containerDetails.js @@ -0,0 +1,15 @@ +function ContainerDetailsViewModel(data) { + this.Id = data.Id; + this.State = data.State; + this.Name = data.Name; + this.NetworkSettings = data.NetworkSettings; + this.Args = data.Args; + this.Image = data.Image; + this.Config = data.Config; + this.HostConfig = data.HostConfig; + if (data.Portainer) { + if (data.Portainer.ResourceControl) { + this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); + } + } +} diff --git a/app/models/event.js b/app/models/docker/event.js similarity index 100% rename from app/models/event.js rename to app/models/docker/event.js diff --git a/app/models/image.js b/app/models/docker/image.js similarity index 100% rename from app/models/image.js rename to app/models/docker/image.js diff --git a/app/models/imageDetails.js b/app/models/docker/imageDetails.js similarity index 100% rename from app/models/imageDetails.js rename to app/models/docker/imageDetails.js diff --git a/app/models/node.js b/app/models/docker/node.js similarity index 100% rename from app/models/node.js rename to app/models/docker/node.js diff --git a/app/models/service.js b/app/models/docker/service.js similarity index 95% rename from app/models/service.js rename to app/models/docker/service.js index 5f59e2407..766a4aaee 100644 --- a/app/models/service.js +++ b/app/models/docker/service.js @@ -80,11 +80,8 @@ function ServiceViewModel(data, runningTasks, nodes) { this.EditName = false; if (data.Portainer) { - this.Metadata = {}; if (data.Portainer.ResourceControl) { - this.Metadata.ResourceControl = { - OwnerId: data.Portainer.ResourceControl.OwnerId - }; + this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); } } } diff --git a/app/models/docker/task.js b/app/models/docker/task.js new file mode 100644 index 000000000..ab23be4c0 --- /dev/null +++ b/app/models/docker/task.js @@ -0,0 +1,10 @@ +function TaskViewModel(data) { + this.Id = data.ID; + this.Created = data.CreatedAt; + this.Updated = data.UpdatedAt; + this.Slot = data.Slot; + this.Spec = data.Spec; + this.Status = data.Status; + this.ServiceId = data.ServiceID; + this.NodeId = data.NodeID; +} diff --git a/app/models/volume.js b/app/models/docker/volume.js similarity index 50% rename from app/models/volume.js rename to app/models/docker/volume.js index f46356ce5..fc6dbd848 100644 --- a/app/models/volume.js +++ b/app/models/docker/volume.js @@ -1,14 +1,13 @@ function VolumeViewModel(data) { - this.Id = data.Id; - this.Name = data.Name; + this.Id = data.Name; this.Driver = data.Driver; + this.Options = data.Options; + this.Labels = data.Labels; this.Mountpoint = data.Mountpoint; + if (data.Portainer) { - this.Metadata = {}; if (data.Portainer.ResourceControl) { - this.Metadata.ResourceControl = { - OwnerId: data.Portainer.ResourceControl.OwnerId - }; + this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); } } } diff --git a/app/models/task.js b/app/models/task.js deleted file mode 100644 index d1b82e8a6..000000000 --- a/app/models/task.js +++ /dev/null @@ -1,15 +0,0 @@ -function TaskViewModel(data, node_data) { - this.Id = data.ID; - this.Created = data.CreatedAt; - this.Updated = data.UpdatedAt; - this.Slot = data.Slot; - this.Status = data.Status.State; - this.Image = data.Spec.ContainerSpec ? data.Spec.ContainerSpec.Image : ''; - if (node_data) { - for (var i = 0; i < node_data.length; ++i) { - if (data.NodeID === node_data[i].ID) { - this.Node = node_data[i].Description.Hostname; - } - } - } -} diff --git a/app/rest/endpoint.js b/app/rest/endpoint.js index 5919c0b61..c2cf17fdf 100644 --- a/app/rest/endpoint.js +++ b/app/rest/endpoint.js @@ -7,6 +7,6 @@ angular.module('portainer.rest') 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'} }, + remove: { method: 'DELETE', params: { id: '@id'} } }); }]); diff --git a/app/rest/resourceControl.js b/app/rest/resourceControl.js index 7734429e6..bcdebde65 100644 --- a/app/rest/resourceControl.js +++ b/app/rest/resourceControl.js @@ -1,8 +1,10 @@ angular.module('portainer.rest') -.factory('ResourceControl', ['$resource', 'USERS_ENDPOINT', function ResourceControlFactory($resource, USERS_ENDPOINT) { +.factory('ResourceControl', ['$resource', 'RESOURCE_CONTROL_ENDPOINT', function ResourceControlFactory($resource, RESOURCE_CONTROL_ENDPOINT) { 'use strict'; - return $resource(USERS_ENDPOINT + '/:userId/resources/:resourceType/:resourceId', {}, { - create: { method: 'POST', params: { userId: '@userId', resourceType: '@resourceType' } }, - remove: { method: 'DELETE', params: { userId: '@userId', resourceId: '@resourceId', resourceType: '@resourceType' } }, + return $resource(RESOURCE_CONTROL_ENDPOINT + '/:id', {}, { + create: { method: 'POST' }, + get: { method: 'GET', params: { id: '@id' } }, + update: { method: 'PUT', params: { id: '@id' } }, + remove: { method: 'DELETE', params: { id: '@id'} } }); }]); diff --git a/app/rest/response/handlers.js b/app/rest/response/handlers.js index 8b60fea6f..03aa6c1d1 100644 --- a/app/rest/response/handlers.js +++ b/app/rest/response/handlers.js @@ -5,7 +5,7 @@ function isJSONArray(jsonString) { function isJSON(jsonString) { try { var o = JSON.parse(jsonString); - if (o && typeof o === "object") { + if (o && typeof o === 'object') { return o; } } @@ -17,7 +17,7 @@ function isJSON(jsonString) { // This handler wrap the JSON objects in an array. // Used by the API in: Image push, Image create, Events query. function jsonObjectsToArrayHandler(data) { - var str = "[" + data.replace(/\n/g, " ").replace(/\}\s*\{/g, "}, {") + "]"; + var str = '[' + data.replace(/\n/g, ' ').replace(/\}\s*\{/g, '}, {') + ']'; return angular.fromJson(str); } diff --git a/app/rest/team.js b/app/rest/team.js new file mode 100644 index 000000000..fd55e95b3 --- /dev/null +++ b/app/rest/team.js @@ -0,0 +1,12 @@ +angular.module('portainer.rest') +.factory('Teams', ['$resource', 'TEAMS_ENDPOINT', function TeamsFactory($resource, TEAMS_ENDPOINT) { + 'use strict'; + return $resource(TEAMS_ENDPOINT + '/:id/:entity/:entityId', {}, { + create: { method: 'POST' }, + query: { method: 'GET', isArray: true }, + get: { method: 'GET', params: { id: '@id' } }, + update: { method: 'PUT', params: { id: '@id' } }, + remove: { method: 'DELETE', params: { id: '@id'} }, + queryMemberships: { method: 'GET', isArray: true, params: { id: '@id', entity: 'memberships' } } + }); +}]); diff --git a/app/rest/teamMembership.js b/app/rest/teamMembership.js new file mode 100644 index 000000000..39b49134c --- /dev/null +++ b/app/rest/teamMembership.js @@ -0,0 +1,10 @@ +angular.module('portainer.rest') +.factory('TeamMemberships', ['$resource', 'TEAM_MEMBERSHIPS_ENDPOINT', function TeamMembershipsFactory($resource, TEAM_MEMBERSHIPS_ENDPOINT) { + 'use strict'; + return $resource(TEAM_MEMBERSHIPS_ENDPOINT + '/:id/:action', {}, { + create: { method: 'POST' }, + query: { method: 'GET', isArray: true }, + update: { method: 'PUT', params: { id: '@id' } }, + remove: { method: 'DELETE', params: { id: '@id'} } + }); +}]); diff --git a/app/rest/user.js b/app/rest/user.js index cc55f448d..6189130cc 100644 --- a/app/rest/user.js +++ b/app/rest/user.js @@ -1,15 +1,17 @@ angular.module('portainer.rest') .factory('Users', ['$resource', 'USERS_ENDPOINT', function UsersFactory($resource, USERS_ENDPOINT) { 'use strict'; - return $resource(USERS_ENDPOINT + '/:id/:action', {}, { + return $resource(USERS_ENDPOINT + '/:id/:entity/:entityId', {}, { create: { method: 'POST' }, query: { method: 'GET', isArray: true }, get: { method: 'GET', params: { id: '@id' } }, update: { method: 'PUT', params: { id: '@id' } }, remove: { method: 'DELETE', params: { id: '@id'} }, + queryMemberships: { method: 'GET', isArray: true, params: { id: '@id', entity: 'memberships' } }, + queryTeams: { method: 'GET', isArray: true, params: { id: '@id', entity: 'teams' } }, // RPCs should be moved to a specific endpoint - checkPassword: { method: 'POST', params: { id: '@id', action: 'passwd' } }, - checkAdminUser: { method: 'GET', params: { id: 'admin', action: 'check' }, isArray: true }, - initAdminUser: { method: 'POST', params: { id: 'admin', action: 'init' } } + checkPassword: { method: 'POST', params: { id: '@id', entity: 'passwd' } }, + checkAdminUser: { method: 'GET', params: { id: 'admin', entity: 'check' }, isArray: true }, + initAdminUser: { method: 'POST', params: { id: 'admin', entity: 'init' } } }); }]); diff --git a/app/rest/volume.js b/app/rest/volume.js index 132fdec01..e6a5aa2c4 100644 --- a/app/rest/volume.js +++ b/app/rest/volume.js @@ -1,17 +1,16 @@ angular.module('portainer.rest') .factory('Volume', ['$resource', 'Settings', 'EndpointProvider', function VolumeFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/:endpointId/volumes/:name/:action', + return $resource(Settings.url + '/:endpointId/volumes/:id/:action', { - name: '@name', endpointId: EndpointProvider.endpointID }, { - query: {method: 'GET'}, - get: {method: 'GET'}, + query: { method: 'GET' }, + get: { method: 'GET', params: {id: '@id'} }, create: {method: 'POST', params: {action: 'create'}, transformResponse: genericHandler}, remove: { - method: 'DELETE', transformResponse: genericHandler + method: 'DELETE', transformResponse: genericHandler, params: {id: '@id'} } }); }]); diff --git a/app/services/containerService.js b/app/services/containerService.js index 41ff5c9f1..8271b49b3 100644 --- a/app/services/containerService.js +++ b/app/services/containerService.js @@ -1,5 +1,5 @@ angular.module('portainer.services') -.factory('ContainerService', ['$q', 'Container', 'ContainerHelper', function ContainerServiceFactory($q, Container, ContainerHelper) { +.factory('ContainerService', ['$q', 'Container', 'ContainerHelper', 'ResourceControlService', function ContainerServiceFactory($q, Container, ContainerHelper, ResourceControlService) { 'use strict'; var service = {}; @@ -67,5 +67,28 @@ angular.module('portainer.services') }); return deferred.promise; }; + + service.remove = function(container, removeVolumes) { + var deferred = $q.defer(); + + Container.remove({id: container.Id, v: (removeVolumes) ? 1 : 0, force: true}).$promise + .then(function success(data) { + if (data.message) { + deferred.reject({ msg: data.message, err: data.message }); + } + if (container.ResourceControl && container.ResourceControl.Type === 1) { + return ResourceControlService.deleteResourceControl(container.ResourceControl.Id); + } + }) + .then(function success() { + deferred.resolve(); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to remove container', err: err }); + }); + + return deferred.promise; + }; + return service; }]); diff --git a/app/services/controllerDataPipeline.js b/app/services/controllerDataPipeline.js new file mode 100644 index 000000000..663bc9dbd --- /dev/null +++ b/app/services/controllerDataPipeline.js @@ -0,0 +1,36 @@ +// ControllerDataPipeline is used to transfer data between multiple controllers. +angular.module('portainer.services') +.factory('ControllerDataPipeline', [function ControllerDataPipelineFactory() { + 'use strict'; + + var pipeline = {}; + + // accessControlData is used to manage the data required by the accessControlPanelController. + var accessControlData = {}; + + pipeline.setAccessControlData = function (type, resourceId, resourceControl) { + accessControlData.resourceType = type; + accessControlData.resourceId = resourceId; + accessControlData.resourceControl = resourceControl; + }; + + pipeline.getAccessControlData = function() { + return accessControlData; + }; + + // accessControlFormData is used to manage the data available in the scope of the accessControlFormController. + var accessControlFormData = {}; + + pipeline.setAccessControlFormData = function(accessControlEnabled, ownership, authorizedUsers, authorizedTeams) { + accessControlFormData.accessControlEnabled = accessControlEnabled; + accessControlFormData.ownership = ownership; + accessControlFormData.authorizedUsers = authorizedUsers; + accessControlFormData.authorizedTeams = authorizedTeams; + }; + + pipeline.getAccessControlFormData = function() { + return accessControlFormData; + }; + + return pipeline; +}]); diff --git a/app/services/endpointProvider.js b/app/services/endpointProvider.js index 4f1c870a9..cc1afc4da 100644 --- a/app/services/endpointProvider.js +++ b/app/services/endpointProvider.js @@ -1,8 +1,9 @@ angular.module('portainer.services') .factory('EndpointProvider', ['LocalStorage', function EndpointProviderFactory(LocalStorage) { 'use strict'; - var endpoint = {}; var service = {}; + var endpoint = {}; + service.initialize = function() { var endpointID = LocalStorage.getEndpointID(); var endpointPublicURL = LocalStorage.getEndpointPublicURL(); @@ -13,22 +14,28 @@ angular.module('portainer.services') endpoint.PublicURL = endpointPublicURL; } }; + service.clean = function() { endpoint = {}; }; + service.endpointID = function() { return endpoint.ID; }; + service.setEndpointID = function(id) { endpoint.ID = id; LocalStorage.storeEndpointID(id); }; + service.endpointPublicURL = function() { return endpoint.PublicURL; - } + }; + service.setEndpointPublicURL = function(publicURL) { endpoint.PublicURL = publicURL; LocalStorage.storeEndpointPublicURL(publicURL); - } + }; + return service; }]); diff --git a/app/services/endpointService.js b/app/services/endpointService.js index 0b4e88418..693f9aea9 100644 --- a/app/services/endpointService.js +++ b/app/services/endpointService.js @@ -11,8 +11,8 @@ angular.module('portainer.services') return Endpoints.query({}).$promise; }; - service.updateAuthorizedUsers = function(id, authorizedUserIDs) { - return Endpoints.updateAccess({id: id}, {authorizedUsers: authorizedUserIDs}).$promise; + service.updateAccess = function(id, authorizedUserIDs, authorizedTeamIDs) { + return Endpoints.updateAccess({id: id}, {authorizedUsers: authorizedUserIDs, authorizedTeams: authorizedTeamIDs}).$promise; }; service.updateEndpoint = function(id, endpointParams) { @@ -23,7 +23,7 @@ angular.module('portainer.services') authorizedUsers: endpointParams.authorizedUsers }; if (endpointParams.type && endpointParams.URL) { - query.URL = endpointParams.type === 'local' ? ("unix://" + endpointParams.URL) : ("tcp://" + endpointParams.URL); + query.URL = endpointParams.type === 'local' ? ('unix://' + endpointParams.URL) : ('tcp://' + endpointParams.URL); } var deferred = $q.defer(); @@ -48,8 +48,8 @@ angular.module('portainer.services') service.createLocalEndpoint = function(name, URL, TLS, active) { var endpoint = { - Name: "local", - URL: "unix:///var/run/docker.sock", + Name: 'local', + URL: 'unix:///var/run/docker.sock', TLS: false }; return Endpoints.create({}, endpoint).$promise; diff --git a/app/services/formValidator.js b/app/services/formValidator.js new file mode 100644 index 000000000..d61419d9a --- /dev/null +++ b/app/services/formValidator.js @@ -0,0 +1,24 @@ +angular.module('portainer.services') +.factory('FormValidator', [function FormValidatorFactory() { + 'use strict'; + + var validator = {}; + + validator.validateAccessControl = function(accessControlData, isAdmin) { + if (!accessControlData.accessControlEnabled) { + return ''; + } + + if (isAdmin && accessControlData.ownership === 'restricted' && + accessControlData.authorizedUsers.length === 0 && + accessControlData.authorizedTeams.length === 0) { + return 'You must specify at least one team or user.'; + } else if (!isAdmin && accessControlData.ownership === 'restricted' && + accessControlData.authorizedTeams.length === 0) { + return 'You must specify at least a team.'; + } + return ''; + }; + + return validator; +}]); diff --git a/app/services/lineChart.js b/app/services/lineChart.js index 12c848ee0..1bfbc8473 100644 --- a/app/services/lineChart.js +++ b/app/services/lineChart.js @@ -3,7 +3,7 @@ angular.module('portainer.services') 'use strict'; return { build: function (id, data, getkey) { - var chart = new Chart($(id).get(0).getContext("2d")); + var chart = new Chart($(id).get(0).getContext('2d')); var map = {}; for (var i = 0; i < data.length; i++) { @@ -33,10 +33,10 @@ angular.module('portainer.services') } var steps = Math.min(max, 10); var dataset = { - fillColor: "rgba(151,187,205,0.5)", - strokeColor: "rgba(151,187,205,1)", - pointColor: "rgba(151,187,205,1)", - pointStrokeColor: "#fff", + fillColor: 'rgba(151,187,205,0.5)', + strokeColor: 'rgba(151,187,205,1)', + pointColor: 'rgba(151,187,205,1)', + pointStrokeColor: '#fff', data: data }; chart.Line({ diff --git a/app/services/modalService.js b/app/services/modalService.js index c70704935..ad55be8ed 100644 --- a/app/services/modalService.js +++ b/app/services/modalService.js @@ -46,46 +46,31 @@ angular.module('portainer.services') applyBoxCSS(box); }; - service.confirmOwnershipChange = function(callback, msg) { + service.confirmAccessControlUpdate = function(callback, msg) { service.confirm({ title: 'Are you sure ?', - message: msg, + message: 'Changing the ownership of this resource will potentially restrict its management to some users.', buttons: { confirm: { label: 'Change ownership', className: 'btn-primary' } }, - callback: callback, + callback: callback }); }; - service.confirmContainerOwnershipChange = function(callback) { - var msg = 'You can change the ownership of a container one way only. You will not be able to make this container private again. Changing ownership on this container will also change the ownership on any attached volume.'; - service.confirmOwnershipChange(callback, msg); - }; - - service.confirmServiceOwnershipChange = function(callback) { - var msg = 'You can change the ownership of a service one way only. You will not be able to make this service private again. Changing ownership on this service will also change the ownership on any attached volume.'; - service.confirmOwnershipChange(callback, msg); - }; - - service.confirmVolumeOwnershipChange = function(callback) { - var msg = 'You can change the ownership of a volume one way only. You will not be able to make this volume private again.'; - service.confirmOwnershipChange(callback, msg); - }; - service.confirmImageForceRemoval = function(callback) { service.confirm({ - title: "Are you sure?", - message: "Forcing the removal of the image will remove the image even if it has multiple tags or if it is used by stopped containers.", + title: 'Are you sure?', + message: 'Forcing the removal of the image will remove the image even if it has multiple tags or if it is used by stopped containers.', buttons: { confirm: { label: 'Remove the image', className: 'btn-danger' } }, - callback: callback, + callback: callback }); }; @@ -99,7 +84,7 @@ angular.module('portainer.services') className: 'btn-danger' } }, - callback: callback, + callback: callback }); }; diff --git a/app/services/networkService.js b/app/services/networkService.js index fb25eead7..e3abe33e7 100644 --- a/app/services/networkService.js +++ b/app/services/networkService.js @@ -24,9 +24,9 @@ angular.module('portainer.services') }; service.addPredefinedLocalNetworks = function(networks) { - networks.push({Scope: "local", Name: "bridge"}); - networks.push({Scope: "local", Name: "host"}); - networks.push({Scope: "local", Name: "none"}); + networks.push({Scope: 'local', Name: 'bridge'}); + networks.push({Scope: 'local', Name: 'host'}); + networks.push({Scope: 'local', Name: 'none'}); }; return service; diff --git a/app/services/nodeService.js b/app/services/nodeService.js new file mode 100644 index 000000000..2fb394a58 --- /dev/null +++ b/app/services/nodeService.js @@ -0,0 +1,24 @@ +angular.module('portainer.services') +.factory('NodeService', ['$q', 'Node', function NodeServiceFactory($q, Node) { + 'use strict'; + var service = {}; + + service.nodes = function(id) { + var deferred = $q.defer(); + + Node.query({}).$promise + .then(function success(data) { + var nodes = data.map(function (item) { + return new NodeViewModel(item); + }); + deferred.resolve(nodes); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve nodes', err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/services/resourceControlService.js b/app/services/resourceControlService.js index 3b30c2680..d18511e7e 100644 --- a/app/services/resourceControlService.js +++ b/app/services/resourceControlService.js @@ -1,30 +1,124 @@ angular.module('portainer.services') -.factory('ResourceControlService', ['$q', 'ResourceControl', function ResourceControlServiceFactory($q, ResourceControl) { +.factory('ResourceControlService', ['$q', 'ResourceControl', 'UserService', 'TeamService', 'ResourceControlHelper', function ResourceControlServiceFactory($q, ResourceControl, UserService, TeamService, ResourceControlHelper) { 'use strict'; var service = {}; - service.setContainerResourceControl = function(userID, resourceID) { - return ResourceControl.create({ userId: userID, resourceType: 'container' }, { ResourceID: resourceID }).$promise; + service.createResourceControl = function(administratorsOnly, userIDs, teamIDs, resourceID, type, subResourceIDs) { + var payload = { + Type: type, + AdministratorsOnly: administratorsOnly, + ResourceID: resourceID, + Users: userIDs, + Teams: teamIDs, + SubResourceIDs: subResourceIDs + }; + return ResourceControl.create({}, payload).$promise; }; - service.removeContainerResourceControl = function(userID, resourceID) { - return ResourceControl.remove({ userId: userID, resourceId: resourceID, resourceType: 'container' }).$promise; + service.deleteResourceControl = function(rcID) { + return ResourceControl.remove({id: rcID}).$promise; }; - service.setServiceResourceControl = function(userID, resourceID) { - return ResourceControl.create({ userId: userID, resourceType: 'service' }, { ResourceID: resourceID }).$promise; + service.updateResourceControl = function(admin, userIDs, teamIDs, resourceControlId) { + var payload = { + AdministratorsOnly: admin, + Users: userIDs, + Teams: teamIDs + }; + return ResourceControl.update({id: resourceControlId}, payload).$promise; }; - service.removeServiceResourceControl = function(userID, resourceID) { - return ResourceControl.remove({ userId: userID, resourceId: resourceID, resourceType: 'service' }).$promise; + service.applyResourceControl = function(resourceControlType, resourceIdentifier, userId, accessControlData, subResources) { + if (!accessControlData.accessControlEnabled) { + return; + } + + var authorizedUserIds = []; + var authorizedTeamIds = []; + var administratorsOnly = false; + switch (accessControlData.ownership) { + case 'administrators': + administratorsOnly = true; + break; + case 'private': + authorizedUserIds.push(userId); + break; + case 'restricted': + angular.forEach(accessControlData.authorizedUsers, function(user) { + authorizedUserIds.push(user.Id); + }); + angular.forEach(accessControlData.authorizedTeams, function(team) { + authorizedTeamIds.push(team.Id); + }); + break; + } + return service.createResourceControl(administratorsOnly, authorizedUserIds, + authorizedTeamIds, resourceIdentifier, resourceControlType, subResources); }; - service.setVolumeResourceControl = function(userID, resourceID) { - return ResourceControl.create({ userId: userID, resourceType: 'volume' }, { ResourceID: resourceID }).$promise; + service.applyResourceControlChange = function(resourceControlType, resourceId, resourceControl, ownershipParameters) { + if (resourceControl) { + if (ownershipParameters.ownership === 'public') { + return service.deleteResourceControl(resourceControl.Id); + } else { + return service.updateResourceControl(ownershipParameters.administratorsOnly, ownershipParameters.authorizedUserIds, + ownershipParameters.authorizedTeamIds, resourceControl.Id); + } + } else { + return service.createResourceControl(ownershipParameters.administratorsOnly, ownershipParameters.authorizedUserIds, + ownershipParameters.authorizedTeamIds, resourceId, resourceControlType); + } }; - service.removeVolumeResourceControl = function(userID, resourceID) { - return ResourceControl.remove({ userId: userID, resourceId: resourceID, resourceType: 'volume' }).$promise; + service.retrieveOwnershipDetails = function(resourceControl) { + var deferred = $q.defer(); + + if (!resourceControl) { + deferred.resolve({ authorizedUsers: [], authorizedTeams: [] }); + return deferred.promise; + } + + $q.all({ + users: resourceControl.UserAccesses.length > 0 ? UserService.users(false) : [], + teams: resourceControl.TeamAccesses.length > 0 ? TeamService.teams() : [] + }) + .then(function success(data) { + var authorizedUserNames = ResourceControlHelper.retrieveAuthorizedUsers(resourceControl, data.users); + var authorizedTeamNames = ResourceControlHelper.retrieveAuthorizedTeams(resourceControl, data.teams); + deferred.resolve({ authorizedUsers: authorizedUserNames, authorizedTeams: authorizedTeamNames }); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve user and team information', err: err }); + }); + + return deferred.promise; + }; + + service.retrieveUserPermissionsOnResource = function(userID, isAdministrator, resourceControl) { + var deferred = $q.defer(); + + if (!resourceControl || isAdministrator) { + deferred.resolve({ isPartOfRestrictedUsers: false, isLeaderOfAnyRestrictedTeams: false }); + return deferred.promise; + } + + var found = _.find(resourceControl.UserAccesses, { UserId: userID }); + if (found) { + deferred.resolve({ isPartOfRestrictedUsers: true, isLeaderOfAnyRestrictedTeams: false }); + } else { + var isTeamLeader = false; + UserService.userMemberships(userID) + .then(function success(data) { + var memberships = data; + isTeamLeader = ResourceControlHelper.isLeaderOfAnyRestrictedTeams(memberships, resourceControl); + deferred.resolve({ isPartOfRestrictedUsers: false, isLeaderOfAnyRestrictedTeams: isTeamLeader }); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve user memberships', err: err }); + }); + } + + return deferred.promise; }; return service; diff --git a/app/services/serviceService.js b/app/services/serviceService.js new file mode 100644 index 000000000..939f1875e --- /dev/null +++ b/app/services/serviceService.js @@ -0,0 +1,41 @@ +angular.module('portainer.services') +.factory('ServiceService', ['$q', 'Service', 'ResourceControlService', function ServiceServiceFactory($q, Service, ResourceControlService) { + 'use strict'; + var service = {}; + + service.service = function(id) { + var deferred = $q.defer(); + + Service.get({ id: id }).$promise + .then(function success(data) { + var service = new ServiceViewModel(data); + deferred.resolve(service); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve service details', err: err }); + }); + + return deferred.promise; + }; + + service.remove = function(service) { + var deferred = $q.defer(); + + Service.remove({id: service.Id}).$promise + .then(function success() { + if (service.ResourceControl && service.ResourceControl.Type === 2) { + return ResourceControlService.deleteResourceControl(service.ResourceControl.Id); + } + }) + .then(function success() { + deferred.resolve(); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to remove service', err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/services/taskService.js b/app/services/taskService.js new file mode 100644 index 000000000..55b9b4f67 --- /dev/null +++ b/app/services/taskService.js @@ -0,0 +1,39 @@ +angular.module('portainer.services') +.factory('TaskService', ['$q', 'Task', function TaskServiceFactory($q, Task) { + 'use strict'; + var service = {}; + + service.task = function(id) { + var deferred = $q.defer(); + + Task.get({ id: id }).$promise + .then(function success(data) { + var task = new TaskViewModel(data); + deferred.resolve(task); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve task details', err: err }); + }); + + return deferred.promise; + }; + + service.serviceTasks = function(serviceName) { + var deferred = $q.defer(); + + Task.query({ filters: { service: [serviceName] } }).$promise + .then(function success(data) { + var tasks = data.map(function (item) { + return new TaskViewModel(item); + }); + deferred.resolve(tasks); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve tasks associated to the service', err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/services/teamMembershipService.js b/app/services/teamMembershipService.js new file mode 100644 index 000000000..4b2c43ebd --- /dev/null +++ b/app/services/teamMembershipService.js @@ -0,0 +1,44 @@ +angular.module('portainer.services') +.factory('TeamMembershipService', ['$q', 'TeamMemberships', function TeamMembershipFactory($q, TeamMemberships) { + 'use strict'; + var service = {}; + + service.memberships = function() { + var deferred = $q.defer(); + TeamMemberships.query().$promise + .then(function success(data) { + var memberships = data.map(function (item) { + return new TeamMembershipModel(item); + }); + deferred.resolve(memberships); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to retrieve team memberships', err: err}); + }); + return deferred.promise; + }; + + service.createMembership = function(userId, teamId, role) { + var payload = { + UserID: userId, + TeamID: teamId, + Role: role + }; + return TeamMemberships.create({}, payload).$promise; + }; + + service.deleteMembership = function(id) { + return TeamMemberships.remove({id: id}).$promise; + }; + + service.updateMembership = function(id, userId, teamId, role) { + var payload = { + UserID: userId, + TeamID: teamId, + Role: role + }; + return TeamMemberships.update({id: id}, payload).$promise; + }; + + return service; +}]); diff --git a/app/services/teamService.js b/app/services/teamService.js new file mode 100644 index 000000000..3b21f2bf6 --- /dev/null +++ b/app/services/teamService.js @@ -0,0 +1,84 @@ +angular.module('portainer.services') +.factory('TeamService', ['$q', 'Teams', 'TeamMembershipService', function TeamServiceFactory($q, Teams, TeamMembershipService) { + 'use strict'; + var service = {}; + + service.teams = function() { + var deferred = $q.defer(); + Teams.query().$promise + .then(function success(data) { + var teams = data.map(function (item) { + return new TeamViewModel(item); + }); + deferred.resolve(teams); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to retrieve teams', err: err}); + }); + return deferred.promise; + }; + + service.team = function(id) { + var deferred = $q.defer(); + Teams.get({id: id}).$promise + .then(function success(data) { + var team = new TeamViewModel(data); + deferred.resolve(team); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to retrieve team details', err: err}); + }); + return deferred.promise; + }; + + service.createTeam = function(name, leaderIds) { + var deferred = $q.defer(); + var payload = { + Name: name + }; + Teams.create({}, payload).$promise + .then(function success(data) { + var teamId = data.Id; + var teamMembershipQueries = []; + angular.forEach(leaderIds, function(userId) { + teamMembershipQueries.push(TeamMembershipService.createMembership(userId, teamId, 1)); + }); + $q.all(teamMembershipQueries) + .then(function success() { + deferred.resolve(); + }); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to create team', err: err }); + }); + return deferred.promise; + }; + + service.deleteTeam = function(id) { + return Teams.remove({id: id}).$promise; + }; + + service.updateTeam = function(id, name, members, leaders) { + var payload = { + Name: name + }; + return Teams.update({id: id}, payload).$promise; + }; + + service.userMemberships = function(id) { + var deferred = $q.defer(); + Teams.queryMemberships({id: id}).$promise + .then(function success(data) { + var memberships = data.map(function (item) { + return new TeamMembershipModel(item); + }); + deferred.resolve(memberships); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve user memberships for the team', err: err }); + }); + return deferred.promise; + }; + + return service; +}]); diff --git a/app/services/userService.js b/app/services/userService.js index a836d298d..24e0f97a2 100644 --- a/app/services/userService.js +++ b/app/services/userService.js @@ -1,17 +1,63 @@ angular.module('portainer.services') -.factory('UserService', ['$q', 'Users', function UserServiceFactory($q, Users) { +.factory('UserService', ['$q', 'Users', 'UserHelper', 'TeamMembershipService', function UserServiceFactory($q, Users, UserHelper, TeamMembershipService) { 'use strict'; var service = {}; - service.users = function() { - return Users.query({}).$promise; + + service.users = function(includeAdministrators) { + var deferred = $q.defer(); + + Users.query({}).$promise + .then(function success(data) { + var users = data.map(function (user) { + return new UserViewModel(user); + }); + if (!includeAdministrators) { + users = UserHelper.filterNonAdministratorUsers(users); + } + deferred.resolve(users); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve users', err: err }); + }); + + return deferred.promise; }; service.user = function(id) { - return Users.get({id: id}).$promise; + var deferred = $q.defer(); + + Users.get({id: id}).$promise + .then(function success(data) { + var user = new UserViewModel(data); + deferred.resolve(user); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve user details', err: err }); + }); + + return deferred.promise; }; - service.createUser = function(username, password, role) { - return Users.create({}, {username: username, password: password, role: role}).$promise; + service.createUser = function(username, password, role, teamIds) { + var deferred = $q.defer(); + + Users.create({}, {username: username, password: password, role: role}).$promise + .then(function success(data) { + var userId = data.Id; + var teamMembershipQueries = []; + angular.forEach(teamIds, function(teamId) { + teamMembershipQueries.push(TeamMembershipService.createMembership(userId, teamId, 2)); + }); + $q.all(teamMembershipQueries) + .then(function success() { + deferred.resolve(); + }); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to create user', err: err }); + }); + + return deferred.promise; }; service.deleteUser = function(id) { @@ -28,12 +74,14 @@ angular.module('portainer.services') service.updateUserPassword = function(id, currentPassword, newPassword) { var deferred = $q.defer(); + Users.checkPassword({id: id}, {password: currentPassword}).$promise .then(function success(data) { if (!data.valid) { deferred.reject({invalidPassword: true}); + } else { + return service.updateUser(id, newPassword, undefined); } - return service.updateUser(id, newPassword, undefined); }) .then(function success(data) { deferred.resolve(); @@ -41,6 +89,65 @@ angular.module('portainer.services') .catch(function error(err) { deferred.reject({msg: 'Unable to update user password', err: err}); }); + + return deferred.promise; + }; + + service.userMemberships = function(id) { + var deferred = $q.defer(); + + Users.queryMemberships({id: id}).$promise + .then(function success(data) { + var memberships = data.map(function (item) { + return new TeamMembershipModel(item); + }); + deferred.resolve(memberships); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve user memberships', err: err }); + }); + + return deferred.promise; + }; + + service.userTeams = function(id) { + var deferred = $q.defer(); + + Users.queryTeams({id: id}).$promise + .then(function success(data) { + var teams = data.map(function (item) { + return new TeamViewModel(item); + }); + deferred.resolve(teams); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve user teams', err: err }); + }); + + return deferred.promise; + }; + + service.userLeadingTeams = function(id) { + var deferred = $q.defer(); + + $q.all({ + teams: service.userTeams(id), + memberships: service.userMemberships(id) + }) + .then(function success(data) { + var memberships = data.memberships; + var teams = data.teams.filter(function (team) { + var membership = _.find(memberships, {TeamId: team.Id}); + if (membership && membership.Role === 1) { + return team; + } + }); + deferred.resolve(teams); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve user teams', err: err }); + }); + return deferred.promise; }; diff --git a/app/services/volumeService.js b/app/services/volumeService.js index 0f2a97a68..b326d8acc 100644 --- a/app/services/volumeService.js +++ b/app/services/volumeService.js @@ -1,12 +1,63 @@ angular.module('portainer.services') -.factory('VolumeService', ['$q', 'Volume', 'VolumeHelper', function VolumeServiceFactory($q, Volume, VolumeHelper) { +.factory('VolumeService', ['$q', 'Volume', 'VolumeHelper', 'ResourceControlService', 'UserService', 'TeamService', function VolumeServiceFactory($q, Volume, VolumeHelper, ResourceControlService, UserService, TeamService) { 'use strict'; var service = {}; + service.volumes = function() { + var deferred = $q.defer(); + Volume.query().$promise + .then(function success(data) { + var volumes = data.Volumes || []; + volumes = volumes.map(function (item) { + return new VolumeViewModel(item); + }); + deferred.resolve(volumes); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to retrieve volumes', err: err}); + }); + return deferred.promise; + }; + + service.volume = function(id) { + var deferred = $q.defer(); + Volume.get({id: id}).$promise + .then(function success(data) { + var volume = new VolumeViewModel(data); + deferred.resolve(volume); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to retrieve volume details', err: err}); + }); + return deferred.promise; + }; + service.getVolumes = function() { return Volume.query({}).$promise; }; + service.remove = function(volume) { + var deferred = $q.defer(); + + Volume.remove({id: volume.Id}).$promise + .then(function success(data) { + if (data.message) { + deferred.reject({ msg: data.message, err: data.message }); + } + if (volume.ResourceControl && volume.ResourceControl.Type === 3) { + return ResourceControlService.deleteResourceControl(volume.ResourceControl.Id); + } + }) + .then(function success() { + deferred.resolve(); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to remove volume', err: err }); + }); + + return deferred.promise; + }; + service.createVolumeConfiguration = function(name, driver, driverOptions) { var volumeConfiguration = { Name: name, @@ -23,7 +74,8 @@ angular.module('portainer.services') if (data.message) { deferred.reject({ msg: data.message }); } else { - deferred.resolve(data); + var volume = new VolumeViewModel(data); + deferred.resolve(volume); } }) .catch(function error(err) { diff --git a/assets/css/app.css b/assets/css/app.css index 13c4d0eb0..db204b5e2 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -64,13 +64,6 @@ html, body, #content-wrapper, .page-content, #view { color: #777; } -.form-section-title { - border-bottom: 1px solid #777; - margin-top: 5px; - margin-bottom: 15px; - color: #777; -} - .form-horizontal .control-label.text-left{ text-align: left; font-size: 0.9em; @@ -81,11 +74,6 @@ input[type="checkbox"] { vertical-align: middle; } -input[type="radio"] { - margin-top: 1px; - vertical-align: middle; -} - a[ng-click]{ cursor: pointer; } @@ -139,6 +127,71 @@ a[ng-click]{ font-size: 90% !important; } +.template-widget { + height: 100%; +} + +.template-widget-body { + max-height: 86%; + overflow-y: auto; +} + +.template-list { + display: flex; + flex-direction: column; +} + +.template-logo { + width: 100%; + max-width: 60px; + height: 100%; + max-height: 60px; +} + +.template-container { + padding: 0.7rem; + margin-bottom: 0.7rem; + cursor: pointer; + border: 1px solid #333333; + border-radius: 2px; + box-shadow: 0 3px 10px -2px rgba(161, 170, 166, 0.5); +} + +.template-container--selected { + border: 2px solid #333333; + background-color: #ececec; + color: #2d3e63; +} + +.template-container:hover { + background-color: #ececec; + color: #2d3e63; +} + +.template-main { + display: flex; +} + +.template-note { + padding: 0.5em; + font-size: 0.9em; +} + +.template-title { + font-size: 1.8em; + font-weight: bold; +} + +.template-description { + font-size: 0.9em; + padding-right: 1em; +} + +.template-line { + display: flex; + justify-content: space-between; +} + .nopadding { padding: 0 !important; } @@ -342,71 +395,113 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { box-shadow: inset 0 0 1px rgba(0,0,0,.5), inset 0 0 40px #337ab7; } -#toast-container > div { - opacity: 0.9; -} - -.template-widget { - height: 100%; -} - -.template-widget-body { - max-height: 86%; - overflow-y: auto; -} - -.template-list { +.ownership_wrapper { display: flex; - flex-direction: column; + flex-flow: row wrap; + margin: 0.5rem; } -.template-logo { - width: 100%; - max-width: 60px; - height: 100%; - max-height: 60px; +.ownership_wrapper > div { + flex: 1; + padding: 0.5rem; } -.template-container { - padding: 0.7rem; - margin-bottom: 0.7rem; - cursor: pointer; - border: 1px solid #333333; - border-radius: 2px; - box-shadow: 0 3px 10px -2px rgba(161, 170, 166, 0.5); -} - -.template-container--selected { - border: 2px solid #333333; - background-color: #ececec; - color: #2d3e63; -} - -.template-container:hover { - background-color: #ececec; - color: #2d3e63; -} - -.template-main { - display: flex; -} - -.template-note { - padding: 0.5em; - font-size: 0.9em; -} - -.template-title { - font-size: 1.8em; +.ownership_wrapper .ownership_header { + font-size: 14px; + margin-bottom: 5px; font-weight: bold; } -.template-description { - font-size: 0.9em; - padding-right: 1em; +.ownership_wrapper input[type="radio"] { + display: none; } -.template-line { - display: flex; - justify-content: space-between; +.ownership_wrapper input[type="radio"]:not(:disabled) ~ label { + cursor: pointer; } + +.ownership_wrapper label { + font-weight: normal; + font-size: 12px; + display: block; + background: white; + border: 1px solid #333333; + border-radius: 2px; + padding: 10px 10px 0 10px; + text-align: center; + box-shadow: 0 3px 10px -2px rgba(161, 170, 166, 0.5); + position: relative; +} + +.ownership_wrapper input[type="radio"]:checked + label { + background: #337ab7; + color: white; + padding-top: 2rem; + border-color: #337ab7; +} + +.ownership_wrapper input[type="radio"]:checked + label::after { + color: #337ab7; + font-family: FontAwesome; + border: 2px solid #337ab7; + content: "\f00c"; + font-size: 16px; + font-weight: bold; + position: absolute; + top: -15px; + left: 50%; + transform: translateX(-50%); + height: 30px; + width: 30px; + line-height: 26px; + text-align: center; + border-radius: 50%; + background: white; + box-shadow: 0 2px 5px -2px rgba(0, 0, 0, 0.25); +} + +@media only screen and (max-width: 700px) { + .ownership_wrapper { + flex-direction: column; + } +} + +/*bootbox override*/ +.modal-open { + padding-right: 0 !important; +} +/*!bootbox override*/ + +/*angular-multi-select override*/ +.multiSelect > button { + min-height: 30px !important; + background-color: unset; + background-image: unset; +} + +.multiSelect .multiSelectItem:not(.multiSelectGroup).selected +{ + background-image: linear-gradient( #337ab7, #337ab7 ); + color: #fff; + border: none; +} + +.multiSelect .multiSelectItem:hover, +.multiSelect .multiSelectGroup:hover { + background-image: linear-gradient( #337ab7 , #337ab7 ) !important; + color: #fff !important; +} + +.multiSelect .tickMark, +.widget .widget-body table tbody .multiSelect .tickMark { + top: 2px; + right: 12px; + font-size: 20px !important; +} +/*!angular-multi-select override*/ + +/*toaster override*/ +#toast-container > div { + opacity: 0.9; +} +/*!toaster override*/ diff --git a/bower.json b/bower.json index ced5b086c..b76b1fa0d 100644 --- a/bower.json +++ b/bower.json @@ -32,7 +32,7 @@ "angular-sanitize": "~1.5.0", "angular-mocks": "~1.5.0", "angular-resource": "~1.5.0", - "angular-ui-select": "~0.17.1", + "angular-ui-select": "~0.19.6", "angular-utils-pagination": "~0.11.1", "angular-local-storage": "~0.5.2", "angular-jwt": "~0.1.8", @@ -48,9 +48,10 @@ "ng-file-upload": "~12.2.13", "splitargs": "~0.2.0", "bootbox.js": "bootbox#^4.4.0", + "angular-multi-select": "~4.0.0", "toastr": "~2.1.3" }, "resolutions": { - "angular": "1.5.5" + "angular": "1.5.11" } } diff --git a/gruntfile.js b/gruntfile.js index 2d07fbae5..0fa157722 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -168,6 +168,7 @@ module.exports = function (grunt) { 'clean:tmp' ]); grunt.registerTask('lint', ['eslint']); + grunt.registerTask('run', ['if:linuxAmd64BinaryNotExist', 'build', 'shell:buildImage', 'shell:run']); grunt.registerTask('run-dev', ['if:linuxAmd64BinaryNotExist', 'shell:buildImage', 'shell:run', 'watch:build']); grunt.registerTask('clear', ['clean:app']); @@ -209,6 +210,7 @@ module.exports = function (grunt) { 'bower_components/moment/min/moment.min.js', 'bower_components/xterm.js/dist/xterm.js', 'bower_components/bootbox.js/bootbox.js', + 'bower_components/angular-multi-select/isteven-multi-select.js', 'bower_components/toastr/toastr.min.js', 'assets/js/legend.js' // Not a bower package ], @@ -221,6 +223,7 @@ module.exports = function (grunt) { 'bower_components/rdash-ui/dist/css/rdash.min.css', 'bower_components/angular-ui-select/dist/select.min.css', 'bower_components/xterm.js/dist/xterm.css', + 'bower_components/angular-multi-select/isteven-multi-select.css', 'bower_components/toastr/toastr.min.css' ] }, @@ -475,27 +478,6 @@ module.exports = function (grunt) { 'docker run --privileged -d -p 9000:9000 -v /tmp/portainer:/data -v /var/run/docker.sock:/var/run/docker.sock --name portainer portainer --no-analytics' ].join(';') }, - runSwarm: { - command: [ - 'docker stop portainer', - 'docker rm portainer', - 'docker run -d -p 9000:9000 --name portainer portainer -H tcp://10.0.7.10:2375 --no-analytics' - ].join(';') - }, - runSwarmLocal: { - command: [ - 'docker stop portainer', - 'docker rm portainer', - 'docker run -d -p 9000:9000 -v /var/run/docker.sock:/var/run/docker.sock --name portainer portainer --no-analytics' - ].join(';') - }, - runSsl: { - command: [ - 'docker stop portainer', - 'docker rm portainer', - 'docker run -d -p 9000:9000 -v /tmp/portainer:/data -v /tmp/docker-ssl:/certs --name portainer portainer -H tcp://10.0.7.10:2376 --tlsverify --no-analytics' - ].join(';') - }, cleanImages: { command: 'docker rmi $(docker images -q -f dangling=true)' }