diff --git a/.github/stale.yml b/.github/stale.yml index 9efe94673..85d558ed5 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -15,6 +15,7 @@ issues: - kind/feature - kind/question - kind/style + - kind/workaround - bug/need-confirmation - bug/confirmed - status/discuss diff --git a/README.md b/README.md index e1297a611..86363fab2 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ **_Portainer_** is a lightweight management UI which allows you to **easily** manage your different Docker environments (Docker hosts or Swarm clusters). **_Portainer_** is meant to be as **simple** to deploy as it is to use. It consists of a single container that can run on any Docker engine (can be deployed as Linux container or a Windows native container, supports other platforms too). -**_Portainer_** allows you to manage your all your Docker resources (containers, images, volumes, networks and more) ! It is compatible with the *standalone Docker* engine and with *Docker Swarm mode*. +**_Portainer_** allows you to manage all your Docker resources (containers, images, volumes, networks and more) ! It is compatible with the *standalone Docker* engine and with *Docker Swarm mode*. ## Demo @@ -45,6 +45,10 @@ Unlike the public demo, the playground sessions are deleted after 4 hours. Apart * Want to report a bug or request a feature? Please open [an issue](https://github.com/portainer/portainer/issues/new). * Want to help us build **_portainer_**? Follow our [contribution guidelines](https://portainer.readthedocs.io/en/latest/contribute.html) to build it locally and make a pull request. We need all the help we can get! +## Security + +* Here at Portainer, we believe in [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure) of security issues. If you have found a security issue, please report it to . + ## Limitations **_Portainer_** has full support for the following Docker versions: diff --git a/api/authorizations.go b/api/authorizations.go new file mode 100644 index 000000000..e2101bdae --- /dev/null +++ b/api/authorizations.go @@ -0,0 +1,419 @@ +package portainer + +// AuthorizationService represents a service used to +// update authorizations associated to a user or team. +type AuthorizationService struct { + endpointService EndpointService + endpointGroupService EndpointGroupService + registryService RegistryService + roleService RoleService + teamMembershipService TeamMembershipService + userService UserService +} + +// AuthorizationServiceParameters are the required parameters +// used to create a new AuthorizationService. +type AuthorizationServiceParameters struct { + EndpointService EndpointService + EndpointGroupService EndpointGroupService + RegistryService RegistryService + RoleService RoleService + TeamMembershipService TeamMembershipService + UserService UserService +} + +// NewAuthorizationService returns a point to a new AuthorizationService instance. +func NewAuthorizationService(parameters *AuthorizationServiceParameters) *AuthorizationService { + return &AuthorizationService{ + endpointService: parameters.EndpointService, + endpointGroupService: parameters.EndpointGroupService, + registryService: parameters.RegistryService, + roleService: parameters.RoleService, + teamMembershipService: parameters.TeamMembershipService, + userService: parameters.UserService, + } +} + +// DefaultPortainerAuthorizations returns the default Portainer authorizations used by non-admin users. +func DefaultPortainerAuthorizations() Authorizations { + return map[Authorization]bool{ + OperationPortainerDockerHubInspect: true, + OperationPortainerEndpointGroupList: true, + OperationPortainerEndpointList: true, + OperationPortainerEndpointInspect: true, + OperationPortainerEndpointExtensionAdd: true, + OperationPortainerEndpointExtensionRemove: true, + OperationPortainerExtensionList: true, + OperationPortainerMOTD: true, + OperationPortainerRegistryList: true, + OperationPortainerRegistryInspect: true, + OperationPortainerTeamList: true, + OperationPortainerTemplateList: true, + OperationPortainerTemplateInspect: true, + OperationPortainerUserList: true, + OperationPortainerUserInspect: true, + OperationPortainerUserMemberships: true, + } +} + +// UpdateVolumeBrowsingAuthorizations will update all the volume browsing authorizations for each role (except endpoint administrator) +// based on the specified removeAuthorizations parameter. If removeAuthorizations is set to true, all +// the authorizations will be dropped for the each role. If removeAuthorizations is set to false, the authorizations +// will be reset based for each role. +func (service AuthorizationService) UpdateVolumeBrowsingAuthorizations(remove bool) error { + roles, err := service.roleService.Roles() + if err != nil { + return err + } + + for _, role := range roles { + // all roles except endpoint administrator + if role.ID != RoleID(1) { + updateRoleVolumeBrowsingAuthorizations(&role, remove) + + err := service.roleService.UpdateRole(role.ID, &role) + if err != nil { + return err + } + } + } + + return nil +} + +func updateRoleVolumeBrowsingAuthorizations(role *Role, removeAuthorizations bool) { + if !removeAuthorizations { + delete(role.Authorizations, OperationDockerAgentBrowseDelete) + delete(role.Authorizations, OperationDockerAgentBrowseGet) + delete(role.Authorizations, OperationDockerAgentBrowseList) + delete(role.Authorizations, OperationDockerAgentBrowsePut) + delete(role.Authorizations, OperationDockerAgentBrowseRename) + return + } + + role.Authorizations[OperationDockerAgentBrowseGet] = true + role.Authorizations[OperationDockerAgentBrowseList] = true + + // Standard-user + if role.ID == RoleID(3) { + role.Authorizations[OperationDockerAgentBrowseDelete] = true + role.Authorizations[OperationDockerAgentBrowsePut] = true + role.Authorizations[OperationDockerAgentBrowseRename] = true + } +} + +// RemoveTeamAccessPolicies will remove all existing access policies associated to the specified team +func (service *AuthorizationService) RemoveTeamAccessPolicies(teamID TeamID) error { + endpoints, err := service.endpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range endpoints { + for policyTeamID := range endpoint.TeamAccessPolicies { + if policyTeamID == teamID { + delete(endpoint.TeamAccessPolicies, policyTeamID) + + err := service.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + + break + } + } + } + + endpointGroups, err := service.endpointGroupService.EndpointGroups() + if err != nil { + return err + } + + for _, endpointGroup := range endpointGroups { + for policyTeamID := range endpointGroup.TeamAccessPolicies { + if policyTeamID == teamID { + delete(endpointGroup.TeamAccessPolicies, policyTeamID) + + err := service.endpointGroupService.UpdateEndpointGroup(endpointGroup.ID, &endpointGroup) + if err != nil { + return err + } + + break + } + } + } + + registries, err := service.registryService.Registries() + if err != nil { + return err + } + + for _, registry := range registries { + for policyTeamID := range registry.TeamAccessPolicies { + if policyTeamID == teamID { + delete(registry.TeamAccessPolicies, policyTeamID) + + err := service.registryService.UpdateRegistry(registry.ID, ®istry) + if err != nil { + return err + } + + break + } + } + } + + return nil +} + +// RemoveUserAccessPolicies will remove all existing access policies associated to the specified user +func (service *AuthorizationService) RemoveUserAccessPolicies(userID UserID) error { + endpoints, err := service.endpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range endpoints { + for policyUserID := range endpoint.UserAccessPolicies { + if policyUserID == userID { + delete(endpoint.UserAccessPolicies, policyUserID) + + err := service.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + + break + } + } + } + + endpointGroups, err := service.endpointGroupService.EndpointGroups() + if err != nil { + return err + } + + for _, endpointGroup := range endpointGroups { + for policyUserID := range endpointGroup.UserAccessPolicies { + if policyUserID == userID { + delete(endpointGroup.UserAccessPolicies, policyUserID) + + err := service.endpointGroupService.UpdateEndpointGroup(endpointGroup.ID, &endpointGroup) + if err != nil { + return err + } + + break + } + } + } + + registries, err := service.registryService.Registries() + if err != nil { + return err + } + + for _, registry := range registries { + for policyUserID := range registry.UserAccessPolicies { + if policyUserID == userID { + delete(registry.UserAccessPolicies, policyUserID) + + err := service.registryService.UpdateRegistry(registry.ID, ®istry) + if err != nil { + return err + } + + break + } + } + } + + return nil +} + +// UpdateUsersAuthorizations will trigger an update of the authorizations for all the users. +func (service *AuthorizationService) UpdateUsersAuthorizations() error { + users, err := service.userService.Users() + if err != nil { + return err + } + + for _, user := range users { + err := service.updateUserAuthorizations(user.ID) + if err != nil { + return err + } + } + + return nil +} + +func (service *AuthorizationService) updateUserAuthorizations(userID UserID) error { + user, err := service.userService.User(userID) + if err != nil { + return err + } + + endpointAuthorizations, err := service.getAuthorizations(user) + if err != nil { + return err + } + + user.EndpointAuthorizations = endpointAuthorizations + + return service.userService.UpdateUser(userID, user) +} + +func (service *AuthorizationService) getAuthorizations(user *User) (EndpointAuthorizations, error) { + endpointAuthorizations := EndpointAuthorizations{} + if user.Role == AdministratorRole { + return endpointAuthorizations, nil + } + + userMemberships, err := service.teamMembershipService.TeamMembershipsByUserID(user.ID) + if err != nil { + return endpointAuthorizations, err + } + + endpoints, err := service.endpointService.Endpoints() + if err != nil { + return endpointAuthorizations, err + } + + endpointGroups, err := service.endpointGroupService.EndpointGroups() + if err != nil { + return endpointAuthorizations, err + } + + roles, err := service.roleService.Roles() + if err != nil { + return endpointAuthorizations, err + } + + endpointAuthorizations = getUserEndpointAuthorizations(user, endpoints, endpointGroups, roles, userMemberships) + + return endpointAuthorizations, nil +} + +func getUserEndpointAuthorizations(user *User, endpoints []Endpoint, endpointGroups []EndpointGroup, roles []Role, userMemberships []TeamMembership) EndpointAuthorizations { + endpointAuthorizations := make(EndpointAuthorizations) + + groupUserAccessPolicies := map[EndpointGroupID]UserAccessPolicies{} + groupTeamAccessPolicies := map[EndpointGroupID]TeamAccessPolicies{} + for _, endpointGroup := range endpointGroups { + groupUserAccessPolicies[endpointGroup.ID] = endpointGroup.UserAccessPolicies + groupTeamAccessPolicies[endpointGroup.ID] = endpointGroup.TeamAccessPolicies + } + + for _, endpoint := range endpoints { + authorizations := getAuthorizationsFromUserEndpointPolicy(user, &endpoint, roles) + if len(authorizations) > 0 { + endpointAuthorizations[endpoint.ID] = authorizations + continue + } + + authorizations = getAuthorizationsFromUserEndpointGroupPolicy(user, &endpoint, roles, groupUserAccessPolicies) + if len(authorizations) > 0 { + endpointAuthorizations[endpoint.ID] = authorizations + continue + } + + authorizations = getAuthorizationsFromTeamEndpointPolicies(userMemberships, &endpoint, roles) + if len(authorizations) > 0 { + endpointAuthorizations[endpoint.ID] = authorizations + continue + } + + authorizations = getAuthorizationsFromTeamEndpointGroupPolicies(userMemberships, &endpoint, roles, groupTeamAccessPolicies) + if len(authorizations) > 0 { + endpointAuthorizations[endpoint.ID] = authorizations + } + } + + return endpointAuthorizations +} + +func getAuthorizationsFromUserEndpointPolicy(user *User, endpoint *Endpoint, roles []Role) Authorizations { + policyRoles := make([]RoleID, 0) + + policy, ok := endpoint.UserAccessPolicies[user.ID] + if ok { + policyRoles = append(policyRoles, policy.RoleID) + } + + return getAuthorizationsFromRoles(policyRoles, roles) +} + +func getAuthorizationsFromUserEndpointGroupPolicy(user *User, endpoint *Endpoint, roles []Role, groupAccessPolicies map[EndpointGroupID]UserAccessPolicies) Authorizations { + policyRoles := make([]RoleID, 0) + + policy, ok := groupAccessPolicies[endpoint.GroupID][user.ID] + if ok { + policyRoles = append(policyRoles, policy.RoleID) + } + + return getAuthorizationsFromRoles(policyRoles, roles) +} + +func getAuthorizationsFromTeamEndpointPolicies(memberships []TeamMembership, endpoint *Endpoint, roles []Role) Authorizations { + policyRoles := make([]RoleID, 0) + + for _, membership := range memberships { + policy, ok := endpoint.TeamAccessPolicies[membership.TeamID] + if ok { + policyRoles = append(policyRoles, policy.RoleID) + } + } + + return getAuthorizationsFromRoles(policyRoles, roles) +} + +func getAuthorizationsFromTeamEndpointGroupPolicies(memberships []TeamMembership, endpoint *Endpoint, roles []Role, groupAccessPolicies map[EndpointGroupID]TeamAccessPolicies) Authorizations { + policyRoles := make([]RoleID, 0) + + for _, membership := range memberships { + policy, ok := groupAccessPolicies[endpoint.GroupID][membership.TeamID] + if ok { + policyRoles = append(policyRoles, policy.RoleID) + } + } + + return getAuthorizationsFromRoles(policyRoles, roles) +} + +func getAuthorizationsFromRoles(roleIdentifiers []RoleID, roles []Role) Authorizations { + var roleAuthorizations []Authorizations + for _, id := range roleIdentifiers { + for _, role := range roles { + if role.ID == id { + roleAuthorizations = append(roleAuthorizations, role.Authorizations) + break + } + } + } + + processedAuthorizations := make(Authorizations) + if len(roleAuthorizations) > 0 { + processedAuthorizations = roleAuthorizations[0] + for idx, authorizations := range roleAuthorizations { + if idx == 0 { + continue + } + processedAuthorizations = mergeAuthorizations(processedAuthorizations, authorizations) + } + } + + return processedAuthorizations +} + +func mergeAuthorizations(a, b Authorizations) Authorizations { + c := make(map[Authorization]bool) + + for k := range b { + if _, ok := a[k]; ok { + c[k] = true + } + } + return c +} diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index 42100f514..a857375f3 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -124,8 +124,11 @@ func (store *Store) MigrateData() error { ExtensionService: store.ExtensionService, RegistryService: store.RegistryService, ResourceControlService: store.ResourceControlService, + RoleService: store.RoleService, + ScheduleService: store.ScheduleService, SettingsService: store.SettingsService, StackService: store.StackService, + TeamMembershipService: store.TeamMembershipService, TemplateService: store.TemplateService, UserService: store.UserService, VersionService: store.VersionService, diff --git a/api/bolt/init.go b/api/bolt/init.go index b8b731bf2..a9b941179 100644 --- a/api/bolt/init.go +++ b/api/bolt/init.go @@ -218,8 +218,6 @@ func (store *Store) Init() error { portainer.OperationDockerAgentPing: true, portainer.OperationDockerAgentList: true, portainer.OperationDockerAgentHostInfo: true, - portainer.OperationDockerAgentBrowseGet: true, - portainer.OperationDockerAgentBrowseList: true, portainer.OperationPortainerStackList: true, portainer.OperationPortainerStackInspect: true, portainer.OperationPortainerStackFile: true, @@ -342,11 +340,6 @@ func (store *Store) Init() error { portainer.OperationDockerAgentPing: true, portainer.OperationDockerAgentList: true, portainer.OperationDockerAgentHostInfo: true, - portainer.OperationDockerAgentBrowseDelete: true, - portainer.OperationDockerAgentBrowseGet: true, - portainer.OperationDockerAgentBrowseList: true, - portainer.OperationDockerAgentBrowsePut: true, - portainer.OperationDockerAgentBrowseRename: true, portainer.OperationDockerAgentUndefined: true, portainer.OperationPortainerResourceControlCreate: true, portainer.OperationPortainerResourceControlUpdate: true, @@ -413,8 +406,6 @@ func (store *Store) Init() error { portainer.OperationDockerAgentPing: true, portainer.OperationDockerAgentList: true, portainer.OperationDockerAgentHostInfo: true, - portainer.OperationDockerAgentBrowseGet: true, - portainer.OperationDockerAgentBrowseList: true, portainer.OperationPortainerStackList: true, portainer.OperationPortainerStackInspect: true, portainer.OperationPortainerStackFile: true, diff --git a/api/bolt/migrator/migrate_dbversion19.go b/api/bolt/migrator/migrate_dbversion19.go new file mode 100644 index 000000000..0692db5af --- /dev/null +++ b/api/bolt/migrator/migrate_dbversion19.go @@ -0,0 +1,67 @@ +package migrator + +import ( + "strings" + + portainer "github.com/portainer/portainer/api" +) + +func (m *Migrator) updateUsersToDBVersion20() error { + authorizationServiceParameters := &portainer.AuthorizationServiceParameters{ + EndpointService: m.endpointService, + EndpointGroupService: m.endpointGroupService, + RegistryService: m.registryService, + RoleService: m.roleService, + TeamMembershipService: m.teamMembershipService, + UserService: m.userService, + } + + authorizationService := portainer.NewAuthorizationService(authorizationServiceParameters) + return authorizationService.UpdateUsersAuthorizations() +} + +func (m *Migrator) updateSettingsToDBVersion20() error { + legacySettings, err := m.settingsService.Settings() + if err != nil { + return err + } + + legacySettings.AllowVolumeBrowserForRegularUsers = false + + return m.settingsService.UpdateSettings(legacySettings) +} + +func (m *Migrator) updateSchedulesToDBVersion20() error { + legacySchedules, err := m.scheduleService.Schedules() + if err != nil { + return err + } + + for _, schedule := range legacySchedules { + if schedule.JobType == portainer.ScriptExecutionJobType { + if schedule.CronExpression == "0 0 * * *" { + schedule.CronExpression = "0 * * * *" + } else if schedule.CronExpression == "0 0 0/2 * *" { + schedule.CronExpression = "0 */2 * * *" + } else if schedule.CronExpression == "0 0 0 * *" { + schedule.CronExpression = "0 0 * * *" + } else { + revisedCronExpression := strings.Split(schedule.CronExpression, " ") + if len(revisedCronExpression) == 5 { + continue + } + + revisedCronExpression = revisedCronExpression[1:] + schedule.CronExpression = strings.Join(revisedCronExpression, " ") + } + + err := m.scheduleService.UpdateSchedule(schedule.ID, &schedule) + if err != nil { + return err + } + } + + } + + return nil +} diff --git a/api/bolt/migrator/migrator.go b/api/bolt/migrator/migrator.go index 63a51f150..048ab11ac 100644 --- a/api/bolt/migrator/migrator.go +++ b/api/bolt/migrator/migrator.go @@ -8,8 +8,11 @@ import ( "github.com/portainer/portainer/api/bolt/extension" "github.com/portainer/portainer/api/bolt/registry" "github.com/portainer/portainer/api/bolt/resourcecontrol" + "github.com/portainer/portainer/api/bolt/role" + "github.com/portainer/portainer/api/bolt/schedule" "github.com/portainer/portainer/api/bolt/settings" "github.com/portainer/portainer/api/bolt/stack" + "github.com/portainer/portainer/api/bolt/teammembership" "github.com/portainer/portainer/api/bolt/template" "github.com/portainer/portainer/api/bolt/user" "github.com/portainer/portainer/api/bolt/version" @@ -25,8 +28,11 @@ type ( extensionService *extension.Service registryService *registry.Service resourceControlService *resourcecontrol.Service + roleService *role.Service + scheduleService *schedule.Service settingsService *settings.Service stackService *stack.Service + teamMembershipService *teammembership.Service templateService *template.Service userService *user.Service versionService *version.Service @@ -42,8 +48,11 @@ type ( ExtensionService *extension.Service RegistryService *registry.Service ResourceControlService *resourcecontrol.Service + RoleService *role.Service + ScheduleService *schedule.Service SettingsService *settings.Service StackService *stack.Service + TeamMembershipService *teammembership.Service TemplateService *template.Service UserService *user.Service VersionService *version.Service @@ -61,7 +70,10 @@ func NewMigrator(parameters *Parameters) *Migrator { extensionService: parameters.ExtensionService, registryService: parameters.RegistryService, resourceControlService: parameters.ResourceControlService, + roleService: parameters.RoleService, + scheduleService: parameters.ScheduleService, settingsService: parameters.SettingsService, + teamMembershipService: parameters.TeamMembershipService, templateService: parameters.TemplateService, stackService: parameters.StackService, userService: parameters.UserService, @@ -257,5 +269,23 @@ func (m *Migrator) Migrate() error { } } + // Portainer 1.22.1 + if m.currentDBVersion < 20 { + err := m.updateUsersToDBVersion20() + if err != nil { + return err + } + + err = m.updateSettingsToDBVersion20() + if err != nil { + return err + } + + err = m.updateSchedulesToDBVersion20() + if err != nil { + return err + } + } + return m.versionService.StoreDBVersion(portainer.DBVersion) } diff --git a/api/bolt/role/role.go b/api/bolt/role/role.go index 8a4e3e975..36cd8e7d1 100644 --- a/api/bolt/role/role.go +++ b/api/bolt/role/role.go @@ -66,18 +66,24 @@ func (service *Service) Roles() ([]portainer.Role, error) { } // CreateRole creates a new Role. -func (service *Service) CreateRole(set *portainer.Role) error { +func (service *Service) CreateRole(role *portainer.Role) error { return service.db.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) id, _ := bucket.NextSequence() - set.ID = portainer.RoleID(id) + role.ID = portainer.RoleID(id) - data, err := internal.MarshalObject(set) + data, err := internal.MarshalObject(role) if err != nil { return err } - return bucket.Put(internal.Itob(int(set.ID)), data) + return bucket.Put(internal.Itob(int(role.ID)), data) }) } + +// UpdateRole updates a role. +func (service *Service) UpdateRole(ID portainer.RoleID, role *portainer.Role) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, role) +} diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 961039738..88b0a4e7c 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -102,7 +102,7 @@ func initLDAPService() portainer.LDAPService { } func initGitService() portainer.GitService { - return &git.Service{} + return git.NewService() } func initClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService) *docker.ClientFactory { @@ -271,6 +271,7 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL OAuthSettings: portainer.OAuthSettings{}, AllowBindMountsForRegularUsers: true, AllowPrivilegedModeForRegularUsers: true, + AllowVolumeBrowserForRegularUsers: false, EnableHostManagementFeatures: false, SnapshotInterval: *flags.SnapshotInterval, EdgeAgentCheckinInterval: portainer.DefaultEdgeAgentCheckinIntervalInSeconds, @@ -499,8 +500,13 @@ func initExtensionManager(fileService portainer.FileService, extensionService po log.Printf("Unable to enable extension: %s [extension: %s]", err.Error(), extension.Name) extension.Enabled = false extension.License.Valid = false - extensionService.Persist(&extension) } + + err = extensionService.Persist(&extension) + if err != nil { + return nil, err + } + } return extensionManager, nil @@ -618,7 +624,7 @@ func main() { if err != nil { log.Fatal(err) } - adminPasswordHash, err = cryptoService.Hash(string(content)) + adminPasswordHash, err = cryptoService.Hash(strings.TrimSuffix(string(content), "\n")) if err != nil { log.Fatal(err) } @@ -635,26 +641,10 @@ func main() { if len(users) == 0 { log.Printf("Creating admin user with password hash %s", adminPasswordHash) user := &portainer.User{ - Username: "admin", - Role: portainer.AdministratorRole, - Password: adminPasswordHash, - PortainerAuthorizations: map[portainer.Authorization]bool{ - portainer.OperationPortainerDockerHubInspect: true, - portainer.OperationPortainerEndpointGroupList: true, - portainer.OperationPortainerEndpointList: true, - portainer.OperationPortainerEndpointInspect: true, - portainer.OperationPortainerEndpointExtensionAdd: true, - portainer.OperationPortainerEndpointExtensionRemove: true, - portainer.OperationPortainerExtensionList: true, - portainer.OperationPortainerMOTD: true, - portainer.OperationPortainerRegistryList: true, - portainer.OperationPortainerRegistryInspect: true, - portainer.OperationPortainerTeamList: true, - portainer.OperationPortainerTemplateList: true, - portainer.OperationPortainerTemplateInspect: true, - portainer.OperationPortainerUserList: true, - portainer.OperationPortainerUserMemberships: true, - }, + Username: "admin", + Role: portainer.AdministratorRole, + Password: adminPasswordHash, + PortainerAuthorizations: portainer.DefaultPortainerAuthorizations(), } err := store.UserService.CreateUser(user) if err != nil { diff --git a/api/cron/scheduler.go b/api/cron/scheduler.go index 9a2de7450..85b27398b 100644 --- a/api/cron/scheduler.go +++ b/api/cron/scheduler.go @@ -19,7 +19,8 @@ func NewJobScheduler() *JobScheduler { // ScheduleJob schedules the execution of a job via a runner func (scheduler *JobScheduler) ScheduleJob(runner portainer.JobRunner) error { - return scheduler.cron.AddJob(runner.GetSchedule().CronExpression, runner) + _, err := scheduler.cron.AddJob(runner.GetSchedule().CronExpression, runner) + return err } // UpdateSystemJobSchedule updates the first occurence of the specified @@ -35,7 +36,7 @@ func (scheduler *JobScheduler) UpdateSystemJobSchedule(jobType portainer.JobType for _, entry := range cronEntries { if entry.Job.(portainer.JobRunner).GetSchedule().JobType == jobType { - err := newCron.AddJob(newCronExpression, entry.Job) + _, err := newCron.AddJob(newCronExpression, entry.Job) if err != nil { return err } @@ -69,7 +70,7 @@ func (scheduler *JobScheduler) UpdateJobSchedule(runner portainer.JobRunner) err jobRunner = entry.Job } - err := newCron.AddJob(runner.GetSchedule().CronExpression, jobRunner) + _, err := newCron.AddJob(runner.GetSchedule().CronExpression, jobRunner) if err != nil { return err } diff --git a/api/docker/snapshot.go b/api/docker/snapshot.go index e03a76496..7d7ceea8e 100644 --- a/api/docker/snapshot.go +++ b/api/docker/snapshot.go @@ -2,6 +2,7 @@ package docker import ( "context" + "log" "time" "github.com/docker/docker/api/types" @@ -10,7 +11,7 @@ import ( "github.com/portainer/portainer/api" ) -func snapshot(cli *client.Client) (*portainer.Snapshot, error) { +func snapshot(cli *client.Client, endpoint *portainer.Endpoint) (*portainer.Snapshot, error) { _, err := cli.Ping(context.Background()) if err != nil { return nil, err @@ -22,44 +23,44 @@ func snapshot(cli *client.Client) (*portainer.Snapshot, error) { err = snapshotInfo(snapshot, cli) if err != nil { - return nil, err + log.Printf("[WARN] [docker,snapshot] [message: unable to snapshot engine information] [endpoint: %s] [err: %s]", endpoint.Name, err) } if snapshot.Swarm { err = snapshotSwarmServices(snapshot, cli) if err != nil { - return nil, err + log.Printf("[WARN] [docker,snapshot] [message: unable to snapshot Swarm services] [endpoint: %s] [err: %s]", endpoint.Name, err) } err = snapshotNodes(snapshot, cli) if err != nil { - return nil, err + log.Printf("[WARN] [docker,snapshot] [message: unable to snapshot Swarm nodes] [endpoint: %s] [err: %s]", endpoint.Name, err) } } err = snapshotContainers(snapshot, cli) if err != nil { - return nil, err + log.Printf("[WARN] [docker,snapshot] [message: unable to snapshot containers] [endpoint: %s] [err: %s]", endpoint.Name, err) } err = snapshotImages(snapshot, cli) if err != nil { - return nil, err + log.Printf("[WARN] [docker,snapshot] [message: unable to snapshot images] [endpoint: %s] [err: %s]", endpoint.Name, err) } err = snapshotVolumes(snapshot, cli) if err != nil { - return nil, err + log.Printf("[WARN] [docker,snapshot] [message: unable to snapshot volumes] [endpoint: %s] [err: %s]", endpoint.Name, err) } err = snapshotNetworks(snapshot, cli) if err != nil { - return nil, err + log.Printf("[WARN] [docker,snapshot] [message: unable to snapshot networks] [endpoint: %s] [err: %s]", endpoint.Name, err) } err = snapshotVersion(snapshot, cli) if err != nil { - return nil, err + log.Printf("[WARN] [docker,snapshot] [message: unable to snapshot engine version] [endpoint: %s] [err: %s]", endpoint.Name, err) } snapshot.Time = time.Now().Unix() diff --git a/api/docker/snapshotter.go b/api/docker/snapshotter.go index 24d121845..25eceb023 100644 --- a/api/docker/snapshotter.go +++ b/api/docker/snapshotter.go @@ -24,5 +24,5 @@ func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*p } defer cli.Close() - return snapshot(cli) + return snapshot(cli, endpoint) } diff --git a/api/exec/extension.go b/api/exec/extension.go index b0b015599..d4bed5961 100644 --- a/api/exec/extension.go +++ b/api/exec/extension.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "errors" + "log" "os/exec" "path" "runtime" @@ -193,6 +194,7 @@ func validateLicense(binaryPath, licenseKey string) ([]string, error) { err := licenseCheckProcess.Run() if err != nil { + log.Printf("[DEBUG] [exec,extension] [message: unable to run extension process] [err: %s]", err) return nil, errors.New("Invalid extension license key") } diff --git a/api/git/git.go b/api/git/git.go index add688b01..116a5f113 100644 --- a/api/git/git.go +++ b/api/git/git.go @@ -1,21 +1,37 @@ package git import ( + "crypto/tls" + "net/http" "net/url" "strings" + "time" "gopkg.in/src-d/go-git.v4" "gopkg.in/src-d/go-git.v4/plumbing" + "gopkg.in/src-d/go-git.v4/plumbing/transport/client" + githttp "gopkg.in/src-d/go-git.v4/plumbing/transport/http" ) // Service represents a service for managing Git. -type Service struct{} +type Service struct { + httpsCli *http.Client +} // NewService initializes a new service. -func NewService(dataStorePath string) (*Service, error) { - service := &Service{} +func NewService() *Service { + httpsCli := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + Timeout: 300 * time.Second, + } - return service, nil + client.InstallProtocol("https", githttp.NewClient(httpsCli)) + + return &Service{ + httpsCli: httpsCli, + } } // ClonePublicRepository clones a public git repository using the specified URL in the specified @@ -32,7 +48,7 @@ func (service *Service) ClonePrivateRepositoryWithBasicAuth(repositoryURL, refer return cloneRepository(repositoryURL, referenceName, destination) } -func cloneRepository(repositoryURL, referenceName string, destination string) error { +func cloneRepository(repositoryURL, referenceName, destination string) error { options := &git.CloneOptions{ URL: repositoryURL, } diff --git a/api/http/handler/auth/authenticate.go b/api/http/handler/auth/authenticate.go index 9acfd78e3..02159c275 100644 --- a/api/http/handler/auth/authenticate.go +++ b/api/http/handler/auth/authenticate.go @@ -52,7 +52,7 @@ func (handler *Handler) authenticate(w http.ResponseWriter, r *http.Request) *ht return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a user with the specified username from the database", err} } - if err == portainer.ErrObjectNotFound && settings.AuthenticationMethod == portainer.AuthenticationInternal { + if err == portainer.ErrObjectNotFound && (settings.AuthenticationMethod == portainer.AuthenticationInternal || settings.AuthenticationMethod == portainer.AuthenticationOAuth) { return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", portainer.ErrUnauthorized} } @@ -98,25 +98,9 @@ func (handler *Handler) authenticateLDAPAndCreateUser(w http.ResponseWriter, use } user := &portainer.User{ - Username: username, - Role: portainer.StandardUserRole, - PortainerAuthorizations: map[portainer.Authorization]bool{ - portainer.OperationPortainerDockerHubInspect: true, - portainer.OperationPortainerEndpointGroupList: true, - portainer.OperationPortainerEndpointList: true, - portainer.OperationPortainerEndpointInspect: true, - portainer.OperationPortainerEndpointExtensionAdd: true, - portainer.OperationPortainerEndpointExtensionRemove: true, - portainer.OperationPortainerExtensionList: true, - portainer.OperationPortainerMOTD: true, - portainer.OperationPortainerRegistryList: true, - portainer.OperationPortainerRegistryInspect: true, - portainer.OperationPortainerTeamList: true, - portainer.OperationPortainerTemplateList: true, - portainer.OperationPortainerTemplateInspect: true, - portainer.OperationPortainerUserList: true, - portainer.OperationPortainerUserMemberships: true, - }, + Username: username, + Role: portainer.StandardUserRole, + PortainerAuthorizations: portainer.DefaultPortainerAuthorizations(), } err = handler.UserService.CreateUser(user) @@ -134,59 +118,14 @@ func (handler *Handler) authenticateLDAPAndCreateUser(w http.ResponseWriter, use func (handler *Handler) writeToken(w http.ResponseWriter, user *portainer.User) *httperror.HandlerError { tokenData := &portainer.TokenData{ - ID: user.ID, - Username: user.Username, - Role: user.Role, - PortainerAuthorizations: user.PortainerAuthorizations, + ID: user.ID, + Username: user.Username, + Role: user.Role, } - _, err := handler.ExtensionService.Extension(portainer.RBACExtension) - if err == portainer.ErrObjectNotFound { - return handler.persistAndWriteToken(w, tokenData) - } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err} - } - - endpointAuthorizations, err := handler.getAuthorizations(user) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve authorizations associated to the user", err} - } - tokenData.EndpointAuthorizations = endpointAuthorizations - return handler.persistAndWriteToken(w, tokenData) } -func (handler *Handler) getAuthorizations(user *portainer.User) (portainer.EndpointAuthorizations, error) { - endpointAuthorizations := portainer.EndpointAuthorizations{} - if user.Role == portainer.AdministratorRole { - return endpointAuthorizations, nil - } - - userMemberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(user.ID) - if err != nil { - return endpointAuthorizations, err - } - - endpoints, err := handler.EndpointService.Endpoints() - if err != nil { - return endpointAuthorizations, err - } - - endpointGroups, err := handler.EndpointGroupService.EndpointGroups() - if err != nil { - return endpointAuthorizations, err - } - - roles, err := handler.RoleService.Roles() - if err != nil { - return endpointAuthorizations, err - } - - endpointAuthorizations = getUserEndpointAuthorizations(user, endpoints, endpointGroups, roles, userMemberships) - - return endpointAuthorizations, nil -} - func (handler *Handler) persistAndWriteToken(w http.ResponseWriter, tokenData *portainer.TokenData) *httperror.HandlerError { token, err := handler.JWTService.GenerateToken(tokenData) if err != nil { diff --git a/api/http/handler/auth/authenticate_oauth.go b/api/http/handler/auth/authenticate_oauth.go index b87a759e0..849859d9f 100644 --- a/api/http/handler/auth/authenticate_oauth.go +++ b/api/http/handler/auth/authenticate_oauth.go @@ -111,25 +111,9 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h if user == nil { user = &portainer.User{ - Username: username, - Role: portainer.StandardUserRole, - PortainerAuthorizations: map[portainer.Authorization]bool{ - portainer.OperationPortainerDockerHubInspect: true, - portainer.OperationPortainerEndpointGroupList: true, - portainer.OperationPortainerEndpointList: true, - portainer.OperationPortainerEndpointInspect: true, - portainer.OperationPortainerEndpointExtensionAdd: true, - portainer.OperationPortainerEndpointExtensionRemove: true, - portainer.OperationPortainerExtensionList: true, - portainer.OperationPortainerMOTD: true, - portainer.OperationPortainerRegistryList: true, - portainer.OperationPortainerRegistryInspect: true, - portainer.OperationPortainerTeamList: true, - portainer.OperationPortainerTemplateList: true, - portainer.OperationPortainerTemplateInspect: true, - portainer.OperationPortainerUserList: true, - portainer.OperationPortainerUserMemberships: true, - }, + Username: username, + Role: portainer.StandardUserRole, + PortainerAuthorizations: portainer.DefaultPortainerAuthorizations(), } err = handler.UserService.CreateUser(user) diff --git a/api/http/handler/auth/authorization.go b/api/http/handler/auth/authorization.go deleted file mode 100644 index 64f2e5cd8..000000000 --- a/api/http/handler/auth/authorization.go +++ /dev/null @@ -1,122 +0,0 @@ -package auth - -import portainer "github.com/portainer/portainer/api" - -func getUserEndpointAuthorizations(user *portainer.User, endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup, roles []portainer.Role, userMemberships []portainer.TeamMembership) portainer.EndpointAuthorizations { - endpointAuthorizations := make(portainer.EndpointAuthorizations) - - groupUserAccessPolicies := map[portainer.EndpointGroupID]portainer.UserAccessPolicies{} - groupTeamAccessPolicies := map[portainer.EndpointGroupID]portainer.TeamAccessPolicies{} - for _, endpointGroup := range endpointGroups { - groupUserAccessPolicies[endpointGroup.ID] = endpointGroup.UserAccessPolicies - groupTeamAccessPolicies[endpointGroup.ID] = endpointGroup.TeamAccessPolicies - } - - for _, endpoint := range endpoints { - authorizations := getAuthorizationsFromUserEndpointPolicy(user, &endpoint, roles) - if len(authorizations) > 0 { - endpointAuthorizations[endpoint.ID] = authorizations - continue - } - - authorizations = getAuthorizationsFromUserEndpointGroupPolicy(user, &endpoint, roles, groupUserAccessPolicies) - if len(authorizations) > 0 { - endpointAuthorizations[endpoint.ID] = authorizations - continue - } - - authorizations = getAuthorizationsFromTeamEndpointPolicies(userMemberships, &endpoint, roles) - if len(authorizations) > 0 { - endpointAuthorizations[endpoint.ID] = authorizations - continue - } - - endpointAuthorizations[endpoint.ID] = getAuthorizationsFromTeamEndpointGroupPolicies(userMemberships, &endpoint, roles, groupTeamAccessPolicies) - } - - return endpointAuthorizations -} - -func getAuthorizationsFromUserEndpointPolicy(user *portainer.User, endpoint *portainer.Endpoint, roles []portainer.Role) portainer.Authorizations { - policyRoles := make([]portainer.RoleID, 0) - - policy, ok := endpoint.UserAccessPolicies[user.ID] - if ok { - policyRoles = append(policyRoles, policy.RoleID) - } - - return getAuthorizationsFromRoles(policyRoles, roles) -} - -func getAuthorizationsFromUserEndpointGroupPolicy(user *portainer.User, endpoint *portainer.Endpoint, roles []portainer.Role, groupAccessPolicies map[portainer.EndpointGroupID]portainer.UserAccessPolicies) portainer.Authorizations { - policyRoles := make([]portainer.RoleID, 0) - - policy, ok := groupAccessPolicies[endpoint.GroupID][user.ID] - if ok { - policyRoles = append(policyRoles, policy.RoleID) - } - - return getAuthorizationsFromRoles(policyRoles, roles) -} - -func getAuthorizationsFromTeamEndpointPolicies(memberships []portainer.TeamMembership, endpoint *portainer.Endpoint, roles []portainer.Role) portainer.Authorizations { - policyRoles := make([]portainer.RoleID, 0) - - for _, membership := range memberships { - policy, ok := endpoint.TeamAccessPolicies[membership.TeamID] - if ok { - policyRoles = append(policyRoles, policy.RoleID) - } - } - - return getAuthorizationsFromRoles(policyRoles, roles) -} - -func getAuthorizationsFromTeamEndpointGroupPolicies(memberships []portainer.TeamMembership, endpoint *portainer.Endpoint, roles []portainer.Role, groupAccessPolicies map[portainer.EndpointGroupID]portainer.TeamAccessPolicies) portainer.Authorizations { - policyRoles := make([]portainer.RoleID, 0) - - for _, membership := range memberships { - policy, ok := groupAccessPolicies[endpoint.GroupID][membership.TeamID] - if ok { - policyRoles = append(policyRoles, policy.RoleID) - } - } - - return getAuthorizationsFromRoles(policyRoles, roles) -} - -func getAuthorizationsFromRoles(roleIdentifiers []portainer.RoleID, roles []portainer.Role) portainer.Authorizations { - var roleAuthorizations []portainer.Authorizations - for _, id := range roleIdentifiers { - for _, role := range roles { - if role.ID == id { - roleAuthorizations = append(roleAuthorizations, role.Authorizations) - break - } - } - } - - processedAuthorizations := make(portainer.Authorizations) - if len(roleAuthorizations) > 0 { - processedAuthorizations = roleAuthorizations[0] - for idx, authorizations := range roleAuthorizations { - if idx == 0 { - continue - } - processedAuthorizations = mergeAuthorizations(processedAuthorizations, authorizations) - } - } - - return processedAuthorizations -} - -func mergeAuthorizations(a, b portainer.Authorizations) portainer.Authorizations { - c := make(map[portainer.Authorization]bool) - - for k := range b { - if _, ok := a[k]; ok { - c[k] = true - } - } - return c -} diff --git a/api/http/handler/dockerhub/handler.go b/api/http/handler/dockerhub/handler.go index 7e2cb4bd0..ba4ed2c34 100644 --- a/api/http/handler/dockerhub/handler.go +++ b/api/http/handler/dockerhub/handler.go @@ -25,9 +25,9 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), } h.Handle("/dockerhub", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.dockerhubInspect))).Methods(http.MethodGet) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.dockerhubInspect))).Methods(http.MethodGet) h.Handle("/dockerhub", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.dockerhubUpdate))).Methods(http.MethodPut) + bouncer.AdminAccess(httperror.LoggerHandler(h.dockerhubUpdate))).Methods(http.MethodPut) return h } diff --git a/api/http/handler/endpointgroups/endpointgroup_delete.go b/api/http/handler/endpointgroups/endpointgroup_delete.go index a7a3c0a79..dbb634eff 100644 --- a/api/http/handler/endpointgroups/endpointgroup_delete.go +++ b/api/http/handler/endpointgroups/endpointgroup_delete.go @@ -37,8 +37,10 @@ func (handler *Handler) endpointGroupDelete(w http.ResponseWriter, r *http.Reque return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} } + updateAuthorizations := false for _, endpoint := range endpoints { if endpoint.GroupID == portainer.EndpointGroupID(endpointGroupID) { + updateAuthorizations = true endpoint.GroupID = portainer.EndpointGroupID(1) err = handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) if err != nil { @@ -47,5 +49,12 @@ func (handler *Handler) endpointGroupDelete(w http.ResponseWriter, r *http.Reque } } + if updateAuthorizations { + err = handler.AuthorizationService.UpdateUsersAuthorizations() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} + } + } + return response.Empty(w) } diff --git a/api/http/handler/endpointgroups/endpointgroup_update.go b/api/http/handler/endpointgroups/endpointgroup_update.go index 92dbc9037..58ea605fc 100644 --- a/api/http/handler/endpointgroups/endpointgroup_update.go +++ b/api/http/handler/endpointgroups/endpointgroup_update.go @@ -2,6 +2,7 @@ package endpointgroups import ( "net/http" + "reflect" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" @@ -53,12 +54,15 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque endpointGroup.Tags = payload.Tags } - if payload.UserAccessPolicies != nil { + updateAuthorizations := false + if payload.UserAccessPolicies != nil && !reflect.DeepEqual(payload.UserAccessPolicies, endpointGroup.UserAccessPolicies) { endpointGroup.UserAccessPolicies = payload.UserAccessPolicies + updateAuthorizations = true } - if payload.TeamAccessPolicies != nil { + if payload.TeamAccessPolicies != nil && !reflect.DeepEqual(payload.TeamAccessPolicies, endpointGroup.TeamAccessPolicies) { endpointGroup.TeamAccessPolicies = payload.TeamAccessPolicies + updateAuthorizations = true } err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup) @@ -66,5 +70,12 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint group changes inside the database", err} } + if updateAuthorizations { + err = handler.AuthorizationService.UpdateUsersAuthorizations() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} + } + } + return response.JSON(w, endpointGroup) } diff --git a/api/http/handler/endpointgroups/handler.go b/api/http/handler/endpointgroups/handler.go index fa10d92c9..d4a36d3f5 100644 --- a/api/http/handler/endpointgroups/handler.go +++ b/api/http/handler/endpointgroups/handler.go @@ -14,6 +14,7 @@ type Handler struct { *mux.Router EndpointService portainer.EndpointService EndpointGroupService portainer.EndpointGroupService + AuthorizationService *portainer.AuthorizationService } // NewHandler creates a handler to manage endpoint group operations. @@ -22,18 +23,18 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), } h.Handle("/endpoint_groups", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointGroupCreate))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointGroupCreate))).Methods(http.MethodPost) h.Handle("/endpoint_groups", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointGroupList))).Methods(http.MethodGet) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointGroupList))).Methods(http.MethodGet) h.Handle("/endpoint_groups/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointGroupInspect))).Methods(http.MethodGet) + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointGroupInspect))).Methods(http.MethodGet) h.Handle("/endpoint_groups/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointGroupUpdate))).Methods(http.MethodPut) + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointGroupUpdate))).Methods(http.MethodPut) h.Handle("/endpoint_groups/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointGroupDelete))).Methods(http.MethodDelete) + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointGroupDelete))).Methods(http.MethodDelete) h.Handle("/endpoint_groups/{id}/endpoints/{endpointId}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointGroupAddEndpoint))).Methods(http.MethodPut) + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointGroupAddEndpoint))).Methods(http.MethodPut) h.Handle("/endpoint_groups/{id}/endpoints/{endpointId}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointGroupDeleteEndpoint))).Methods(http.MethodDelete) + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointGroupDeleteEndpoint))).Methods(http.MethodDelete) return h } diff --git a/api/http/handler/endpointproxy/handler.go b/api/http/handler/endpointproxy/handler.go index 69db8f54d..be89bb750 100644 --- a/api/http/handler/endpointproxy/handler.go +++ b/api/http/handler/endpointproxy/handler.go @@ -25,10 +25,10 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { requestBouncer: bouncer, } h.PathPrefix("/{id}/azure").Handler( - bouncer.RestrictedAccess(httperror.LoggerHandler(h.proxyRequestsToAzureAPI))) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToAzureAPI))) h.PathPrefix("/{id}/docker").Handler( - bouncer.RestrictedAccess(httperror.LoggerHandler(h.proxyRequestsToDockerAPI))) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToDockerAPI))) h.PathPrefix("/{id}/storidge").Handler( - bouncer.RestrictedAccess(httperror.LoggerHandler(h.proxyRequestsToStoridgeAPI))) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToStoridgeAPI))) return h } diff --git a/api/http/handler/endpointproxy/proxy_docker.go b/api/http/handler/endpointproxy/proxy_docker.go index bdd5c8322..35490a8ff 100644 --- a/api/http/handler/endpointproxy/proxy_docker.go +++ b/api/http/handler/endpointproxy/proxy_docker.go @@ -25,10 +25,6 @@ func (handler *Handler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http. return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } - if endpoint.Type != portainer.EdgeAgentEnvironment && endpoint.Status == portainer.EndpointStatusDown { - return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to query endpoint", errors.New("Endpoint is down")} - } - err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint, true) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index 6f4163025..865923149 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -2,12 +2,12 @@ package endpoints import ( "errors" - "log" "net" "net/http" "net/url" "runtime" "strconv" + "strings" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" @@ -192,9 +192,9 @@ func (handler *Handler) createAzureEndpoint(payload *endpointCreatePayload) (*po Snapshots: []portainer.Snapshot{}, } - err = handler.EndpointService.CreateEndpoint(endpoint) + err = handler.saveEndpointAndUpdateAuthorizations(endpoint) if err != nil { - return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint inside the database", err} + return nil, &httperror.HandlerError{http.StatusInternalServerError, "An error occured while trying to create the endpoint", err} } return endpoint, nil @@ -238,9 +238,9 @@ func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload) EdgeKey: edgeKey, } - err = handler.EndpointService.CreateEndpoint(endpoint) + err = handler.saveEndpointAndUpdateAuthorizations(endpoint) if err != nil { - return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint inside the database", err} + return nil, &httperror.HandlerError{http.StatusInternalServerError, "An error occured while trying to create the endpoint", err} } return endpoint, nil @@ -344,17 +344,37 @@ func (handler *Handler) snapshotAndPersistEndpoint(endpoint *portainer.Endpoint) snapshot, err := handler.Snapshotter.CreateSnapshot(endpoint) endpoint.Status = portainer.EndpointStatusUp if err != nil { - log.Printf("http error: endpoint snapshot error (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) - endpoint.Status = portainer.EndpointStatusDown + if strings.Contains(err.Error(), "Invalid request signature") { + err = errors.New("agent already paired with another Portainer instance") + } + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to initiate communications with endpoint", err} } if snapshot != nil { endpoint.Snapshots = []portainer.Snapshot{*snapshot} } - err = handler.EndpointService.CreateEndpoint(endpoint) + err = handler.saveEndpointAndUpdateAuthorizations(endpoint) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "An error occured while trying to create the endpoint", err} + } + + return nil +} + +func (handler *Handler) saveEndpointAndUpdateAuthorizations(endpoint *portainer.Endpoint) error { + err := handler.EndpointService.CreateEndpoint(endpoint) + if err != nil { + return err + } + + group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) + if err != nil { + return err + } + + if len(group.UserAccessPolicies) > 0 || len(group.TeamAccessPolicies) > 0 { + return handler.AuthorizationService.UpdateUsersAuthorizations() } return nil diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go index ca7af3aa9..4d82f9c60 100644 --- a/api/http/handler/endpoints/endpoint_delete.go +++ b/api/http/handler/endpoints/endpoint_delete.go @@ -43,5 +43,12 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) * handler.ProxyManager.DeleteProxy(endpoint) + if len(endpoint.UserAccessPolicies) > 0 || len(endpoint.TeamAccessPolicies) > 0 { + err = handler.AuthorizationService.UpdateUsersAuthorizations() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} + } + } + return response.Empty(w) } diff --git a/api/http/handler/endpoints/endpoint_update.go b/api/http/handler/endpoints/endpoint_update.go index f62ee638b..6816ec0d7 100644 --- a/api/http/handler/endpoints/endpoint_update.go +++ b/api/http/handler/endpoints/endpoint_update.go @@ -2,6 +2,7 @@ package endpoints import ( "net/http" + "reflect" "strconv" httperror "github.com/portainer/libhttp/error" @@ -76,12 +77,15 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * endpoint.Tags = payload.Tags } - if payload.UserAccessPolicies != nil { + updateAuthorizations := false + if payload.UserAccessPolicies != nil && !reflect.DeepEqual(payload.UserAccessPolicies, endpoint.UserAccessPolicies) { endpoint.UserAccessPolicies = payload.UserAccessPolicies + updateAuthorizations = true } - if payload.TeamAccessPolicies != nil { + if payload.TeamAccessPolicies != nil && !reflect.DeepEqual(payload.TeamAccessPolicies, endpoint.TeamAccessPolicies) { endpoint.TeamAccessPolicies = payload.TeamAccessPolicies + updateAuthorizations = true } if payload.Status != nil { @@ -173,5 +177,12 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} } + if updateAuthorizations { + err = handler.AuthorizationService.UpdateUsersAuthorizations() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} + } + } + return response.JSON(w, endpoint) } diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index 17d0012bb..c655a0eef 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -37,6 +37,7 @@ type Handler struct { JobService portainer.JobService ReverseTunnelService portainer.ReverseTunnelService SettingsService portainer.SettingsService + AuthorizationService *portainer.AuthorizationService } // NewHandler creates a handler to manage endpoint operations. @@ -48,25 +49,25 @@ func NewHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bo } h.Handle("/endpoints", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointCreate))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointCreate))).Methods(http.MethodPost) h.Handle("/endpoints/snapshot", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointSnapshots))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSnapshots))).Methods(http.MethodPost) h.Handle("/endpoints", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointList))).Methods(http.MethodGet) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointList))).Methods(http.MethodGet) h.Handle("/endpoints/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointInspect))).Methods(http.MethodGet) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointInspect))).Methods(http.MethodGet) h.Handle("/endpoints/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointUpdate))).Methods(http.MethodPut) + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointUpdate))).Methods(http.MethodPut) h.Handle("/endpoints/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointDelete))).Methods(http.MethodDelete) + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDelete))).Methods(http.MethodDelete) h.Handle("/endpoints/{id}/extensions", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointExtensionAdd))).Methods(http.MethodPost) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointExtensionAdd))).Methods(http.MethodPost) h.Handle("/endpoints/{id}/extensions/{extensionType}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointExtensionRemove))).Methods(http.MethodDelete) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointExtensionRemove))).Methods(http.MethodDelete) h.Handle("/endpoints/{id}/job", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointJob))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointJob))).Methods(http.MethodPost) h.Handle("/endpoints/{id}/snapshot", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost) h.Handle("/endpoints/{id}/status", bouncer.PublicAccess(httperror.LoggerHandler(h.endpointStatusInspect))).Methods(http.MethodGet) diff --git a/api/http/handler/extensions/handler.go b/api/http/handler/extensions/handler.go index a7ab38feb..d77347594 100644 --- a/api/http/handler/extensions/handler.go +++ b/api/http/handler/extensions/handler.go @@ -17,6 +17,7 @@ type Handler struct { EndpointGroupService portainer.EndpointGroupService EndpointService portainer.EndpointService RegistryService portainer.RegistryService + AuthorizationService *portainer.AuthorizationService } // NewHandler creates a handler to manage extension operations. @@ -26,15 +27,15 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { } h.Handle("/extensions", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.extensionList))).Methods(http.MethodGet) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.extensionList))).Methods(http.MethodGet) h.Handle("/extensions", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.extensionCreate))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.extensionCreate))).Methods(http.MethodPost) h.Handle("/extensions/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.extensionInspect))).Methods(http.MethodGet) + bouncer.AdminAccess(httperror.LoggerHandler(h.extensionInspect))).Methods(http.MethodGet) h.Handle("/extensions/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.extensionDelete))).Methods(http.MethodDelete) + bouncer.AdminAccess(httperror.LoggerHandler(h.extensionDelete))).Methods(http.MethodDelete) h.Handle("/extensions/{id}/update", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.extensionUpdate))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.extensionUpdate))).Methods(http.MethodPost) return h } diff --git a/api/http/handler/extensions/upgrade.go b/api/http/handler/extensions/upgrade.go index 7e3203412..b0f37f5d7 100644 --- a/api/http/handler/extensions/upgrade.go +++ b/api/http/handler/extensions/upgrade.go @@ -1,6 +1,8 @@ package extensions -import portainer "github.com/portainer/portainer/api" +import ( + portainer "github.com/portainer/portainer/api" +) func updateUserAccessPolicyToReadOnlyRole(policies portainer.UserAccessPolicies, key portainer.UserID) { tmp := policies[key] @@ -33,7 +35,6 @@ func (handler *Handler) upgradeRBACData() error { if err != nil { return err } - } endpoints, err := handler.EndpointService.Endpoints() @@ -55,5 +56,6 @@ func (handler *Handler) upgradeRBACData() error { return err } } - return nil + + return handler.AuthorizationService.UpdateUsersAuthorizations() } diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 9dc541641..7bb72dbb9 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -4,6 +4,8 @@ import ( "net/http" "strings" + "github.com/portainer/portainer/api/http/handler/support" + "github.com/portainer/portainer/api/http/handler/schedules" "github.com/portainer/portainer/api/http/handler/roles" @@ -48,6 +50,7 @@ type Handler struct { SettingsHandler *settings.Handler StackHandler *stacks.Handler StatusHandler *status.Handler + SupportHandler *support.Handler TagHandler *tags.Handler TeamMembershipHandler *teammemberships.Handler TeamHandler *teams.Handler @@ -96,6 +99,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api", h.StackHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/status"): http.StripPrefix("/api", h.StatusHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/support"): + http.StripPrefix("/api", h.SupportHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/tags"): http.StripPrefix("/api", h.TagHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/templates"): diff --git a/api/http/handler/motd/handler.go b/api/http/handler/motd/handler.go index aa2d1d002..f7aa79e84 100644 --- a/api/http/handler/motd/handler.go +++ b/api/http/handler/motd/handler.go @@ -18,7 +18,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), } h.Handle("/motd", - bouncer.AuthorizedAccess(http.HandlerFunc(h.motd))).Methods(http.MethodGet) + bouncer.RestrictedAccess(http.HandlerFunc(h.motd))).Methods(http.MethodGet) return h } diff --git a/api/http/handler/registries/handler.go b/api/http/handler/registries/handler.go index 3c90e6e67..202a81fdc 100644 --- a/api/http/handler/registries/handler.go +++ b/api/http/handler/registries/handler.go @@ -33,19 +33,19 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { } h.Handle("/registries", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.registryCreate))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.registryCreate))).Methods(http.MethodPost) h.Handle("/registries", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.registryList))).Methods(http.MethodGet) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.registryList))).Methods(http.MethodGet) h.Handle("/registries/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.registryInspect))).Methods(http.MethodGet) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.registryInspect))).Methods(http.MethodGet) h.Handle("/registries/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.registryUpdate))).Methods(http.MethodPut) + bouncer.AdminAccess(httperror.LoggerHandler(h.registryUpdate))).Methods(http.MethodPut) h.Handle("/registries/{id}/configure", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.registryConfigure))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.registryConfigure))).Methods(http.MethodPost) h.Handle("/registries/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.registryDelete))).Methods(http.MethodDelete) + bouncer.AdminAccess(httperror.LoggerHandler(h.registryDelete))).Methods(http.MethodDelete) h.PathPrefix("/registries/{id}/v2").Handler( - bouncer.RestrictedAccess(httperror.LoggerHandler(h.proxyRequestsToRegistryAPI))) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToRegistryAPI))) return h } diff --git a/api/http/handler/registries/registry_update.go b/api/http/handler/registries/registry_update.go index 14c11cbba..15dd2d9dc 100644 --- a/api/http/handler/registries/registry_update.go +++ b/api/http/handler/registries/registry_update.go @@ -3,7 +3,6 @@ package registries import ( "net/http" - "github.com/asaskevich/govalidator" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" @@ -11,19 +10,16 @@ import ( ) type registryUpdatePayload struct { - Name string - URL string - Authentication bool - Username string - Password string + Name *string + URL *string + Authentication *bool + Username *string + Password *string UserAccessPolicies portainer.UserAccessPolicies TeamAccessPolicies portainer.TeamAccessPolicies } func (payload *registryUpdatePayload) Validate(r *http.Request) error { - if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) { - return portainer.Error("Invalid credentials. Username and password must be specified when authentication is enabled") - } return nil } @@ -47,32 +43,41 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} } - registries, err := handler.RegistryService.Registries() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} + if payload.Name != nil { + registry.Name = *payload.Name } - for _, r := range registries { - if r.URL == payload.URL && r.ID != registry.ID { - return &httperror.HandlerError{http.StatusConflict, "Another registry with the same URL already exists", portainer.ErrRegistryAlreadyExists} + + if payload.URL != nil { + registries, err := handler.RegistryService.Registries() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} } + for _, r := range registries { + if r.URL == *payload.URL && r.ID != registry.ID { + return &httperror.HandlerError{http.StatusConflict, "Another registry with the same URL already exists", portainer.ErrRegistryAlreadyExists} + } + } + + registry.URL = *payload.URL } - if payload.Name != "" { - registry.Name = payload.Name - } + if payload.Authentication != nil { + if *payload.Authentication { + registry.Authentication = true - if payload.URL != "" { - registry.URL = payload.URL - } + if payload.Username != nil { + registry.Username = *payload.Username + } - if payload.Authentication { - registry.Authentication = true - registry.Username = payload.Username - registry.Password = payload.Password - } else { - registry.Authentication = false - registry.Username = "" - registry.Password = "" + if payload.Password != nil { + registry.Password = *payload.Password + } + + } else { + registry.Authentication = false + registry.Username = "" + registry.Password = "" + } } if payload.UserAccessPolicies != nil { diff --git a/api/http/handler/resourcecontrols/handler.go b/api/http/handler/resourcecontrols/handler.go index d187ba6c8..a2227f2c8 100644 --- a/api/http/handler/resourcecontrols/handler.go +++ b/api/http/handler/resourcecontrols/handler.go @@ -21,11 +21,11 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), } h.Handle("/resource_controls", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.resourceControlCreate))).Methods(http.MethodPost) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.resourceControlCreate))).Methods(http.MethodPost) h.Handle("/resource_controls/{id}", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.resourceControlUpdate))).Methods(http.MethodPut) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.resourceControlUpdate))).Methods(http.MethodPut) h.Handle("/resource_controls/{id}", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.resourceControlDelete))).Methods(http.MethodDelete) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.resourceControlDelete))).Methods(http.MethodDelete) return h } diff --git a/api/http/handler/roles/handler.go b/api/http/handler/roles/handler.go index e6bb7c4c7..89ec52452 100644 --- a/api/http/handler/roles/handler.go +++ b/api/http/handler/roles/handler.go @@ -21,7 +21,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), } h.Handle("/roles", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.roleList))).Methods(http.MethodGet) + bouncer.AdminAccess(httperror.LoggerHandler(h.roleList))).Methods(http.MethodGet) return h } diff --git a/api/http/handler/schedules/handler.go b/api/http/handler/schedules/handler.go index 303178c25..cc7d3dbf2 100644 --- a/api/http/handler/schedules/handler.go +++ b/api/http/handler/schedules/handler.go @@ -28,18 +28,18 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { } h.Handle("/schedules", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.scheduleList))).Methods(http.MethodGet) + bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleList))).Methods(http.MethodGet) h.Handle("/schedules", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.scheduleCreate))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleCreate))).Methods(http.MethodPost) h.Handle("/schedules/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.scheduleInspect))).Methods(http.MethodGet) + bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleInspect))).Methods(http.MethodGet) h.Handle("/schedules/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.scheduleUpdate))).Methods(http.MethodPut) + bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleUpdate))).Methods(http.MethodPut) h.Handle("/schedules/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.scheduleDelete))).Methods(http.MethodDelete) + bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleDelete))).Methods(http.MethodDelete) h.Handle("/schedules/{id}/file", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.scheduleFile))).Methods(http.MethodGet) + bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleFile))).Methods(http.MethodGet) h.Handle("/schedules/{id}/tasks", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.scheduleTasks))).Methods(http.MethodGet) + bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleTasks))).Methods(http.MethodGet) return h } diff --git a/api/http/handler/settings/handler.go b/api/http/handler/settings/handler.go index 2a5348b2e..1f688f343 100644 --- a/api/http/handler/settings/handler.go +++ b/api/http/handler/settings/handler.go @@ -17,11 +17,14 @@ func hideFields(settings *portainer.Settings) { // Handler is the HTTP handler used to handle settings operations. type Handler struct { *mux.Router - SettingsService portainer.SettingsService - LDAPService portainer.LDAPService - FileService portainer.FileService - JobScheduler portainer.JobScheduler - ScheduleService portainer.ScheduleService + SettingsService portainer.SettingsService + LDAPService portainer.LDAPService + FileService portainer.FileService + JobScheduler portainer.JobScheduler + ScheduleService portainer.ScheduleService + RoleService portainer.RoleService + ExtensionService portainer.ExtensionService + AuthorizationService *portainer.AuthorizationService } // NewHandler creates a handler to manage settings operations. @@ -30,13 +33,13 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), } h.Handle("/settings", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.settingsInspect))).Methods(http.MethodGet) + bouncer.AdminAccess(httperror.LoggerHandler(h.settingsInspect))).Methods(http.MethodGet) h.Handle("/settings", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.settingsUpdate))).Methods(http.MethodPut) + bouncer.AdminAccess(httperror.LoggerHandler(h.settingsUpdate))).Methods(http.MethodPut) h.Handle("/settings/public", bouncer.PublicAccess(httperror.LoggerHandler(h.settingsPublic))).Methods(http.MethodGet) h.Handle("/settings/authentication/checkLDAP", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.settingsLDAPCheck))).Methods(http.MethodPut) + bouncer.AdminAccess(httperror.LoggerHandler(h.settingsLDAPCheck))).Methods(http.MethodPut) return h } diff --git a/api/http/handler/settings/settings_public.go b/api/http/handler/settings/settings_public.go index 38eb8d4e3..afa8e85dd 100644 --- a/api/http/handler/settings/settings_public.go +++ b/api/http/handler/settings/settings_public.go @@ -14,6 +14,7 @@ type publicSettingsResponse struct { AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"` AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"` AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` + AllowVolumeBrowserForRegularUsers bool `json:"AllowVolumeBrowserForRegularUsers"` EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"` ExternalTemplates bool `json:"ExternalTemplates"` OAuthLoginURI string `json:"OAuthLoginURI"` @@ -31,6 +32,7 @@ func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) * AuthenticationMethod: settings.AuthenticationMethod, AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers, AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers, + AllowVolumeBrowserForRegularUsers: settings.AllowVolumeBrowserForRegularUsers, EnableHostManagementFeatures: settings.EnableHostManagementFeatures, ExternalTemplates: false, OAuthLoginURI: fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=%s&prompt=login", diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index 6de707c38..cf0994648 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -19,6 +19,7 @@ type settingsUpdatePayload struct { OAuthSettings *portainer.OAuthSettings AllowBindMountsForRegularUsers *bool AllowPrivilegedModeForRegularUsers *bool + AllowVolumeBrowserForRegularUsers *bool EnableHostManagementFeatures *bool SnapshotInterval *string TemplatesURL *string @@ -93,6 +94,12 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * settings.AllowPrivilegedModeForRegularUsers = *payload.AllowPrivilegedModeForRegularUsers } + updateAuthorizations := false + if payload.AllowVolumeBrowserForRegularUsers != nil { + settings.AllowVolumeBrowserForRegularUsers = *payload.AllowVolumeBrowserForRegularUsers + updateAuthorizations = true + } + if payload.EnableHostManagementFeatures != nil { settings.EnableHostManagementFeatures = *payload.EnableHostManagementFeatures } @@ -118,9 +125,37 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist settings changes inside the database", err} } + if updateAuthorizations { + err := handler.updateVolumeBrowserSetting(settings) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update RBAC authorizations", err} + } + } + return response.JSON(w, settings) } +func (handler *Handler) updateVolumeBrowserSetting(settings *portainer.Settings) error { + err := handler.AuthorizationService.UpdateVolumeBrowsingAuthorizations(settings.AllowVolumeBrowserForRegularUsers) + if err != nil { + return err + } + + extension, err := handler.ExtensionService.Extension(portainer.RBACExtension) + if err != nil && err != portainer.ErrObjectNotFound { + return err + } + + if extension != nil { + err = handler.AuthorizationService.UpdateUsersAuthorizations() + if err != nil { + return err + } + } + + return nil +} + func (handler *Handler) updateSnapshotInterval(settings *portainer.Settings, snapshotInterval string) error { settings.SnapshotInterval = snapshotInterval diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go index 66c9d91cb..7cae40cf9 100644 --- a/api/http/handler/stacks/create_compose_stack.go +++ b/api/http/handler/stacks/create_compose_stack.go @@ -1,7 +1,9 @@ package stacks import ( + "errors" "net/http" + "path" "strconv" "strings" @@ -238,7 +240,7 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, } stackFolder := strconv.Itoa(int(stack.ID)) - projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) + projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, payload.StackFileContent) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Compose file on disk", err} } @@ -271,6 +273,7 @@ type composeStackDeploymentConfig struct { endpoint *portainer.Endpoint dockerhub *portainer.DockerHub registries []portainer.Registry + isAdmin bool } func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) (*composeStackDeploymentConfig, *httperror.HandlerError) { @@ -295,6 +298,7 @@ func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portai endpoint: endpoint, dockerhub: dockerhub, registries: filteredRegistries, + isAdmin: securityContext.IsAdmin, } return config, nil @@ -306,12 +310,34 @@ func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portai // clean it. Hence the use of the mutex. // We should contribute to libcompose to support authentication without using the config.json file. func (handler *Handler) deployComposeStack(config *composeStackDeploymentConfig) error { + settings, err := handler.SettingsService.Settings() + if err != nil { + return err + } + + if !settings.AllowBindMountsForRegularUsers && !config.isAdmin { + composeFilePath := path.Join(config.stack.ProjectPath, config.stack.EntryPoint) + + stackContent, err := handler.FileService.GetFileContent(composeFilePath) + if err != nil { + return err + } + + valid, err := handler.isValidStackFile(stackContent) + if err != nil { + return err + } + if !valid { + return errors.New("bind-mount disabled for non administrator users") + } + } + handler.stackCreationMutex.Lock() defer handler.stackCreationMutex.Unlock() handler.SwarmStackManager.Login(config.dockerhub, config.registries, config.endpoint) - err := handler.ComposeStackManager.Up(config.stack, config.endpoint) + err = handler.ComposeStackManager.Up(config.stack, config.endpoint) if err != nil { return err } diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go index 0832111e0..4210cab0e 100644 --- a/api/http/handler/stacks/create_swarm_stack.go +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -1,7 +1,9 @@ package stacks import ( + "errors" "net/http" + "path" "strconv" "strings" @@ -290,6 +292,7 @@ type swarmStackDeploymentConfig struct { dockerhub *portainer.DockerHub registries []portainer.Registry prune bool + isAdmin bool } func (handler *Handler) createSwarmDeployConfig(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, prune bool) (*swarmStackDeploymentConfig, *httperror.HandlerError) { @@ -315,18 +318,41 @@ func (handler *Handler) createSwarmDeployConfig(r *http.Request, stack *portaine dockerhub: dockerhub, registries: filteredRegistries, prune: prune, + isAdmin: securityContext.IsAdmin, } return config, nil } func (handler *Handler) deploySwarmStack(config *swarmStackDeploymentConfig) error { + settings, err := handler.SettingsService.Settings() + if err != nil { + return err + } + + if !settings.AllowBindMountsForRegularUsers && !config.isAdmin { + composeFilePath := path.Join(config.stack.ProjectPath, config.stack.EntryPoint) + + stackContent, err := handler.FileService.GetFileContent(composeFilePath) + if err != nil { + return err + } + + valid, err := handler.isValidStackFile(stackContent) + if err != nil { + return err + } + if !valid { + return errors.New("bind-mount disabled for non administrator users") + } + } + handler.stackCreationMutex.Lock() defer handler.stackCreationMutex.Unlock() handler.SwarmStackManager.Login(config.dockerhub, config.registries, config.endpoint) - err := handler.SwarmStackManager.Deploy(config.stack, config.prune, config.endpoint) + err = handler.SwarmStackManager.Deploy(config.stack, config.prune, config.endpoint) if err != nil { return err } diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go index caf181bef..b81270543 100644 --- a/api/http/handler/stacks/handler.go +++ b/api/http/handler/stacks/handler.go @@ -25,6 +25,7 @@ type Handler struct { DockerHubService portainer.DockerHubService SwarmStackManager portainer.SwarmStackManager ComposeStackManager portainer.ComposeStackManager + SettingsService portainer.SettingsService } // NewHandler creates a handler to manage stack operations. @@ -36,18 +37,18 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { requestBouncer: bouncer, } h.Handle("/stacks", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackCreate))).Methods(http.MethodPost) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackCreate))).Methods(http.MethodPost) h.Handle("/stacks", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackList))).Methods(http.MethodGet) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackList))).Methods(http.MethodGet) h.Handle("/stacks/{id}", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackInspect))).Methods(http.MethodGet) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackInspect))).Methods(http.MethodGet) h.Handle("/stacks/{id}", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackDelete))).Methods(http.MethodDelete) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackDelete))).Methods(http.MethodDelete) h.Handle("/stacks/{id}", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackUpdate))).Methods(http.MethodPut) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackUpdate))).Methods(http.MethodPut) h.Handle("/stacks/{id}/file", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackFile))).Methods(http.MethodGet) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackFile))).Methods(http.MethodGet) h.Handle("/stacks/{id}/migrate", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackMigrate))).Methods(http.MethodPost) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackMigrate))).Methods(http.MethodPost) return h } diff --git a/api/http/handler/stacks/stack_create.go b/api/http/handler/stacks/stack_create.go index 0d1adae53..aba79482c 100644 --- a/api/http/handler/stacks/stack_create.go +++ b/api/http/handler/stacks/stack_create.go @@ -5,6 +5,9 @@ import ( "log" "net/http" + "github.com/docker/cli/cli/compose/types" + + "github.com/docker/cli/cli/compose/loader" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/portainer/api" @@ -87,3 +90,38 @@ func (handler *Handler) createSwarmStack(w http.ResponseWriter, r *http.Request, return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string, repository or file", errors.New(request.ErrInvalidQueryParameter)} } + +func (handler *Handler) isValidStackFile(stackFileContent []byte) (bool, error) { + composeConfigYAML, err := loader.ParseYAML(stackFileContent) + if err != nil { + return false, err + } + + composeConfigFile := types.ConfigFile{ + Config: composeConfigYAML, + } + + composeConfigDetails := types.ConfigDetails{ + ConfigFiles: []types.ConfigFile{composeConfigFile}, + Environment: map[string]string{}, + } + + composeConfig, err := loader.Load(composeConfigDetails, func(options *loader.Options) { + options.SkipValidation = true + options.SkipInterpolation = true + }) + if err != nil { + return false, err + } + + for key := range composeConfig.Services { + service := composeConfig.Services[key] + for _, volume := range service.Volumes { + if volume.Type == "bind" { + return false, nil + } + } + } + + return true, nil +} diff --git a/api/http/handler/status/handler.go b/api/http/handler/status/handler.go index cb30c8479..af11d25a3 100644 --- a/api/http/handler/status/handler.go +++ b/api/http/handler/status/handler.go @@ -23,6 +23,8 @@ func NewHandler(bouncer *security.RequestBouncer, status *portainer.Status) *Han } h.Handle("/status", bouncer.PublicAccess(httperror.LoggerHandler(h.statusInspect))).Methods(http.MethodGet) + h.Handle("/status/version", + bouncer.AuthenticatedAccess(http.HandlerFunc(h.statusInspectVersion))).Methods(http.MethodGet) return h } diff --git a/api/http/handler/status/status_inspect_version.go b/api/http/handler/status/status_inspect_version.go new file mode 100644 index 000000000..054bd670e --- /dev/null +++ b/api/http/handler/status/status_inspect_version.go @@ -0,0 +1,51 @@ +package status + +import ( + "encoding/json" + "net/http" + + "github.com/coreos/go-semver/semver" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/client" + + "github.com/portainer/libhttp/response" +) + +type inspectVersionResponse struct { + UpdateAvailable bool `json:"UpdateAvailable"` + LatestVersion string `json:"LatestVersion"` +} + +type githubData struct { + TagName string `json:"tag_name"` +} + +// GET request on /api/status/version +func (handler *Handler) statusInspectVersion(w http.ResponseWriter, r *http.Request) { + motd, err := client.Get(portainer.VersionCheckURL, 5) + if err != nil { + response.JSON(w, &inspectVersionResponse{UpdateAvailable: false}) + return + } + + var data githubData + err = json.Unmarshal(motd, &data) + if err != nil { + response.JSON(w, &inspectVersionResponse{UpdateAvailable: false}) + return + } + + resp := inspectVersionResponse{ + UpdateAvailable: false, + } + + currentVersion := semver.New(portainer.APIVersion) + latestVersion := semver.New(data.TagName) + if currentVersion.LessThan(*latestVersion) { + resp.UpdateAvailable = true + resp.LatestVersion = data.TagName + } + + response.JSON(w, &resp) +} diff --git a/api/http/handler/support/handler.go b/api/http/handler/support/handler.go new file mode 100644 index 000000000..1ac8de22a --- /dev/null +++ b/api/http/handler/support/handler.go @@ -0,0 +1,26 @@ +package support + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + + "github.com/gorilla/mux" + "github.com/portainer/portainer/api/http/security" +) + +// Handler is the HTTP handler used to handle support operations. +type Handler struct { + *mux.Router +} + +// NewHandler returns a new Handler +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/support", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.supportList))).Methods(http.MethodGet) + + return h +} diff --git a/api/http/handler/support/support_list.go b/api/http/handler/support/support_list.go new file mode 100644 index 000000000..a16d8dafd --- /dev/null +++ b/api/http/handler/support/support_list.go @@ -0,0 +1,39 @@ +package support + +import ( + "encoding/json" + + httperror "github.com/portainer/libhttp/error" + portainer "github.com/portainer/portainer/api" + + "net/http" + + "github.com/portainer/portainer/api/http/client" + + "github.com/portainer/libhttp/response" +) + +type supportProduct struct { + ID int `json:"Id"` + Name string `json:"Name"` + ShortDescription string `json:"ShortDescription"` + Price string `json:"Price"` + PriceDescription string `json:"PriceDescription"` + Description string `json:"Description"` + ProductID string `json:"ProductId"` +} + +func (handler *Handler) supportList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + supportData, err := client.Get(portainer.SupportProductsURL, 30) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to fetch support options", err} + } + + var supportProducts []supportProduct + err = json.Unmarshal(supportData, &supportProducts) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to fetch support options", err} + } + + return response.JSON(w, supportProducts) +} diff --git a/api/http/handler/tags/handler.go b/api/http/handler/tags/handler.go index d6461e2dc..33cb59c9d 100644 --- a/api/http/handler/tags/handler.go +++ b/api/http/handler/tags/handler.go @@ -21,11 +21,11 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), } h.Handle("/tags", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.tagCreate))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.tagCreate))).Methods(http.MethodPost) h.Handle("/tags", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.tagList))).Methods(http.MethodGet) + bouncer.AdminAccess(httperror.LoggerHandler(h.tagList))).Methods(http.MethodGet) h.Handle("/tags/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.tagDelete))).Methods(http.MethodDelete) + bouncer.AdminAccess(httperror.LoggerHandler(h.tagDelete))).Methods(http.MethodDelete) return h } diff --git a/api/http/handler/teammemberships/handler.go b/api/http/handler/teammemberships/handler.go index 3fd56bfc0..0428241ec 100644 --- a/api/http/handler/teammemberships/handler.go +++ b/api/http/handler/teammemberships/handler.go @@ -13,8 +13,8 @@ import ( // Handler is the HTTP handler used to handle team membership operations. type Handler struct { *mux.Router - TeamMembershipService portainer.TeamMembershipService - ResourceControlService portainer.ResourceControlService + TeamMembershipService portainer.TeamMembershipService + AuthorizationService *portainer.AuthorizationService } // NewHandler creates a handler to manage team membership operations. @@ -23,13 +23,13 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), } h.Handle("/team_memberships", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.teamMembershipCreate))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.teamMembershipCreate))).Methods(http.MethodPost) h.Handle("/team_memberships", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.teamMembershipList))).Methods(http.MethodGet) + bouncer.AdminAccess(httperror.LoggerHandler(h.teamMembershipList))).Methods(http.MethodGet) h.Handle("/team_memberships/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.teamMembershipUpdate))).Methods(http.MethodPut) + bouncer.AdminAccess(httperror.LoggerHandler(h.teamMembershipUpdate))).Methods(http.MethodPut) h.Handle("/team_memberships/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.teamMembershipDelete))).Methods(http.MethodDelete) + bouncer.AdminAccess(httperror.LoggerHandler(h.teamMembershipDelete))).Methods(http.MethodDelete) return h } diff --git a/api/http/handler/teammemberships/teammembership_create.go b/api/http/handler/teammemberships/teammembership_create.go index 0d8716de9..245b1fe67 100644 --- a/api/http/handler/teammemberships/teammembership_create.go +++ b/api/http/handler/teammemberships/teammembership_create.go @@ -70,5 +70,10 @@ func (handler *Handler) teamMembershipCreate(w http.ResponseWriter, r *http.Requ return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist team memberships inside the database", err} } + err = handler.AuthorizationService.UpdateUsersAuthorizations() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} + } + return response.JSON(w, membership) } diff --git a/api/http/handler/teammemberships/teammembership_delete.go b/api/http/handler/teammemberships/teammembership_delete.go index dd8fe6d0b..179bbe800 100644 --- a/api/http/handler/teammemberships/teammembership_delete.go +++ b/api/http/handler/teammemberships/teammembership_delete.go @@ -38,5 +38,10 @@ func (handler *Handler) teamMembershipDelete(w http.ResponseWriter, r *http.Requ return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the team membership from the database", err} } + err = handler.AuthorizationService.UpdateUsersAuthorizations() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} + } + return response.Empty(w) } diff --git a/api/http/handler/teams/handler.go b/api/http/handler/teams/handler.go index 1aad28ef0..e5eea77fc 100644 --- a/api/http/handler/teams/handler.go +++ b/api/http/handler/teams/handler.go @@ -12,9 +12,9 @@ import ( // Handler is the HTTP handler used to handle team operations. type Handler struct { *mux.Router - TeamService portainer.TeamService - TeamMembershipService portainer.TeamMembershipService - ResourceControlService portainer.ResourceControlService + TeamService portainer.TeamService + TeamMembershipService portainer.TeamMembershipService + AuthorizationService *portainer.AuthorizationService } // NewHandler creates a handler to manage team operations. @@ -23,17 +23,17 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), } h.Handle("/teams", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.teamCreate))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.teamCreate))).Methods(http.MethodPost) h.Handle("/teams", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.teamList))).Methods(http.MethodGet) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.teamList))).Methods(http.MethodGet) h.Handle("/teams/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.teamInspect))).Methods(http.MethodGet) + bouncer.AdminAccess(httperror.LoggerHandler(h.teamInspect))).Methods(http.MethodGet) h.Handle("/teams/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.teamUpdate))).Methods(http.MethodPut) + bouncer.AdminAccess(httperror.LoggerHandler(h.teamUpdate))).Methods(http.MethodPut) h.Handle("/teams/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.teamDelete))).Methods(http.MethodDelete) + bouncer.AdminAccess(httperror.LoggerHandler(h.teamDelete))).Methods(http.MethodDelete) h.Handle("/teams/{id}/memberships", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.teamMemberships))).Methods(http.MethodGet) + bouncer.AdminAccess(httperror.LoggerHandler(h.teamMemberships))).Methods(http.MethodGet) return h } diff --git a/api/http/handler/teams/team_delete.go b/api/http/handler/teams/team_delete.go index f38010725..2b96e9351 100644 --- a/api/http/handler/teams/team_delete.go +++ b/api/http/handler/teams/team_delete.go @@ -33,5 +33,10 @@ func (handler *Handler) teamDelete(w http.ResponseWriter, r *http.Request) *http return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete associated team memberships from the database", err} } + err = handler.AuthorizationService.RemoveTeamAccessPolicies(portainer.TeamID(teamID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clean-up team access policies", err} + } + return response.Empty(w) } diff --git a/api/http/handler/templates/handler.go b/api/http/handler/templates/handler.go index 026b137ee..3eac57b4a 100644 --- a/api/http/handler/templates/handler.go +++ b/api/http/handler/templates/handler.go @@ -27,15 +27,15 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { } h.Handle("/templates", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.templateList))).Methods(http.MethodGet) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.templateList))).Methods(http.MethodGet) h.Handle("/templates", - bouncer.AuthorizedAccess(h.templateManagementCheck(httperror.LoggerHandler(h.templateCreate)))).Methods(http.MethodPost) + bouncer.AdminAccess(h.templateManagementCheck(httperror.LoggerHandler(h.templateCreate)))).Methods(http.MethodPost) h.Handle("/templates/{id}", - bouncer.AuthorizedAccess(h.templateManagementCheck(httperror.LoggerHandler(h.templateInspect)))).Methods(http.MethodGet) + bouncer.RestrictedAccess(h.templateManagementCheck(httperror.LoggerHandler(h.templateInspect)))).Methods(http.MethodGet) h.Handle("/templates/{id}", - bouncer.AuthorizedAccess(h.templateManagementCheck(httperror.LoggerHandler(h.templateUpdate)))).Methods(http.MethodPut) + bouncer.AdminAccess(h.templateManagementCheck(httperror.LoggerHandler(h.templateUpdate)))).Methods(http.MethodPut) h.Handle("/templates/{id}", - bouncer.AuthorizedAccess(h.templateManagementCheck(httperror.LoggerHandler(h.templateDelete)))).Methods(http.MethodDelete) + bouncer.AdminAccess(h.templateManagementCheck(httperror.LoggerHandler(h.templateDelete)))).Methods(http.MethodDelete) return h } diff --git a/api/http/handler/upload/handler.go b/api/http/handler/upload/handler.go index fe3060dac..dd6a459a1 100644 --- a/api/http/handler/upload/handler.go +++ b/api/http/handler/upload/handler.go @@ -22,6 +22,6 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), } h.Handle("/upload/tls/{certificate:(?:ca|cert|key)}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.uploadTLS))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.uploadTLS))).Methods(http.MethodPost) return h } diff --git a/api/http/handler/users/admin_init.go b/api/http/handler/users/admin_init.go index 044bc2876..a57e95f62 100644 --- a/api/http/handler/users/admin_init.go +++ b/api/http/handler/users/admin_init.go @@ -43,25 +43,9 @@ func (handler *Handler) adminInit(w http.ResponseWriter, r *http.Request) *httpe } user := &portainer.User{ - Username: payload.Username, - Role: portainer.AdministratorRole, - PortainerAuthorizations: map[portainer.Authorization]bool{ - portainer.OperationPortainerDockerHubInspect: true, - portainer.OperationPortainerEndpointGroupList: true, - portainer.OperationPortainerEndpointList: true, - portainer.OperationPortainerEndpointInspect: true, - portainer.OperationPortainerEndpointExtensionAdd: true, - portainer.OperationPortainerEndpointExtensionRemove: true, - portainer.OperationPortainerExtensionList: true, - portainer.OperationPortainerMOTD: true, - portainer.OperationPortainerRegistryList: true, - portainer.OperationPortainerRegistryInspect: true, - portainer.OperationPortainerTeamList: true, - portainer.OperationPortainerTemplateList: true, - portainer.OperationPortainerTemplateInspect: true, - portainer.OperationPortainerUserList: true, - portainer.OperationPortainerUserMemberships: true, - }, + Username: payload.Username, + Role: portainer.AdministratorRole, + PortainerAuthorizations: portainer.DefaultPortainerAuthorizations(), } user.Password, err = handler.CryptoService.Hash(payload.Password) diff --git a/api/http/handler/users/handler.go b/api/http/handler/users/handler.go index 8e7f5035b..646bf8ae5 100644 --- a/api/http/handler/users/handler.go +++ b/api/http/handler/users/handler.go @@ -23,6 +23,7 @@ type Handler struct { ResourceControlService portainer.ResourceControlService CryptoService portainer.CryptoService SettingsService portainer.SettingsService + AuthorizationService *portainer.AuthorizationService } // NewHandler creates a handler to manage user operations. @@ -31,19 +32,19 @@ func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimi Router: mux.NewRouter(), } h.Handle("/users", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.userCreate))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.userCreate))).Methods(http.MethodPost) h.Handle("/users", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.userList))).Methods(http.MethodGet) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.userList))).Methods(http.MethodGet) h.Handle("/users/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.userInspect))).Methods(http.MethodGet) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.userInspect))).Methods(http.MethodGet) h.Handle("/users/{id}", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.userUpdate))).Methods(http.MethodPut) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.userUpdate))).Methods(http.MethodPut) h.Handle("/users/{id}", - bouncer.AuthorizedAccess(httperror.LoggerHandler(h.userDelete))).Methods(http.MethodDelete) + bouncer.AdminAccess(httperror.LoggerHandler(h.userDelete))).Methods(http.MethodDelete) h.Handle("/users/{id}/memberships", bouncer.RestrictedAccess(httperror.LoggerHandler(h.userMemberships))).Methods(http.MethodGet) h.Handle("/users/{id}/passwd", - rateLimiter.LimitAccess(bouncer.RestrictedAccess(httperror.LoggerHandler(h.userUpdatePassword)))).Methods(http.MethodPut) + rateLimiter.LimitAccess(bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.userUpdatePassword)))).Methods(http.MethodPut) h.Handle("/users/admin/check", bouncer.PublicAccess(httperror.LoggerHandler(h.adminCheck))).Methods(http.MethodGet) h.Handle("/users/admin/init", diff --git a/api/http/handler/users/user_create.go b/api/http/handler/users/user_create.go index ab89cf1ff..582ca084d 100644 --- a/api/http/handler/users/user_create.go +++ b/api/http/handler/users/user_create.go @@ -58,25 +58,9 @@ func (handler *Handler) userCreate(w http.ResponseWriter, r *http.Request) *http } user = &portainer.User{ - Username: payload.Username, - Role: portainer.UserRole(payload.Role), - PortainerAuthorizations: map[portainer.Authorization]bool{ - portainer.OperationPortainerDockerHubInspect: true, - portainer.OperationPortainerEndpointGroupList: true, - portainer.OperationPortainerEndpointList: true, - portainer.OperationPortainerEndpointInspect: true, - portainer.OperationPortainerEndpointExtensionAdd: true, - portainer.OperationPortainerEndpointExtensionRemove: true, - portainer.OperationPortainerExtensionList: true, - portainer.OperationPortainerMOTD: true, - portainer.OperationPortainerRegistryList: true, - portainer.OperationPortainerRegistryInspect: true, - portainer.OperationPortainerTeamList: true, - portainer.OperationPortainerTemplateList: true, - portainer.OperationPortainerTemplateInspect: true, - portainer.OperationPortainerUserList: true, - portainer.OperationPortainerUserMemberships: true, - }, + Username: payload.Username, + Role: portainer.UserRole(payload.Role), + PortainerAuthorizations: portainer.DefaultPortainerAuthorizations(), } settings, err := handler.SettingsService.Settings() diff --git a/api/http/handler/users/user_delete.go b/api/http/handler/users/user_delete.go index c600e1943..203a3be47 100644 --- a/api/http/handler/users/user_delete.go +++ b/api/http/handler/users/user_delete.go @@ -65,15 +65,20 @@ func (handler *Handler) deleteAdminUser(w http.ResponseWriter, user *portainer.U } func (handler *Handler) deleteUser(w http.ResponseWriter, user *portainer.User) *httperror.HandlerError { - err := handler.UserService.DeleteUser(portainer.UserID(user.ID)) + err := handler.UserService.DeleteUser(user.ID) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove user from the database", err} } - err = handler.TeamMembershipService.DeleteTeamMembershipByUserID(portainer.UserID(user.ID)) + err = handler.TeamMembershipService.DeleteTeamMembershipByUserID(user.ID) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove user memberships from the database", err} } + err = handler.AuthorizationService.RemoveUserAccessPolicies(user.ID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clean-up user access policies", err} + } + return response.Empty(w) } diff --git a/api/http/handler/users/user_inspect.go b/api/http/handler/users/user_inspect.go index f0723bd49..4c5954283 100644 --- a/api/http/handler/users/user_inspect.go +++ b/api/http/handler/users/user_inspect.go @@ -3,6 +3,8 @@ package users import ( "net/http" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" @@ -16,6 +18,15 @@ func (handler *Handler) userInspect(w http.ResponseWriter, r *http.Request) *htt return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err} } + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !securityContext.IsAdmin && securityContext.UserID != portainer.UserID(userID) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied inspect user", portainer.ErrResourceAccessDenied} + } + user, err := handler.UserService.User(portainer.UserID(userID)) if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err} diff --git a/api/http/handler/webhooks/handler.go b/api/http/handler/webhooks/handler.go index f2deb2e5c..2e342114e 100644 --- a/api/http/handler/webhooks/handler.go +++ b/api/http/handler/webhooks/handler.go @@ -24,11 +24,11 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), } h.Handle("/webhooks", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.webhookCreate))).Methods(http.MethodPost) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.webhookCreate))).Methods(http.MethodPost) h.Handle("/webhooks", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.webhookList))).Methods(http.MethodGet) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.webhookList))).Methods(http.MethodGet) h.Handle("/webhooks/{id}", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.webhookDelete))).Methods(http.MethodDelete) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.webhookDelete))).Methods(http.MethodDelete) h.Handle("/webhooks/{token}", bouncer.PublicAccess(httperror.LoggerHandler(h.webhookExecute))).Methods(http.MethodPost) return h diff --git a/api/http/handler/websocket/handler.go b/api/http/handler/websocket/handler.go index 79dc0502a..cc0165eb0 100644 --- a/api/http/handler/websocket/handler.go +++ b/api/http/handler/websocket/handler.go @@ -26,8 +26,8 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { requestBouncer: bouncer, } h.PathPrefix("/websocket/exec").Handler( - bouncer.RestrictedAccess(httperror.LoggerHandler(h.websocketExec))) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.websocketExec))) h.PathPrefix("/websocket/attach").Handler( - bouncer.RestrictedAccess(httperror.LoggerHandler(h.websocketAttach))) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.websocketAttach))) return h } diff --git a/api/http/proxy/docker_transport.go b/api/http/proxy/docker_transport.go index a3e3d99f8..8abb21ce3 100644 --- a/api/http/proxy/docker_transport.go +++ b/api/http/proxy/docker_transport.go @@ -19,12 +19,14 @@ type ( dockerTransport *http.Transport enableSignature bool ResourceControlService portainer.ResourceControlService + UserService portainer.UserService TeamMembershipService portainer.TeamMembershipService RegistryService portainer.RegistryService DockerHubService portainer.DockerHubService SettingsService portainer.SettingsService SignatureService portainer.DigitalSignatureService ReverseTunnelService portainer.ReverseTunnelService + ExtensionService portainer.ExtensionService endpointIdentifier portainer.EndpointID endpointType portainer.EndpointType } @@ -112,11 +114,29 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon return p.proxyBuildRequest(request) case strings.HasPrefix(path, "/images"): return p.proxyImageRequest(request) + case strings.HasPrefix(path, "/v2"): + return p.proxyAgentRequest(request) default: return p.executeDockerRequest(request) } } +func (p *proxyTransport) proxyAgentRequest(r *http.Request) (*http.Response, error) { + requestPath := strings.TrimPrefix(r.URL.Path, "/v2") + + switch { + case strings.HasPrefix(requestPath, "/browse"): + volumeIDParameter, found := r.URL.Query()["volumeID"] + if !found || len(volumeIDParameter) < 1 { + return p.administratorOperation(r) + } + + return p.restrictedVolumeBrowserOperation(r, volumeIDParameter[0]) + } + + return p.executeDockerRequest(r) +} + func (p *proxyTransport) proxyConfigRequest(request *http.Request) (*http.Response, error) { switch requestPath := request.URL.Path; requestPath { case "/configs/create": @@ -360,7 +380,63 @@ func (p *proxyTransport) restrictedOperation(request *http.Request, resourceID s } resourceControl := getResourceControlByResourceID(resourceID, resourceControls) - if resourceControl != nil && !canUserAccessResource(tokenData.ID, userTeamIDs, resourceControl) { + if resourceControl == nil || !canUserAccessResource(tokenData.ID, userTeamIDs, resourceControl) { + return writeAccessDeniedResponse() + } + } + + return p.executeDockerRequest(request) +} + +// restrictedVolumeBrowserOperation is similar to restrictedOperation but adds an extra check on a specific setting +func (p *proxyTransport) restrictedVolumeBrowserOperation(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 { + settings, err := p.SettingsService.Settings() + if err != nil { + return nil, err + } + + _, err = p.ExtensionService.Extension(portainer.RBACExtension) + if err == portainer.ErrObjectNotFound && !settings.AllowVolumeBrowserForRegularUsers { + return writeAccessDeniedResponse() + } else if err != nil && err != portainer.ErrObjectNotFound { + return nil, err + } + + user, err := p.UserService.User(tokenData.ID) + if err != nil { + return nil, err + } + + endpointResourceAccess := false + _, ok := user.EndpointAuthorizations[p.endpointIdentifier][portainer.EndpointResourcesAccess] + if ok { + endpointResourceAccess = true + } + + 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 !endpointResourceAccess && (resourceControl == nil || !canUserAccessResource(tokenData.ID, userTeamIDs, resourceControl)) { return writeAccessDeniedResponse() } } @@ -498,7 +574,12 @@ func (p *proxyTransport) createOperationContext(request *http.Request) (*restric if tokenData.Role != portainer.AdministratorRole { operationContext.isAdmin = false - _, ok := tokenData.EndpointAuthorizations[p.endpointIdentifier][portainer.EndpointResourcesAccess] + user, err := p.UserService.User(operationContext.userID) + if err != nil { + return nil, err + } + + _, ok := user.EndpointAuthorizations[p.endpointIdentifier][portainer.EndpointResourcesAccess] if ok { operationContext.endpointResourceAccess = true } diff --git a/api/http/proxy/factory.go b/api/http/proxy/factory.go index 0774e7f14..b9416707d 100644 --- a/api/http/proxy/factory.go +++ b/api/http/proxy/factory.go @@ -16,12 +16,14 @@ const AzureAPIBaseURL = "https://management.azure.com" // proxyFactory is a factory to create reverse proxies to Docker endpoints type proxyFactory struct { ResourceControlService portainer.ResourceControlService + UserService portainer.UserService TeamMembershipService portainer.TeamMembershipService SettingsService portainer.SettingsService RegistryService portainer.RegistryService DockerHubService portainer.DockerHubService SignatureService portainer.DigitalSignatureService ReverseTunnelService portainer.ReverseTunnelService + ExtensionService portainer.ExtensionService } func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler { @@ -70,11 +72,13 @@ func (factory *proxyFactory) createDockerReverseProxy(u *url.URL, endpoint *port transport := &proxyTransport{ enableSignature: enableSignature, ResourceControlService: factory.ResourceControlService, + UserService: factory.UserService, TeamMembershipService: factory.TeamMembershipService, SettingsService: factory.SettingsService, RegistryService: factory.RegistryService, DockerHubService: factory.DockerHubService, ReverseTunnelService: factory.ReverseTunnelService, + ExtensionService: factory.ExtensionService, dockerTransport: &http.Transport{}, endpointIdentifier: endpoint.ID, endpointType: endpoint.Type, diff --git a/api/http/proxy/factory_local.go b/api/http/proxy/factory_local.go index 1e461ab1a..c9ab44b81 100644 --- a/api/http/proxy/factory_local.go +++ b/api/http/proxy/factory_local.go @@ -13,10 +13,12 @@ func (factory *proxyFactory) newLocalProxy(path string, endpoint *portainer.Endp transport := &proxyTransport{ enableSignature: false, ResourceControlService: factory.ResourceControlService, + UserService: factory.UserService, TeamMembershipService: factory.TeamMembershipService, SettingsService: factory.SettingsService, RegistryService: factory.RegistryService, DockerHubService: factory.DockerHubService, + ExtensionService: factory.ExtensionService, dockerTransport: newSocketTransport(path), ReverseTunnelService: factory.ReverseTunnelService, endpointIdentifier: endpoint.ID, diff --git a/api/http/proxy/factory_local_windows.go b/api/http/proxy/factory_local_windows.go index 01b020cf8..3f1d860d7 100644 --- a/api/http/proxy/factory_local_windows.go +++ b/api/http/proxy/factory_local_windows.go @@ -6,6 +6,8 @@ import ( "net" "net/http" + "github.com/Microsoft/go-winio" + portainer "github.com/portainer/portainer/api" ) @@ -14,11 +16,13 @@ func (factory *proxyFactory) newLocalProxy(path string, endpoint *portainer.Endp transport := &proxyTransport{ enableSignature: false, ResourceControlService: factory.ResourceControlService, + UserService: factory.UserService, TeamMembershipService: factory.TeamMembershipService, SettingsService: factory.SettingsService, RegistryService: factory.RegistryService, DockerHubService: factory.DockerHubService, ReverseTunnelService: factory.ReverseTunnelService, + ExtensionService: factory.ExtensionService, dockerTransport: newNamedPipeTransport(path), endpointIdentifier: endpoint.ID, endpointType: endpoint.Type, diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go index 6d0311cfa..7a1f38580 100644 --- a/api/http/proxy/manager.go +++ b/api/http/proxy/manager.go @@ -31,12 +31,14 @@ type ( // ManagerParams represents the required parameters to create a new Manager instance. ManagerParams struct { ResourceControlService portainer.ResourceControlService + UserService portainer.UserService TeamMembershipService portainer.TeamMembershipService SettingsService portainer.SettingsService RegistryService portainer.RegistryService DockerHubService portainer.DockerHubService SignatureService portainer.DigitalSignatureService ReverseTunnelService portainer.ReverseTunnelService + ExtensionService portainer.ExtensionService } ) @@ -48,12 +50,14 @@ func NewManager(parameters *ManagerParams) *Manager { legacyExtensionProxies: cmap.New(), proxyFactory: &proxyFactory{ ResourceControlService: parameters.ResourceControlService, + UserService: parameters.UserService, TeamMembershipService: parameters.TeamMembershipService, SettingsService: parameters.SettingsService, RegistryService: parameters.RegistryService, DockerHubService: parameters.DockerHubService, SignatureService: parameters.SignatureService, ReverseTunnelService: parameters.ReverseTunnelService, + ExtensionService: parameters.ExtensionService, }, reverseTunnelService: parameters.ReverseTunnelService, } diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index ad2152c77..d52c98562 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -34,7 +34,7 @@ type ( } // RestrictedRequestContext is a data structure containing information - // used in RestrictedAccess + // used in AuthenticatedAccess RestrictedRequestContext struct { IsAdmin bool IsTeamLeader bool @@ -64,22 +64,40 @@ func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler { return h } -// AuthorizedAccess defines a security check for API endpoints that require an authorization check. +// AdminAccess defines a security check for API endpoints that require an authorization check. // Authentication is required to access these endpoints. // If the RBAC extension is enabled, authorizations are required to use these endpoints. // If the RBAC extension is not enabled, the administrator role is required to use these endpoints. -func (bouncer *RequestBouncer) AuthorizedAccess(h http.Handler) http.Handler { +// The request context will be enhanced with a RestrictedRequestContext object +// that might be used later to inside the API operation for extra authorization validation +// and resource filtering. +func (bouncer *RequestBouncer) AdminAccess(h http.Handler) http.Handler { h = bouncer.mwUpgradeToRestrictedRequest(h) - h = bouncer.mwCheckPortainerAuthorizations(h) + h = bouncer.mwCheckPortainerAuthorizations(h, true) h = bouncer.mwAuthenticatedUser(h) return h } // RestrictedAccess defines a security check for restricted API endpoints. // Authentication is required to access these endpoints. +// If the RBAC extension is enabled, authorizations are required to use these endpoints. +// If the RBAC extension is not enabled, access is granted to any authenticated user. // The request context will be enhanced with a RestrictedRequestContext object -// that might be used later to authorize/filter access to resources inside an endpoint. +// that might be used later to inside the API operation for extra authorization validation +// and resource filtering. func (bouncer *RequestBouncer) RestrictedAccess(h http.Handler) http.Handler { + h = bouncer.mwUpgradeToRestrictedRequest(h) + h = bouncer.mwCheckPortainerAuthorizations(h, false) + h = bouncer.mwAuthenticatedUser(h) + return h +} + +// AuthenticatedAccess defines a security check for restricted API endpoints. +// Authentication is required to access these endpoints. +// The request context will be enhanced with a RestrictedRequestContext object +// that might be used later to inside the API operation for extra authorization validation +// and resource filtering. +func (bouncer *RequestBouncer) AuthenticatedAccess(h http.Handler) http.Handler { h = bouncer.mwUpgradeToRestrictedRequest(h) h = bouncer.mwAuthenticatedUser(h) return h @@ -142,10 +160,15 @@ func (bouncer *RequestBouncer) checkEndpointOperationAuthorization(r *http.Reque return err } + user, err := bouncer.userService.User(tokenData.ID) + if err != nil { + return err + } + apiOperation := &portainer.APIOperationAuthorizationRequest{ Path: r.URL.String(), Method: r.Method, - Authorizations: tokenData.EndpointAuthorizations[endpoint.ID], + Authorizations: user.EndpointAuthorizations[endpoint.ID], } bouncer.rbacExtensionClient.setLicenseKey(extension.License.LicenseKey) @@ -186,11 +209,13 @@ func (bouncer *RequestBouncer) mwAuthenticatedUser(h http.Handler) http.Handler // mwCheckPortainerAuthorizations will verify that the user has the required authorization to access // a specific API endpoint. It will leverage the RBAC extension authorization validation if the extension // is enabled. -func (bouncer *RequestBouncer) mwCheckPortainerAuthorizations(next http.Handler) http.Handler { +// If the administratorOnly flag is specified and the RBAC extension is not enabled, this will prevent non-admin +// users from accessing the endpoint. +func (bouncer *RequestBouncer) mwCheckPortainerAuthorizations(next http.Handler, administratorOnly bool) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tokenData, err := RetrieveTokenData(r) if err != nil { - httperror.WriteError(w, http.StatusForbidden, "Access denied", portainer.ErrResourceAccessDenied) + httperror.WriteError(w, http.StatusForbidden, "Access denied", portainer.ErrUnauthorized) return } @@ -201,6 +226,11 @@ func (bouncer *RequestBouncer) mwCheckPortainerAuthorizations(next http.Handler) extension, err := bouncer.extensionService.Extension(portainer.RBACExtension) if err == portainer.ErrObjectNotFound { + if administratorOnly { + httperror.WriteError(w, http.StatusForbidden, "Access denied", portainer.ErrUnauthorized) + return + } + next.ServeHTTP(w, r) return } else if err != nil { @@ -208,10 +238,19 @@ func (bouncer *RequestBouncer) mwCheckPortainerAuthorizations(next http.Handler) return } + user, err := bouncer.userService.User(tokenData.ID) + if err != nil && err == portainer.ErrObjectNotFound { + httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", portainer.ErrUnauthorized) + return + } else if err != nil { + httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve user details from the database", err) + return + } + apiOperation := &portainer.APIOperationAuthorizationRequest{ Path: r.URL.String(), Method: r.Method, - Authorizations: tokenData.PortainerAuthorizations, + Authorizations: user.PortainerAuthorizations, } bouncer.rbacExtensionClient.setLicenseKey(extension.License.LicenseKey) @@ -281,7 +320,7 @@ func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Han httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", portainer.ErrUnauthorized) return } else if err != nil { - httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve users from the database", err) + httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve user details from the database", err) return } } else { diff --git a/api/http/server.go b/api/http/server.go index f058d2e00..40c11ac2e 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -3,6 +3,8 @@ package http import ( "time" + "github.com/portainer/portainer/api/http/handler/support" + "github.com/portainer/portainer/api/http/handler/roles" "github.com/portainer/portainer/api" @@ -84,15 +86,27 @@ type Server struct { func (server *Server) Start() error { proxyManagerParameters := &proxy.ManagerParams{ ResourceControlService: server.ResourceControlService, + UserService: server.UserService, TeamMembershipService: server.TeamMembershipService, SettingsService: server.SettingsService, RegistryService: server.RegistryService, DockerHubService: server.DockerHubService, SignatureService: server.SignatureService, ReverseTunnelService: server.ReverseTunnelService, + ExtensionService: server.ExtensionService, } proxyManager := proxy.NewManager(proxyManagerParameters) + authorizationServiceParameters := &portainer.AuthorizationServiceParameters{ + EndpointService: server.EndpointService, + EndpointGroupService: server.EndpointGroupService, + RegistryService: server.RegistryService, + RoleService: server.RoleService, + TeamMembershipService: server.TeamMembershipService, + UserService: server.UserService, + } + authorizationService := portainer.NewAuthorizationService(authorizationServiceParameters) + requestBouncerParameters := &security.RequestBouncerParams{ JWTService: server.JWTService, UserService: server.UserService, @@ -136,10 +150,12 @@ func (server *Server) Start() error { endpointHandler.JobService = server.JobService endpointHandler.ReverseTunnelService = server.ReverseTunnelService endpointHandler.SettingsService = server.SettingsService + endpointHandler.AuthorizationService = authorizationService var endpointGroupHandler = endpointgroups.NewHandler(requestBouncer) endpointGroupHandler.EndpointGroupService = server.EndpointGroupService endpointGroupHandler.EndpointService = server.EndpointService + endpointGroupHandler.AuthorizationService = authorizationService var endpointProxyHandler = endpointproxy.NewHandler(requestBouncer) endpointProxyHandler.EndpointService = server.EndpointService @@ -157,6 +173,7 @@ func (server *Server) Start() error { extensionHandler.EndpointGroupService = server.EndpointGroupService extensionHandler.EndpointService = server.EndpointService extensionHandler.RegistryService = server.RegistryService + extensionHandler.AuthorizationService = authorizationService var registryHandler = registries.NewHandler(requestBouncer) registryHandler.RegistryService = server.RegistryService @@ -182,6 +199,9 @@ func (server *Server) Start() error { settingsHandler.FileService = server.FileService settingsHandler.JobScheduler = server.JobScheduler settingsHandler.ScheduleService = server.ScheduleService + settingsHandler.RoleService = server.RoleService + settingsHandler.ExtensionService = server.ExtensionService + settingsHandler.AuthorizationService = authorizationService var stackHandler = stacks.NewHandler(requestBouncer) stackHandler.FileService = server.FileService @@ -193,6 +213,7 @@ func (server *Server) Start() error { stackHandler.GitService = server.GitService stackHandler.RegistryService = server.RegistryService stackHandler.DockerHubService = server.DockerHubService + stackHandler.SettingsService = server.SettingsService var tagHandler = tags.NewHandler(requestBouncer) tagHandler.TagService = server.TagService @@ -200,11 +221,16 @@ func (server *Server) Start() error { var teamHandler = teams.NewHandler(requestBouncer) teamHandler.TeamService = server.TeamService teamHandler.TeamMembershipService = server.TeamMembershipService + teamHandler.AuthorizationService = authorizationService var teamMembershipHandler = teammemberships.NewHandler(requestBouncer) teamMembershipHandler.TeamMembershipService = server.TeamMembershipService + teamMembershipHandler.AuthorizationService = authorizationService + var statusHandler = status.NewHandler(requestBouncer, server.Status) + var supportHandler = support.NewHandler(requestBouncer) + var templatesHandler = templates.NewHandler(requestBouncer) templatesHandler.TemplateService = server.TemplateService templatesHandler.SettingsService = server.SettingsService @@ -219,6 +245,7 @@ func (server *Server) Start() error { userHandler.CryptoService = server.CryptoService userHandler.ResourceControlService = server.ResourceControlService userHandler.SettingsService = server.SettingsService + userHandler.AuthorizationService = authorizationService var websocketHandler = websocket.NewHandler(requestBouncer) websocketHandler.EndpointService = server.EndpointService @@ -245,6 +272,7 @@ func (server *Server) Start() error { SettingsHandler: settingsHandler, StatusHandler: statusHandler, StackHandler: stackHandler, + SupportHandler: supportHandler, TagHandler: tagHandler, TeamHandler: teamHandler, TeamMembershipHandler: teamMembershipHandler, diff --git a/api/jwt/jwt.go b/api/jwt/jwt.go index 9e18a4bbb..316495724 100644 --- a/api/jwt/jwt.go +++ b/api/jwt/jwt.go @@ -16,11 +16,9 @@ type Service struct { } type claims struct { - UserID int `json:"id"` - Username string `json:"username"` - Role int `json:"role"` - EndpointAuthorizations portainer.EndpointAuthorizations `json:"endpointAuthorizations"` - PortainerAuthorizations portainer.Authorizations `json:"portainerAuthorizations"` + UserID int `json:"id"` + Username string `json:"username"` + Role int `json:"role"` jwt.StandardClaims } @@ -40,12 +38,10 @@ func NewService() (*Service, error) { func (service *Service) GenerateToken(data *portainer.TokenData) (string, error) { expireToken := time.Now().Add(time.Hour * 8).Unix() cl := claims{ - int(data.ID), - data.Username, - int(data.Role), - data.EndpointAuthorizations, - data.PortainerAuthorizations, - jwt.StandardClaims{ + UserID: int(data.ID), + Username: data.Username, + Role: int(data.Role), + StandardClaims: jwt.StandardClaims{ ExpiresAt: expireToken, }, } @@ -71,11 +67,9 @@ func (service *Service) ParseAndVerifyToken(token string) (*portainer.TokenData, if err == nil && parsedToken != nil { if cl, ok := parsedToken.Claims.(*claims); ok && parsedToken.Valid { tokenData := &portainer.TokenData{ - ID: portainer.UserID(cl.UserID), - Username: cl.Username, - Role: portainer.UserRole(cl.Role), - EndpointAuthorizations: cl.EndpointAuthorizations, - PortainerAuthorizations: cl.PortainerAuthorizations, + ID: portainer.UserID(cl.UserID), + Username: cl.Username, + Role: portainer.UserRole(cl.Role), } return tokenData, nil } diff --git a/api/portainer.go b/api/portainer.go index d7961929e..d7997b2d7 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -106,6 +106,7 @@ type ( OAuthSettings OAuthSettings `json:"OAuthSettings"` AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"` AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` + AllowVolumeBrowserForRegularUsers bool `json:"AllowVolumeBrowserForRegularUsers"` SnapshotInterval string `json:"SnapshotInterval"` TemplatesURL string `json:"TemplatesURL"` EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"` @@ -118,11 +119,12 @@ type ( // User represents a user account User struct { - ID UserID `json:"Id"` - Username string `json:"Username"` - Password string `json:"Password,omitempty"` - Role UserRole `json:"Role"` - PortainerAuthorizations Authorizations `json:"PortainerAuthorizations"` + ID UserID `json:"Id"` + Username string `json:"Username"` + Password string `json:"Password,omitempty"` + Role UserRole `json:"Role"` + PortainerAuthorizations Authorizations `json:"PortainerAuthorizations"` + EndpointAuthorizations EndpointAuthorizations `json:"EndpointAuthorizations"` } // UserID represents a user identifier @@ -160,11 +162,9 @@ type ( // TokenData represents the data embedded in a JWT token TokenData struct { - ID UserID - Username string - Role UserRole - EndpointAuthorizations EndpointAuthorizations - PortainerAuthorizations Authorizations + ID UserID + Username string + Role UserRole } // StackID represents a stack identifier (it must be composed of Name + "_" + SwarmID to create a unique identifier) @@ -638,7 +638,8 @@ type ( RoleService interface { Role(ID RoleID) (*Role, error) Roles() ([]Role, error) - CreateRole(set *Role) error + CreateRole(role *Role) error + UpdateRole(ID RoleID, role *Role) error } // TeamService represents a service for managing user data @@ -902,15 +903,19 @@ type ( const ( // APIVersion is the version number of the Portainer API - APIVersion = "1.22.0" + APIVersion = "1.22.1" // DBVersion is the version number of the Portainer database - DBVersion = 19 + DBVersion = 20 // AssetsServerURL represents the URL of the Portainer asset server AssetsServerURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com" // MessageOfTheDayURL represents the URL where Portainer MOTD message can be retrieved MessageOfTheDayURL = AssetsServerURL + "/motd.json" + // VersionCheckURL represents the URL used to retrieve the latest version of Portainer + VersionCheckURL = "https://api.github.com/repos/portainer/portainer/releases/latest" // ExtensionDefinitionsURL represents the URL where Portainer extension definitions can be retrieved - ExtensionDefinitionsURL = AssetsServerURL + "/extensions-1.22.0.json" + ExtensionDefinitionsURL = AssetsServerURL + "/extensions-1.22.1.json" + // SupportProductsURL represents the URL where Portainer support products can be retrieved + SupportProductsURL = AssetsServerURL + "/support.json" // PortainerAgentHeader represents the name of the header available in any agent response PortainerAgentHeader = "Portainer-Agent" // PortainerAgentEdgeIDHeader represent the name of the header containing the Edge ID associated to an agent/agent cluster diff --git a/api/swagger.yaml b/api/swagger.yaml index f7d87939a..54f437420 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -54,7 +54,7 @@ info: **NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8). - version: "1.22.0" + version: "1.22.1" title: "Portainer API" contact: email: "info@portainer.io" @@ -254,7 +254,7 @@ paths: - name: "EndpointType" in: "formData" type: "integer" - description: "Environment type. Value must be one of: 1 (Docker environment), 2 (Agent environment) or 3 (Azure environment)" + description: "Environment type. Value must be one of: 1 (Docker environment), 2 (Agent environment), 3 (Azure environment) or 4 (Edge agent environment)" required: true - name: "URL" in: "formData" @@ -3174,7 +3174,7 @@ definitions: description: "Is analytics enabled" Version: type: "string" - example: "1.22.0" + example: "1.22.1" description: "Portainer API version" PublicSettingsInspectResponse: type: "object" @@ -3920,6 +3920,10 @@ definitions: type: "boolean" example: true description: "Whether non-administrator users should be able to use privileged mode when creating containers" + EdgeAgentCheckinInterval: + type: "integer" + example: "30" + description: "Polling interval for Edge agent (in seconds)" EndpointGroupCreateRequest: type: "object" required: diff --git a/api/swagger_config.json b/api/swagger_config.json index 20bc14037..1f65abf52 100644 --- a/api/swagger_config.json +++ b/api/swagger_config.json @@ -1,5 +1,5 @@ { "packageName": "portainer", - "packageVersion": "1.22.0", + "packageVersion": "1.22.1", "projectName": "portainer" } diff --git a/app/__module.js b/app/__module.js index 881453c9a..9eaa6248d 100644 --- a/app/__module.js +++ b/app/__module.js @@ -1,4 +1,5 @@ import '../assets/css/app.css'; +import './libraries/isteven-angular-multiselect/isteven-multi-select.css'; import angular from 'angular'; import './agent/_module'; diff --git a/app/agent/components/files-datatable/files-datatable.html b/app/agent/components/files-datatable/files-datatable.html index f3d7ba564..0cda7d450 100644 --- a/app/agent/components/files-datatable/files-datatable.html +++ b/app/agent/components/files-datatable/files-datatable.html @@ -7,7 +7,7 @@
diff --git a/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html index ae7a459ce..54361570c 100644 --- a/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html +++ b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html @@ -17,7 +17,7 @@
diff --git a/app/constants.js b/app/constants.js index d741e312d..8cbcbb9e8 100644 --- a/app/constants.js +++ b/app/constants.js @@ -11,6 +11,7 @@ angular.module('portainer') .constant('API_ENDPOINT_SETTINGS', 'api/settings') .constant('API_ENDPOINT_STACKS', 'api/stacks') .constant('API_ENDPOINT_STATUS', 'api/status') +.constant('API_ENDPOINT_SUPPORT', 'api/support') .constant('API_ENDPOINT_USERS', 'api/users') .constant('API_ENDPOINT_TAGS', 'api/tags') .constant('API_ENDPOINT_TEAMS', 'api/teams') diff --git a/app/docker/components/datatables/configs-datatable/configsDatatable.html b/app/docker/components/datatables/configs-datatable/configsDatatable.html index 846fdbee4..31fd0cc67 100644 --- a/app/docker/components/datatables/configs-datatable/configsDatatable.html +++ b/app/docker/components/datatables/configs-datatable/configsDatatable.html @@ -55,7 +55,7 @@
diff --git a/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.html b/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.html index 5449c012d..37f3152b5 100644 --- a/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.html +++ b/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.html @@ -8,7 +8,7 @@
diff --git a/app/docker/components/datatables/containers-datatable/containersDatatable.html b/app/docker/components/datatables/containers-datatable/containersDatatable.html index 8e7ff7bb7..fb2a24cd8 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatable.html +++ b/app/docker/components/datatables/containers-datatable/containersDatatable.html @@ -136,7 +136,7 @@ >
diff --git a/app/docker/components/datatables/containers-datatable/containersDatatableController.js b/app/docker/components/datatables/containers-datatable/containersDatatableController.js index 929686a60..c95cc7d69 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatableController.js +++ b/app/docker/components/datatables/containers-datatable/containersDatatableController.js @@ -172,6 +172,7 @@ function ($scope, $controller, DatatableService, EndpointProvider) { this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -203,6 +204,5 @@ function ($scope, $controller, DatatableService, EndpointProvider) { this.columnVisibility = storedColumnVisibility; this.columnVisibility.state.open = false; } - this.state.orderBy = this.orderBy; }; }]); diff --git a/app/docker/components/datatables/events-datatable/eventsDatatable.html b/app/docker/components/datatables/events-datatable/eventsDatatable.html index f70cbb9e7..377d97f7d 100644 --- a/app/docker/components/datatables/events-datatable/eventsDatatable.html +++ b/app/docker/components/datatables/events-datatable/eventsDatatable.html @@ -8,7 +8,7 @@
diff --git a/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html b/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html index 2e0ce6ffe..599ce8669 100644 --- a/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html +++ b/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html @@ -16,7 +16,7 @@
diff --git a/app/docker/components/datatables/host-jobs-datatable/jobsDatatableController.js b/app/docker/components/datatables/host-jobs-datatable/jobsDatatableController.js index 4acc5472a..c95cd9ef3 100644 --- a/app/docker/components/datatables/host-jobs-datatable/jobsDatatableController.js +++ b/app/docker/components/datatables/host-jobs-datatable/jobsDatatableController.js @@ -108,6 +108,7 @@ angular.module('portainer.docker') this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -135,7 +136,6 @@ angular.module('portainer.docker') this.settings.open = false; } this.onSettingsRepeaterChange(); - this.state.orderBy = this.orderBy; }; } ]); diff --git a/app/docker/components/datatables/images-datatable/imagesDatatable.html b/app/docker/components/datatables/images-datatable/imagesDatatable.html index 8a536f2ae..0fd576f66 100644 --- a/app/docker/components/datatables/images-datatable/imagesDatatable.html +++ b/app/docker/components/datatables/images-datatable/imagesDatatable.html @@ -75,7 +75,7 @@
diff --git a/app/docker/components/datatables/images-datatable/imagesDatatableController.js b/app/docker/components/datatables/images-datatable/imagesDatatableController.js index 67bb932e2..55e96f36a 100644 --- a/app/docker/components/datatables/images-datatable/imagesDatatableController.js +++ b/app/docker/components/datatables/images-datatable/imagesDatatableController.js @@ -39,6 +39,7 @@ function ($scope, $controller, DatatableService) { this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -66,6 +67,5 @@ function ($scope, $controller, DatatableService) { } this.onSettingsRepeaterChange(); - this.state.orderBy = this.orderBy; }; }]); diff --git a/app/docker/components/datatables/macvlan-nodes-datatable/macvlanNodesDatatable.html b/app/docker/components/datatables/macvlan-nodes-datatable/macvlanNodesDatatable.html index 574aa2fe6..334ecc6ac 100644 --- a/app/docker/components/datatables/macvlan-nodes-datatable/macvlanNodesDatatable.html +++ b/app/docker/components/datatables/macvlan-nodes-datatable/macvlanNodesDatatable.html @@ -8,7 +8,7 @@
diff --git a/app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.html b/app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.html new file mode 100644 index 000000000..691569467 --- /dev/null +++ b/app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.html @@ -0,0 +1,27 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.js b/app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.js new file mode 100644 index 000000000..90dc60c57 --- /dev/null +++ b/app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.js @@ -0,0 +1,15 @@ +angular.module('portainer.docker') +.directive('networkRowContent', [function networkRowContent() { + var directive = { + templateUrl: './networkRowContent.html', + restrict: 'A', + transclude: true, + scope: { + item: '<', + parentCtrl: '<', + allowCheckbox: '<', + allowExpand: '<' + } + }; + return directive; +}]); diff --git a/app/docker/components/datatables/networks-datatable/networksDatatable.html b/app/docker/components/datatables/networks-datatable/networksDatatable.html index c2347d9cd..7dc2d199f 100644 --- a/app/docker/components/datatables/networks-datatable/networksDatatable.html +++ b/app/docker/components/datatables/networks-datatable/networksDatatable.html @@ -55,17 +55,22 @@
+ + + + + + + {{ item.Name | truncate:40 }} + {{ item.Name | truncate:40 }} +{{ item.StackName ? item.StackName : '-' }}{{ item.Scope }}{{ item.Driver }}{{ item.Attachable }}{{ item.Internal }}{{ item.IPAM.Driver }}{{ item.IPAM.Config[0].Subnet ? item.IPAM.Config[0].Subnet : '-' }}{{ item.IPAM.Config[0].Gateway ? item.IPAM.Config[0].Gateway : '-' }}{{ item.NodeName ? item.NodeName : '-' }} + + + {{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = 'administrators' }} + +
- + - - - - - - - - - - - - + + + diff --git a/app/docker/components/datatables/networks-datatable/networksDatatableController.js b/app/docker/components/datatables/networks-datatable/networksDatatableController.js index 1fcaec634..7550111b4 100644 --- a/app/docker/components/datatables/networks-datatable/networksDatatableController.js +++ b/app/docker/components/datatables/networks-datatable/networksDatatableController.js @@ -1,3 +1,5 @@ +import _ from 'lodash-es'; + angular.module('portainer.docker') .controller('NetworksDatatableController', ['$scope', '$controller', 'PREDEFINED_NETWORKS', 'DatatableService', function ($scope, $controller, PREDEFINED_NETWORKS, DatatableService) { @@ -8,6 +10,10 @@ angular.module('portainer.docker') return PREDEFINED_NETWORKS.includes(item.Name); }; + this.state = Object.assign(this.state, { + expandedItems: [] + }) + /** * Do not allow PREDEFINED_NETWORKS to be selected */ @@ -19,6 +25,7 @@ angular.module('portainer.docker') this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -45,7 +52,27 @@ angular.module('portainer.docker') this.settings.open = false; } this.onSettingsRepeaterChange(); - this.state.orderBy = this.orderBy; + }; + + this.expandItem = function(item, expanded) { + item.Expanded = expanded; + }; + + this.itemCanExpand = function(item) { + return item.Subs.length > 0; + } + + this.hasExpandableItems = function() { + return _.filter(this.state.filteredDataSet, (item) => this.itemCanExpand(item)).length; + }; + + this.expandAll = function() { + this.state.expandAll = !this.state.expandAll; + _.forEach(this.state.filteredDataSet, (item) => { + if (this.itemCanExpand(item)) { + this.expandItem(item, this.state.expandAll); + } + }); }; } ]); \ No newline at end of file diff --git a/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.html b/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.html index ea87ad303..874b2facf 100644 --- a/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.html +++ b/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.html @@ -8,7 +8,7 @@
+ + + + + Name @@ -145,30 +150,11 @@
- - - - - {{ item.Name | truncate:40 }} - {{ item.Name | truncate:40 }} - {{ item.StackName ? item.StackName : '-' }}{{ item.Scope }}{{ item.Driver }}{{ item.Attachable }}{{ item.Internal }}{{ item.IPAM.Driver }}{{ item.IPAM.Config[0].Subnet ? item.IPAM.Config[0].Subnet : '-' }}{{ item.IPAM.Config[0].Gateway ? item.IPAM.Config[0].Gateway : '-' }}{{ item.NodeName ? item.NodeName : '-' }} - - - {{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = 'administrators' }} - -
Loading...
diff --git a/app/docker/components/datatables/nodes-datatable/nodesDatatable.html b/app/docker/components/datatables/nodes-datatable/nodesDatatable.html index 5e28c0b41..b49b20f29 100644 --- a/app/docker/components/datatables/nodes-datatable/nodesDatatable.html +++ b/app/docker/components/datatables/nodes-datatable/nodesDatatable.html @@ -46,7 +46,7 @@
diff --git a/app/docker/components/datatables/secrets-datatable/secretsDatatable.html b/app/docker/components/datatables/secrets-datatable/secretsDatatable.html index fb82e5c31..4f9916f53 100644 --- a/app/docker/components/datatables/secrets-datatable/secretsDatatable.html +++ b/app/docker/components/datatables/secrets-datatable/secretsDatatable.html @@ -55,7 +55,7 @@
diff --git a/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatableController.js b/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatableController.js index 23f5c8e72..7e6afa667 100644 --- a/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatableController.js +++ b/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatableController.js @@ -61,6 +61,7 @@ angular.module('portainer.docker') this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -87,7 +88,6 @@ angular.module('portainer.docker') this.settings.open = false; } this.onSettingsRepeaterChange(); - this.state.orderBy = this.orderBy; }; } ]); diff --git a/app/docker/components/datatables/services-datatable/servicesDatatable.html b/app/docker/components/datatables/services-datatable/servicesDatatable.html index 9b3b86805..0952d091f 100644 --- a/app/docker/components/datatables/services-datatable/servicesDatatable.html +++ b/app/docker/components/datatables/services-datatable/servicesDatatable.html @@ -52,7 +52,7 @@ >
diff --git a/app/docker/components/datatables/services-datatable/servicesDatatableController.js b/app/docker/components/datatables/services-datatable/servicesDatatableController.js index 13a4314e1..9289733e8 100644 --- a/app/docker/components/datatables/services-datatable/servicesDatatableController.js +++ b/app/docker/components/datatables/services-datatable/servicesDatatableController.js @@ -68,6 +68,7 @@ function ($scope, $controller, DatatableService, EndpointProvider) { this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -99,6 +100,5 @@ function ($scope, $controller, DatatableService, EndpointProvider) { this.settings.open = false; } this.onSettingsRepeaterChange(); - this.state.orderBy = this.orderBy; }; }]); diff --git a/app/docker/components/datatables/tasks-datatable/tasksDatatable.html b/app/docker/components/datatables/tasks-datatable/tasksDatatable.html index 391852180..e71b8b805 100644 --- a/app/docker/components/datatables/tasks-datatable/tasksDatatable.html +++ b/app/docker/components/datatables/tasks-datatable/tasksDatatable.html @@ -8,7 +8,7 @@
diff --git a/app/docker/components/datatables/tasks-datatable/tasksDatatableController.js b/app/docker/components/datatables/tasks-datatable/tasksDatatableController.js index 0d8c4d1f2..ab2456b27 100644 --- a/app/docker/components/datatables/tasks-datatable/tasksDatatableController.js +++ b/app/docker/components/datatables/tasks-datatable/tasksDatatableController.js @@ -16,6 +16,7 @@ function ($scope, $controller, DatatableService) { this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -42,6 +43,5 @@ function ($scope, $controller, DatatableService) { this.settings.open = false; } this.onSettingsRepeaterChange(); - this.state.orderBy = this.orderBy; }; }]); diff --git a/app/docker/components/datatables/volumes-datatable/volumesDatatable.html b/app/docker/components/datatables/volumes-datatable/volumesDatatable.html index b1c3ff958..1266ea5be 100644 --- a/app/docker/components/datatables/volumes-datatable/volumesDatatable.html +++ b/app/docker/components/datatables/volumes-datatable/volumesDatatable.html @@ -55,7 +55,7 @@
@@ -149,9 +149,11 @@ {{ item.Id | truncate:40 }}{{ item.Id | truncate:40 }} - - browse - + + + browse + + Unused diff --git a/app/docker/components/datatables/volumes-datatable/volumesDatatableController.js b/app/docker/components/datatables/volumes-datatable/volumesDatatableController.js index 68cc70055..5a49ea70d 100644 --- a/app/docker/components/datatables/volumes-datatable/volumesDatatableController.js +++ b/app/docker/components/datatables/volumes-datatable/volumesDatatableController.js @@ -39,6 +39,7 @@ function ($scope, $controller, DatatableService) { this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -65,6 +66,5 @@ function ($scope, $controller, DatatableService) { this.settings.open = false; } this.onSettingsRepeaterChange(); - this.state.orderBy = this.orderBy; }; }]); diff --git a/app/docker/rest/service.js b/app/docker/rest/service.js index dca148145..81a8eca7e 100644 --- a/app/docker/rest/service.js +++ b/app/docker/rest/service.js @@ -14,19 +14,13 @@ function ServiceFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, Htt method: 'POST', params: {action: 'create'}, headers: { 'X-Registry-Auth': HttpRequestHelper.registryAuthenticationHeader, - // TODO: This is a temporary work-around that allows us to leverage digest pinning on - // the Docker daemon side. It has been moved client-side since Docker API version > 1.29. - // We should introduce digest pinning in Portainer as well. 'version': '1.29' }, ignoreLoadingBar: true }, update: { - method: 'POST', params: { id: '@id', action: 'update', version: '@version' }, + method: 'POST', params: { id: '@id', action: 'update', version: '@version', rollback: '@rollback' }, headers: { - // TODO: This is a temporary work-around that allows us to leverage digest pinning on - // the Docker daemon side. It has been moved client-side since Docker API version > 1.29. - // We should introduce digest pinning in Portainer as well. 'version': '1.29' } }, diff --git a/app/docker/services/serviceService.js b/app/docker/services/serviceService.js index 0696f09ff..d3db5f048 100644 --- a/app/docker/services/serviceService.js +++ b/app/docker/services/serviceService.js @@ -58,8 +58,17 @@ function ServiceServiceFactory($q, Service, ServiceHelper, TaskService, Resource return deferred.promise; }; - service.update = function(service, config) { - return Service.update({ id: service.Id, version: service.Version }, config).$promise; + service.update = function(serv, config, rollback) { + return service.service(serv.Id).then((data) => { + const params = { + id: serv.Id, + version: data.Version + }; + if (rollback) { + params.rollback = rollback + } + return Service.update(params, config).$promise; + }); }; service.logs = function(id, stdout, stderr, timestamps, since, tail) { diff --git a/app/docker/views/containers/create/createContainerController.js b/app/docker/views/containers/create/createContainerController.js index c91e54523..73fb8e17b 100644 --- a/app/docker/views/containers/create/createContainerController.js +++ b/app/docker/views/containers/create/createContainerController.js @@ -402,16 +402,13 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai } } $scope.config.NetworkingConfig.EndpointsConfig[$scope.config.HostConfig.NetworkMode] = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode]; - // Mac Address - if(Object.keys(d.NetworkSettings.Networks).length) { + if(Object.keys(d.NetworkSettings.Networks).length > 1) { var firstNetwork = d.NetworkSettings.Networks[Object.keys(d.NetworkSettings.Networks)[0]]; - $scope.formValues.MacAddress = firstNetwork.MacAddress; $scope.config.NetworkingConfig.EndpointsConfig[$scope.config.HostConfig.NetworkMode] = firstNetwork; $scope.extraNetworks = angular.copy(d.NetworkSettings.Networks); delete $scope.extraNetworks[Object.keys(d.NetworkSettings.Networks)[0]]; - } else { - $scope.formValues.MacAddress = ''; } + $scope.formValues.MacAddress = d.Config.MacAddress; // ExtraHosts if ($scope.config.HostConfig.ExtraHosts) { diff --git a/app/docker/views/containers/edit/container.html b/app/docker/views/containers/edit/container.html index 41486b432..ea1e5fde7 100644 --- a/app/docker/views/containers/edit/container.html +++ b/app/docker/views/containers/edit/container.html @@ -201,6 +201,10 @@ + + + +
{{ item.StackName ? item.StackName : '-' }}CMD {{ container.Config.Cmd|command }}
ENTRYPOINT{{ container.Config.Entrypoint ? (container.Config.Entrypoint|command) : "null" }}
ENV diff --git a/app/docker/views/networks/networksController.js b/app/docker/views/networks/networksController.js index f2f79b395..6beb8da73 100644 --- a/app/docker/views/networks/networksController.js +++ b/app/docker/views/networks/networksController.js @@ -1,6 +1,8 @@ +import _ from 'lodash-es'; + angular.module('portainer.docker') -.controller('NetworksController', ['$scope', '$state', 'NetworkService', 'Notifications', 'HttpRequestHelper', 'EndpointProvider', -function ($scope, $state, NetworkService, Notifications, HttpRequestHelper, EndpointProvider) { +.controller('NetworksController', ['$q', '$scope', '$state', 'NetworkService', 'Notifications', 'HttpRequestHelper', 'EndpointProvider', 'AgentService', +function ($q, $scope, $state, NetworkService, Notifications, HttpRequestHelper, EndpointProvider, AgentService) { $scope.removeAction = function (selectedItems) { var actionCount = selectedItems.length; @@ -28,13 +30,43 @@ function ($scope, $state, NetworkService, Notifications, HttpRequestHelper, Endp $scope.getNetworks = getNetworks; + function groupSwarmNetworksManagerNodesFirst(networks, agents) { + const getRole = (item) => _.find(agents, (agent) => agent.NodeName === item.NodeName).NodeRole; + + const nonSwarmNetworks = _.remove(networks, (item) => item.Scope !== 'swarm') + const grouped = _.toArray(_.groupBy(networks, (item) => item.Id)); + const sorted = _.map(grouped, (arr) => _.sortBy(arr, (item) => getRole(item))); + const arr = _.map(sorted, (a) => { + const item = a[0]; + for (let i = 1; i < a.length; i++) { + item.Subs.push(a[i]); + } + return item; + }); + const res = _.concat(arr, ...nonSwarmNetworks); + return res; + } + function getNetworks() { - NetworkService.networks(true, true, true) - .then(function success(data) { - $scope.networks = data; + const req = { + networks: NetworkService.networks(true, true, true) + }; + + if ($scope.applicationState.endpoint.mode.agentProxy && $scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') { + req.agents = AgentService.agents(); + } + + $q.all(req) + .then((data) => { $scope.offlineMode = EndpointProvider.offlineMode(); + const networks = _.forEach(data.networks, (item) => item.Subs = []); + if ($scope.applicationState.endpoint.mode.agentProxy && $scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') { + $scope.networks = groupSwarmNetworksManagerNodesFirst(data.networks, data.agents); + } else { + $scope.networks = networks; + } }) - .catch(function error(err) { + .catch((err) => { $scope.networks = []; Notifications.error('Failure', err, 'Unable to retrieve networks'); }); diff --git a/app/docker/views/services/create/createservice.html b/app/docker/views/services/create/createservice.html index ceca97a55..9c0e98e13 100644 --- a/app/docker/views/services/create/createservice.html +++ b/app/docker/views/services/create/createservice.html @@ -315,9 +315,8 @@
volume - -
diff --git a/app/docker/views/services/edit/includes/mounts.html b/app/docker/views/services/edit/includes/mounts.html index a71c4eb9e..fc1582dc9 100644 --- a/app/docker/views/services/edit/includes/mounts.html +++ b/app/docker/views/services/edit/includes/mounts.html @@ -30,7 +30,7 @@
- diff --git a/app/docker/views/services/edit/service.html b/app/docker/views/services/edit/service.html index e4f7ef22a..13ef42d57 100644 --- a/app/docker/views/services/edit/service.html +++ b/app/docker/views/services/edit/service.html @@ -91,11 +91,18 @@
+

+ Note: you can only rollback one level of changes. Clicking the rollback button without making a new change will undo your previous rollback +

Service logs +

- Filters + Options
@@ -48,6 +48,14 @@
+
+ + +
@@ -95,6 +103,17 @@
CPU: {{ node.CPUs / 1000000000 }}
Memory: {{ node.Memory|humansize: 2 }}
{{ node.Status }}
+
+
Labels
+
+ + {{ label.key }} + + + = {{ label.value }} + +
+
diff --git a/app/docker/views/volumes/volumes.html b/app/docker/views/volumes/volumes.html index c31b7ac3b..425d93a75 100644 --- a/app/docker/views/volumes/volumes.html +++ b/app/docker/views/volumes/volumes.html @@ -16,7 +16,7 @@ remove-action="removeAction" show-ownership-column="applicationState.application.authentication" show-host-column="applicationState.endpoint.mode.agentProxy && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'" - show-browse-action="applicationState.endpoint.mode.agentProxy" + show-browse-action="showBrowseAction" offline-mode="offlineMode" refresh-callback="getVolumes" > diff --git a/app/docker/views/volumes/volumesController.js b/app/docker/views/volumes/volumesController.js index 0cad1b914..6cde69eb2 100644 --- a/app/docker/views/volumes/volumesController.js +++ b/app/docker/views/volumes/volumesController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('VolumesController', ['$q', '$scope', '$state', 'VolumeService', 'ServiceService', 'VolumeHelper', 'Notifications', 'HttpRequestHelper', 'EndpointProvider', -function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notifications, HttpRequestHelper, EndpointProvider) { +.controller('VolumesController', ['$q', '$scope', '$state', 'VolumeService', 'ServiceService', 'VolumeHelper', 'Notifications', 'HttpRequestHelper', 'EndpointProvider', 'Authentication', 'ExtensionService', +function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notifications, HttpRequestHelper, EndpointProvider, Authentication, ExtensionService) { $scope.removeAction = function (selectedItems) { var actionCount = selectedItems.length; @@ -56,6 +56,18 @@ function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notif function initView() { getVolumes(); + + $scope.showBrowseAction = $scope.applicationState.endpoint.mode.agentProxy; + + ExtensionService.extensionEnabled(ExtensionService.EXTENSIONS.RBAC) + .then(function success(extensionEnabled) { + if (!extensionEnabled) { + var isAdmin = Authentication.isAdmin(); + if (!$scope.applicationState.application.enableVolumeBrowserForNonAdminUsers && !isAdmin) { + $scope.showBrowseAction = false; + } + } + }); } initView(); diff --git a/app/extensions/rbac/components/access-viewer/datatable/accessViewerDatatable.html b/app/extensions/rbac/components/access-viewer/datatable/accessViewerDatatable.html index 47f38fcfe..951388c3f 100644 --- a/app/extensions/rbac/components/access-viewer/datatable/accessViewerDatatable.html +++ b/app/extensions/rbac/components/access-viewer/datatable/accessViewerDatatable.html @@ -2,7 +2,7 @@
diff --git a/app/extensions/rbac/components/roles-datatable/rolesDatatable.html b/app/extensions/rbac/components/roles-datatable/rolesDatatable.html index 8da5671c4..ccbef5168 100644 --- a/app/extensions/rbac/components/roles-datatable/rolesDatatable.html +++ b/app/extensions/rbac/components/roles-datatable/rolesDatatable.html @@ -8,7 +8,7 @@
diff --git a/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html b/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html index 7ea3164b1..8f60ae379 100644 --- a/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html +++ b/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html @@ -8,7 +8,7 @@
diff --git a/app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.html b/app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.html index 9a02df286..0da259f37 100644 --- a/app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.html +++ b/app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.html @@ -14,7 +14,7 @@
diff --git a/app/integrations/storidge/components/cluster-events-datatable/storidgeClusterEventsDatatable.html b/app/integrations/storidge/components/cluster-events-datatable/storidgeClusterEventsDatatable.html index 2094f938b..905cc2779 100644 --- a/app/integrations/storidge/components/cluster-events-datatable/storidgeClusterEventsDatatable.html +++ b/app/integrations/storidge/components/cluster-events-datatable/storidgeClusterEventsDatatable.html @@ -8,7 +8,7 @@
diff --git a/app/integrations/storidge/components/drives-datatable/storidgeDrivesDatatable.html b/app/integrations/storidge/components/drives-datatable/storidgeDrivesDatatable.html index ed46b9a5d..2c47c1b72 100644 --- a/app/integrations/storidge/components/drives-datatable/storidgeDrivesDatatable.html +++ b/app/integrations/storidge/components/drives-datatable/storidgeDrivesDatatable.html @@ -13,7 +13,7 @@
diff --git a/app/integrations/storidge/components/drives-datatable/storidgeDrivesDatatableController.js b/app/integrations/storidge/components/drives-datatable/storidgeDrivesDatatableController.js index 68348427b..5e0b6c33d 100644 --- a/app/integrations/storidge/components/drives-datatable/storidgeDrivesDatatableController.js +++ b/app/integrations/storidge/components/drives-datatable/storidgeDrivesDatatableController.js @@ -11,6 +11,7 @@ angular.module('portainer.docker') this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -37,7 +38,6 @@ angular.module('portainer.docker') this.settings.open = false; } this.onSettingsRepeaterChange(); - this.state.orderBy = this.orderBy; }; } ]); \ No newline at end of file diff --git a/app/integrations/storidge/components/nodes-datatable/storidgeNodesDatatable.html b/app/integrations/storidge/components/nodes-datatable/storidgeNodesDatatable.html index 4a8857844..f4453c640 100644 --- a/app/integrations/storidge/components/nodes-datatable/storidgeNodesDatatable.html +++ b/app/integrations/storidge/components/nodes-datatable/storidgeNodesDatatable.html @@ -27,7 +27,7 @@
diff --git a/app/integrations/storidge/components/nodes-datatable/storidgeNodesDatatableController.js b/app/integrations/storidge/components/nodes-datatable/storidgeNodesDatatableController.js index 61e8fec46..7a1d263af 100644 --- a/app/integrations/storidge/components/nodes-datatable/storidgeNodesDatatableController.js +++ b/app/integrations/storidge/components/nodes-datatable/storidgeNodesDatatableController.js @@ -25,6 +25,7 @@ function($scope, $controller, clipboard, Notifications, StoridgeNodeService, Dat this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -51,6 +52,5 @@ function($scope, $controller, clipboard, Notifications, StoridgeNodeService, Dat this.settings.open = false; } this.onSettingsRepeaterChange(); - this.state.orderBy = this.orderBy; }; }]); diff --git a/app/integrations/storidge/components/profiles-datatable/storidgeProfilesDatatable.html b/app/integrations/storidge/components/profiles-datatable/storidgeProfilesDatatable.html index 36147404f..c1a074bd3 100644 --- a/app/integrations/storidge/components/profiles-datatable/storidgeProfilesDatatable.html +++ b/app/integrations/storidge/components/profiles-datatable/storidgeProfilesDatatable.html @@ -14,7 +14,7 @@
diff --git a/app/integrations/storidge/components/snapshots-datatable/storidgeSnapshotsDatatable.html b/app/integrations/storidge/components/snapshots-datatable/storidgeSnapshotsDatatable.html index 915e07280..78696c88b 100644 --- a/app/integrations/storidge/components/snapshots-datatable/storidgeSnapshotsDatatable.html +++ b/app/integrations/storidge/components/snapshots-datatable/storidgeSnapshotsDatatable.html @@ -14,7 +14,7 @@
diff --git a/app/libraries/isteven-angular-multiselect/.npmignore b/app/libraries/isteven-angular-multiselect/.npmignore new file mode 100644 index 000000000..84a23b94d --- /dev/null +++ b/app/libraries/isteven-angular-multiselect/.npmignore @@ -0,0 +1,8 @@ +.git +.gitignore +bower.json +CHANGELOG.md +package.json +README.md +screenshot.png +/doc diff --git a/app/libraries/isteven-angular-multiselect/LICENSE.txt b/app/libraries/isteven-angular-multiselect/LICENSE.txt new file mode 100644 index 000000000..6e524fa92 --- /dev/null +++ b/app/libraries/isteven-angular-multiselect/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014-2015 Ignatius Steven (https://github.com/isteven) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app/libraries/isteven-angular-multiselect/README.md b/app/libraries/isteven-angular-multiselect/README.md new file mode 100644 index 000000000..9c6255bcf --- /dev/null +++ b/app/libraries/isteven-angular-multiselect/README.md @@ -0,0 +1,50 @@ +# AngularJS MultiSelect +Pure AngularJS directive which creates a dropdown button with multiple or single selections. +Doesn't require jQuery and works well with other Javascript libraries. + +![Screenshot](https://raw.githubusercontent.com/isteven/angular-multi-select/master/screenshot.png) + +### Demo & How To +Go to http://isteven.github.io/angular-multi-select + +### Current Version +4.0.0 + +### Change Log +See CHANGELOG.md. +For those who's upgrading from version 2.x.x, do note that this version is not backward-compatible. Please read the manual +thoroughly and update your code accordingly. + +### Bug Reporting +Please follow these steps: + +1. **READ THE MANUAL AGAIN**. You might have missed something. This includes the MINIMUM ANGULARJS VERSION and the SUPPORTED BROWSERS. +2. The next step is to search in Github's issue section first. There might already be an answer for similar issue. Do check both open and closed issues. +3. If there's no previous issue found, then please create a new issue in https://github.com/isteven/angular-multi-select/issues. +4. Please **replicate the problem in JSFiddle or Plunker** (or any other online JS collaboration tool), and include the URL in the issue you are creating. +5. When you're done, please close the issue you've created. + +### Licence +Released under the MIT license: + +The MIT License (MIT) + +Copyright (c) 2014-2015 Ignatius Steven (https://github.com/isteven) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app/libraries/isteven-angular-multiselect/isteven-multi-select.css b/app/libraries/isteven-angular-multiselect/isteven-multi-select.css new file mode 100644 index 000000000..44dfc95f5 --- /dev/null +++ b/app/libraries/isteven-angular-multiselect/isteven-multi-select.css @@ -0,0 +1,299 @@ +/* + * Don't modify things marked with ! - unless you know what you're doing + */ + +/* ! vertical layout */ +.multiSelect .vertical { + float: none; +} + +/* ! horizontal layout */ +.multiSelect .horizontal:not(.multiSelectGroup) { + float: left; +} + +/* ! create a "row" */ +.multiSelect .line { + padding: 2px 0px 4px 0px; + max-height: 30px; + overflow: hidden; + box-sizing: content-box; +} + +/* ! create a "column" */ +.multiSelect .acol { + display: inline-block; + min-width: 12px; +} + +/* ! */ +.multiSelect .inlineBlock { + display: inline-block; +} + +/* the multiselect button */ +.multiSelect > button { + display: inline-block; + position: relative; + text-align: center; + cursor: pointer; + border: 1px solid #c6c6c6; + padding: 1px 8px 1px 8px; + font-size: 14px; + min-height : 38px !important; + border-radius: 4px; + color: #555; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + -o-user-select: none; + user-select: none; + white-space:normal; + background-color: #fff; + background-image: linear-gradient(#fff, #f7f7f7); +} + +/* button: hover */ +.multiSelect > button:hover { + background-image: linear-gradient(#fff, #e9e9e9); +} + +/* button: disabled */ +.multiSelect > button:disabled { + background-image: linear-gradient(#fff, #fff); + border: 1px solid #ddd; + color: #999; +} + +/* button: clicked */ +.multiSelect .buttonClicked { + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15) inset, 0 1px 2px rgba(0, 0, 0, 0.05); +} + +/* labels on the button */ +.multiSelect .buttonLabel { + display: inline-block; + padding: 5px 0px 5px 0px; +} + +/* downward pointing arrow */ +.multiSelect .caret { + display: inline-block; + width: 0; + height: 0; + margin: 0px 0px 1px 12px !important; + vertical-align: middle; + border-top: 4px solid #333; + border-right: 4px solid transparent; + border-left: 4px solid transparent; + border-bottom: 0 dotted; +} + +/* the main checkboxes and helper layer */ +.multiSelect .checkboxLayer { + background-color: #fff; + position: absolute; + z-index: 999; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 4px; + -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); + min-width:278px; + display: none !important; +} + +/* container of helper elements */ +.multiSelect .helperContainer { + border-bottom: 1px solid #ddd; + padding: 8px 8px 0px 8px; +} + +/* helper buttons (select all, none, reset); */ +.multiSelect .helperButton { + display: inline; + text-align: center; + cursor: pointer; + border: 1px solid #ccc; + height: 26px; + font-size: 13px; + border-radius: 2px; + color: #666; + background-color: #f1f1f1; + line-height: 1.6; + margin: 0px 0px 8px 0px; +} + +.multiSelect .helperButton.reset{ + float: right; +} + +.multiSelect .helperButton:not( .reset ) { + margin-right: 4px; +} + +/* clear button */ +.multiSelect .clearButton { + position: absolute; + display: inline; + text-align: center; + cursor: pointer; + border: 1px solid #ccc; + height: 22px; + width: 22px; + font-size: 13px; + border-radius: 2px; + color: #666; + background-color: #f1f1f1; + line-height: 1.4; + right : 2px; + top: 4px; +} + +/* filter */ +.multiSelect .inputFilter { + border-radius: 2px; + border: 1px solid #ccc; + height: 26px; + font-size: 14px; + width:100%; + padding-left:7px; + -webkit-box-sizing: border-box; /* Safari/Chrome, other WebKit */ + -moz-box-sizing: border-box; /* Firefox, other Gecko */ + box-sizing: border-box; /* Opera/IE 8+ */ + color: #888; + margin: 0px 0px 8px 0px; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); +} + +/* helper elements on hover & focus */ +.multiSelect .clearButton:hover, +.multiSelect .helperButton:hover { + border: 1px solid #ccc; + color: #999; + background-color: #f4f4f4; +} +.multiSelect .helperButton:disabled { + color: #ccc; + border: 1px solid #ddd; +} + +.multiSelect .clearButton:focus, +.multiSelect .helperButton:focus, +.multiSelect .inputFilter:focus { + border: 1px solid #66AFE9 !important; + outline: 0; + -webkit-box-shadow: inset 0 0 1px rgba(0,0,0,.065), 0 0 5px rgba(102, 175, 233, .6) !important; + box-shadow: inset 0 0 1px rgba(0,0,0,.065), 0 0 5px rgba(102, 175, 233, .6) !important; +} + +/* container of multi select items */ +.multiSelect .checkBoxContainer { + display: block; + padding: 8px; + overflow: hidden; +} + +/* ! to show / hide the checkbox layer above */ +.multiSelect .show { + display: block !important; +} + +/* item labels */ +.multiSelect .multiSelectItem { + display: block; + padding: 3px; + color: #444; + white-space: nowrap; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + -o-user-select: none; + user-select: none; + border: 1px solid transparent; + position: relative; + min-width:278px; + min-height: 32px; +} + +/* Styling on selected items */ +.multiSelect .multiSelectItem:not(.multiSelectGroup).selected +{ + background-image: linear-gradient( #e9e9e9, #f1f1f1 ); + color: #555; + cursor: pointer; + border-top: 1px solid #e4e4e4; + border-left: 1px solid #e4e4e4; + border-right: 1px solid #d9d9d9; +} + +.multiSelect .multiSelectItem .acol label { + display: inline-block; + padding-right: 30px; + margin: 0px; + font-weight: normal; + line-height: normal; +} + +/* item labels focus on mouse hover */ +.multiSelect .multiSelectItem:hover, +.multiSelect .multiSelectGroup:hover { + background-image: linear-gradient( #c1c1c1, #999 ) !important; + color: #fff !important; + cursor: pointer; + border: 1px solid #ccc !important; +} + +/* item labels focus using keyboard */ +.multiSelect .multiSelectFocus { + background-image: linear-gradient( #c1c1c1, #999 ) !important; + color: #fff !important; + cursor: pointer; + border: 1px solid #ccc !important; +} + +/* change mouse pointer into the pointing finger */ +.multiSelect .multiSelectItem span:hover, +.multiSelect .multiSelectGroup span:hover +{ + cursor: pointer; +} + +/* ! group labels */ +.multiSelect .multiSelectGroup { + display: block; + clear: both; +} + +/* right-align the tick mark (✔) */ +.multiSelect .tickMark { + display:inline-block; + position: absolute; + right: 10px; + top: 7px; + font-size: 10px; +} + +/* hide the original HTML checkbox away */ +.multiSelect .checkbox { + color: #ddd !important; + position: absolute; + left: -9999px; + cursor: pointer; +} + +/* checkboxes currently disabled */ +.multiSelect .disabled, +.multiSelect .disabled:hover, +.multiSelect .disabled label input:hover ~ span { + color: #c4c4c4 !important; + cursor: not-allowed !important; +} + +/* If you use images in button / checkbox label, you might want to change the image style here. */ +.multiSelect img { + vertical-align: middle; + margin-bottom:0px; + max-height: 22px; + max-width:22px; +} diff --git a/app/libraries/isteven-angular-multiselect/isteven-multi-select.js b/app/libraries/isteven-angular-multiselect/isteven-multi-select.js new file mode 100644 index 000000000..02b136aa3 --- /dev/null +++ b/app/libraries/isteven-angular-multiselect/isteven-multi-select.js @@ -0,0 +1,1127 @@ +/* + * Angular JS Multi Select + * Creates a dropdown-like button with checkboxes. + * + * Project started on: Tue, 14 Jan 2014 - 5:18:02 PM + * Current version: 4.0.0 + * + * Released under the MIT License + * -------------------------------------------------------------------------------- + * The MIT License (MIT) + * + * Copyright (c) 2014 Ignatius Steven (https://github.com/isteven) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * -------------------------------------------------------------------------------- + */ + +'use strict' + +angular.module( 'isteven-multi-select', ['ng'] ).directive( 'istevenMultiSelect' , [ '$sce', '$timeout', '$sanitize', function ( $sce, $timeout, $sanitize) { + return { + restrict: + 'AE', + + scope: + { + // models + inputModel : '=', + outputModel : '=', + + // settings based on attribute + isDisabled : '=', + + // callbacks + onClear : '&', + onClose : '&', + onSearchChange : '&', + onItemClick : '&', + onOpen : '&', + onReset : '&', + onSelectAll : '&', + onSelectNone : '&', + + // i18n + translation : '=' + }, + + /* + * The rest are attributes. They don't need to be parsed / binded, so we can safely access them by value. + * - buttonLabel, directiveId, helperElements, itemLabel, maxLabels, orientation, selectionMode, minSearchLength, + * tickProperty, disableProperty, groupProperty, searchProperty, maxHeight, outputProperties + */ + + templateUrl: + 'isteven-multi-select.htm', + + link: function ( $scope, element, attrs ) { + + $scope.backUp = []; + $scope.varButtonLabel = ''; + $scope.spacingProperty = ''; + $scope.indexProperty = ''; + $scope.orientationH = false; + $scope.orientationV = true; + $scope.filteredModel = []; + $scope.inputLabel = { labelFilter: '' }; + $scope.tabIndex = 0; + $scope.lang = {}; + $scope.helperStatus = { + all : true, + none : true, + reset : true, + filter : true + }; + + var + prevTabIndex = 0, + helperItems = [], + helperItemsLength = 0, + checkBoxLayer = '', + // scrolled = false, + // selectedItems = [], + formElements = [], + vMinSearchLength = 0, + clickedItem = null + + // v3.0.0 + // clear button clicked + $scope.clearClicked = function( e ) { + $scope.inputLabel.labelFilter = ''; + $scope.updateFilter(); + $scope.select( 'clear', e ); + } + + // A little hack so that AngularJS ng-repeat can loop using start and end index like a normal loop + // http://stackoverflow.com/questions/16824853/way-to-ng-repeat-defined-number-of-times-instead-of-repeating-over-array + $scope.numberToArray = function( num ) { + return new Array( num ); + } + + // Call this function when user type on the filter field + $scope.searchChanged = function() { + if ( $scope.inputLabel.labelFilter.length < vMinSearchLength && $scope.inputLabel.labelFilter.length > 0 ) { + return false; + } + $scope.updateFilter(); + } + + $scope.updateFilter = function() + { + // we check by looping from end of input-model + $scope.filteredModel = []; + var i = 0; + + if ( typeof $scope.inputModel === 'undefined' ) { + return false; + } + + for( i = $scope.inputModel.length - 1; i >= 0; i-- ) { + + // if it's group end, we push it to filteredModel[]; + if ( typeof $scope.inputModel[ i ][ attrs.groupProperty ] !== 'undefined' && $scope.inputModel[ i ][ attrs.groupProperty ] === false ) { + $scope.filteredModel.push( $scope.inputModel[ i ] ); + } + + // if it's data + var gotData = false; + if ( typeof $scope.inputModel[ i ][ attrs.groupProperty ] === 'undefined' ) { + + // If we set the search-key attribute, we use this loop. + if ( typeof attrs.searchProperty !== 'undefined' && attrs.searchProperty !== '' ) { + + for (const key in $scope.inputModel[ i ] ) { + if ( + typeof $scope.inputModel[ i ][ key ] !== 'boolean' + && String( $scope.inputModel[ i ][ key ] ).toUpperCase().indexOf( $scope.inputLabel.labelFilter.toUpperCase() ) >= 0 + && attrs.searchProperty.indexOf( key ) > -1 + ) { + gotData = true; + break; + } + } + } + // if there's no search-key attribute, we use this one. Much better on performance. + else { + for ( const key in $scope.inputModel[ i ] ) { + if ( + typeof $scope.inputModel[ i ][ key ] !== 'boolean' + && String( $scope.inputModel[ i ][ key ] ).toUpperCase().indexOf( $scope.inputLabel.labelFilter.toUpperCase() ) >= 0 + ) { + gotData = true; + break; + } + } + } + + if ( gotData === true ) { + // push + $scope.filteredModel.push( $scope.inputModel[ i ] ); + } + } + + // if it's group start + if ( typeof $scope.inputModel[ i ][ attrs.groupProperty ] !== 'undefined' && $scope.inputModel[ i ][ attrs.groupProperty ] === true ) { + + if ( typeof $scope.filteredModel[ $scope.filteredModel.length - 1 ][ attrs.groupProperty ] !== 'undefined' + && $scope.filteredModel[ $scope.filteredModel.length - 1 ][ attrs.groupProperty ] === false ) { + $scope.filteredModel.pop(); + } + else { + $scope.filteredModel.push( $scope.inputModel[ i ] ); + } + } + } + + $scope.filteredModel.reverse(); + + $timeout( function() { + + $scope.getFormElements(); + + // Callback: on filter change + if ( $scope.inputLabel.labelFilter.length > vMinSearchLength ) { + + var filterObj = []; + + angular.forEach( $scope.filteredModel, function( value ) { + if ( typeof value !== 'undefined' ) { + if ( typeof value[ attrs.groupProperty ] === 'undefined' ) { + var tempObj = angular.copy( value ); + var index = filterObj.push( tempObj ); + delete filterObj[ index - 1 ][ $scope.indexProperty ]; + delete filterObj[ index - 1 ][ $scope.spacingProperty ]; + } + } + }); + + $scope.onSearchChange({ + data: + { + keyword: $scope.inputLabel.labelFilter, + result: filterObj + } + }); + } + },0); + }; + + // List all the input elements. We need this for our keyboard navigation. + // This function will be called everytime the filter is updated. + // Depending on the size of filtered mode, might not good for performance, but oh well.. + $scope.getFormElements = function() { + formElements = []; + + var + selectButtons = [], + inputField = [], + checkboxes = [], + clearButton = []; + + // If available, then get select all, select none, and reset buttons + if ( $scope.helperStatus.all || $scope.helperStatus.none || $scope.helperStatus.reset ) { + selectButtons = element.children().children().next().children().children()[ 0 ].getElementsByTagName( 'button' ); + // If available, then get the search box and the clear button + if ( $scope.helperStatus.filter ) { + // Get helper - search and clear button. + inputField = element.children().children().next().children().children().next()[ 0 ].getElementsByTagName( 'input' ); + clearButton = element.children().children().next().children().children().next()[ 0 ].getElementsByTagName( 'button' ); + } + } + else { + if ( $scope.helperStatus.filter ) { + // Get helper - search and clear button. + inputField = element.children().children().next().children().children()[ 0 ].getElementsByTagName( 'input' ); + clearButton = element.children().children().next().children().children()[ 0 ].getElementsByTagName( 'button' ); + } + } + + // Get checkboxes + if ( !$scope.helperStatus.all && !$scope.helperStatus.none && !$scope.helperStatus.reset && !$scope.helperStatus.filter ) { + checkboxes = element.children().children().next()[ 0 ].getElementsByTagName( 'input' ); + } + else { + checkboxes = element.children().children().next().children().next()[ 0 ].getElementsByTagName( 'input' ); + } + + // Push them into global array formElements[] + for ( let i = 0; i < selectButtons.length ; i++ ) { formElements.push( selectButtons[ i ] ); } + for ( let i = 0; i < inputField.length ; i++ ) { formElements.push( inputField[ i ] ); } + for ( let i = 0; i < clearButton.length ; i++ ) { formElements.push( clearButton[ i ] ); } + for ( let i = 0; i < checkboxes.length ; i++ ) { formElements.push( checkboxes[ i ] ); } + } + + // check if an item has attrs.groupProperty (be it true or false) + $scope.isGroupMarker = function( item , type ) { + if ( typeof item[ attrs.groupProperty ] !== 'undefined' && item[ attrs.groupProperty ] === type ) return true; + return false; + } + + $scope.removeGroupEndMarker = function( item ) { + if ( typeof item[ attrs.groupProperty ] !== 'undefined' && item[ attrs.groupProperty ] === false ) return false; + return true; + } + + // call this function when an item is clicked + $scope.syncItems = function( item, e, ng_repeat_index ) { + + e.preventDefault(); + e.stopPropagation(); + + // if the directive is globaly disabled, do nothing + if ( typeof attrs.disableProperty !== 'undefined' && item[ attrs.disableProperty ] === true ) { + return false; + } + + // if item is disabled, do nothing + if ( typeof attrs.isDisabled !== 'undefined' && $scope.isDisabled === true ) { + return false; + } + + // if end group marker is clicked, do nothing + if ( typeof item[ attrs.groupProperty ] !== 'undefined' && item[ attrs.groupProperty ] === false ) { + return false; + } + + var index = $scope.filteredModel.indexOf( item ); + + // if the start of group marker is clicked ( only for multiple selection! ) + // how it works: + // - if, in a group, there are items which are not selected, then they all will be selected + // - if, in a group, all items are selected, then they all will be de-selected + if ( typeof item[ attrs.groupProperty ] !== 'undefined' && item[ attrs.groupProperty ] === true ) { + + // this is only for multiple selection, so if selection mode is single, do nothing + if ( typeof attrs.selectionMode !== 'undefined' && attrs.selectionMode.toUpperCase() === 'SINGLE' ) { + return false; + } + + var i, j; + var startIndex = 0; + var endIndex = $scope.filteredModel.length - 1; + var tempArr = []; + + // nest level is to mark the depth of the group. + // when you get into a group (start group marker), nestLevel++ + // when you exit a group (end group marker), nextLevel-- + var nestLevel = 0; + + // we loop throughout the filtered model (not whole model) + for( i = index ; i < $scope.filteredModel.length ; i++) { + + // this break will be executed when we're done processing each group + if ( nestLevel === 0 && i > index ) + { + break; + } + + if ( typeof $scope.filteredModel[ i ][ attrs.groupProperty ] !== 'undefined' && $scope.filteredModel[ i ][ attrs.groupProperty ] === true ) { + + // To cater multi level grouping + if ( tempArr.length === 0 ) { + startIndex = i + 1; + } + nestLevel = nestLevel + 1; + } + + // if group end + else if ( typeof $scope.filteredModel[ i ][ attrs.groupProperty ] !== 'undefined' && $scope.filteredModel[ i ][ attrs.groupProperty ] === false ) { + + nestLevel = nestLevel - 1; + + // cek if all are ticked or not + if ( tempArr.length > 0 && nestLevel === 0 ) { + + var allTicked = true; + + endIndex = i; + + for ( j = 0; j < tempArr.length ; j++ ) { + if ( typeof tempArr[ j ][ $scope.tickProperty ] !== 'undefined' && tempArr[ j ][ $scope.tickProperty ] === false ) { + allTicked = false; + break; + } + } + + if ( allTicked === true ) { + for ( j = startIndex; j <= endIndex ; j++ ) { + if ( typeof $scope.filteredModel[ j ][ attrs.groupProperty ] === 'undefined' ) { + if ( typeof attrs.disableProperty === 'undefined' ) { + $scope.filteredModel[ j ][ $scope.tickProperty ] = false; + // we refresh input model as well + inputModelIndex = $scope.filteredModel[ j ][ $scope.indexProperty ]; + $scope.inputModel[ inputModelIndex ][ $scope.tickProperty ] = false; + } + else if ( $scope.filteredModel[ j ][ attrs.disableProperty ] !== true ) { + $scope.filteredModel[ j ][ $scope.tickProperty ] = false; + // we refresh input model as well + inputModelIndex = $scope.filteredModel[ j ][ $scope.indexProperty ]; + $scope.inputModel[ inputModelIndex ][ $scope.tickProperty ] = false; + } + } + } + } + + else { + for ( j = startIndex; j <= endIndex ; j++ ) { + if ( typeof $scope.filteredModel[ j ][ attrs.groupProperty ] === 'undefined' ) { + if ( typeof attrs.disableProperty === 'undefined' ) { + $scope.filteredModel[ j ][ $scope.tickProperty ] = true; + // we refresh input model as well + inputModelIndex = $scope.filteredModel[ j ][ $scope.indexProperty ]; + $scope.inputModel[ inputModelIndex ][ $scope.tickProperty ] = true; + + } + else if ( $scope.filteredModel[ j ][ attrs.disableProperty ] !== true ) { + $scope.filteredModel[ j ][ $scope.tickProperty ] = true; + // we refresh input model as well + inputModelIndex = $scope.filteredModel[ j ][ $scope.indexProperty ]; + $scope.inputModel[ inputModelIndex ][ $scope.tickProperty ] = true; + } + } + } + } + } + } + + // if data + else { + tempArr.push( $scope.filteredModel[ i ] ); + } + } + } + + // if an item (not group marker) is clicked + else { + + // If it's single selection mode + if ( typeof attrs.selectionMode !== 'undefined' && attrs.selectionMode.toUpperCase() === 'SINGLE' ) { + + // first, set everything to false + for( i=0 ; i < $scope.filteredModel.length ; i++) { + $scope.filteredModel[ i ][ $scope.tickProperty ] = false; + } + for( i=0 ; i < $scope.inputModel.length ; i++) { + $scope.inputModel[ i ][ $scope.tickProperty ] = false; + } + + // then set the clicked item to true + $scope.filteredModel[ index ][ $scope.tickProperty ] = true; + } + + // Multiple + else { + $scope.filteredModel[ index ][ $scope.tickProperty ] = !$scope.filteredModel[ index ][ $scope.tickProperty ]; + } + + // we refresh input model as well + var inputModelIndex = $scope.filteredModel[ index ][ $scope.indexProperty ]; + $scope.inputModel[ inputModelIndex ][ $scope.tickProperty ] = $scope.filteredModel[ index ][ $scope.tickProperty ]; + } + + // we execute the callback function here + clickedItem = angular.copy( item ); + if ( clickedItem !== null ) { + $timeout( function() { + delete clickedItem[ $scope.indexProperty ]; + delete clickedItem[ $scope.spacingProperty ]; + $scope.onItemClick( { data: clickedItem } ); + clickedItem = null; + }, 0 ); + } + + $scope.refreshOutputModel(); + $scope.refreshButton(); + + // We update the index here + prevTabIndex = $scope.tabIndex; + $scope.tabIndex = ng_repeat_index + helperItemsLength; + + // Set focus on the hidden checkbox + e.target.focus(); + + // set & remove CSS style + $scope.removeFocusStyle( prevTabIndex ); + $scope.setFocusStyle( $scope.tabIndex ); + + if ( typeof attrs.selectionMode !== 'undefined' && attrs.selectionMode.toUpperCase() === 'SINGLE' ) { + // on single selection mode, we then hide the checkbox layer + $scope.toggleCheckboxes( e ); + } + } + + // update $scope.outputModel + $scope.refreshOutputModel = function() { + + $scope.outputModel = []; + var + outputProps = [], + tempObj = {}; + + // v4.0.0 + if ( typeof attrs.outputProperties !== 'undefined' ) { + outputProps = attrs.outputProperties.split(' '); + angular.forEach( $scope.inputModel, function( value ) { + if ( + typeof value !== 'undefined' + && typeof value[ attrs.groupProperty ] === 'undefined' + && value[ $scope.tickProperty ] === true + ) { + tempObj = {}; + angular.forEach( value, function( value1, key1 ) { + if ( outputProps.indexOf( key1 ) > -1 ) { + tempObj[ key1 ] = value1; + } + }); + var index = $scope.outputModel.push( tempObj ); + delete $scope.outputModel[ index - 1 ][ $scope.indexProperty ]; + delete $scope.outputModel[ index - 1 ][ $scope.spacingProperty ]; + } + }); + } + else { + angular.forEach( $scope.inputModel, function( value ) { + if ( + typeof value !== 'undefined' + && typeof value[ attrs.groupProperty ] === 'undefined' + && value[ $scope.tickProperty ] === true + ) { + var temp = angular.copy( value ); + var index = $scope.outputModel.push( temp ); + delete $scope.outputModel[ index - 1 ][ $scope.indexProperty ]; + delete $scope.outputModel[ index - 1 ][ $scope.spacingProperty ]; + } + }); + } + } + + // refresh button label + $scope.refreshButton = function() { + + $scope.varButtonLabel = ''; + var ctr = 0; + + // refresh button label... + if ( $scope.outputModel.length === 0 ) { + // https://github.com/isteven/angular-multi-select/pull/19 + $scope.varButtonLabel = $scope.lang.nothingSelected; + } + else { + var tempMaxLabels = $scope.outputModel.length; + if ( typeof attrs.maxLabels !== 'undefined' && attrs.maxLabels !== '' ) { + tempMaxLabels = attrs.maxLabels; + } + + // if max amount of labels displayed.. + if ( $scope.outputModel.length > tempMaxLabels ) { + $scope.more = true; + } + else { + $scope.more = false; + } + + angular.forEach( $scope.inputModel, function( value ) { + if ( typeof value !== 'undefined' && value[ attrs.tickProperty ] === true ) { + if ( ctr < tempMaxLabels ) { + $scope.varButtonLabel += ( $scope.varButtonLabel.length > 0 ? ',
' : '
') + $scope.writeLabel( value, 'buttonLabel' ); + } + ctr++; + } + }); + + if ( $scope.more === true ) { + // https://github.com/isteven/angular-multi-select/pull/16 + if (tempMaxLabels > 0) { + $scope.varButtonLabel += ', ... '; + } + $scope.varButtonLabel += '(' + $scope.outputModel.length + ')'; + } + } + // $scope.varButtonLabel = $sce.trustAsHtml( $scope.varButtonLabel + '' ); + $scope.varButtonLabel = $sanitize($scope.varButtonLabel + ''); + } + + // Check if a checkbox is disabled or enabled. It will check the granular control (disableProperty) and global control (isDisabled) + // Take note that the granular control has higher priority. + $scope.itemIsDisabled = function( item ) { + + if ( typeof attrs.disableProperty !== 'undefined' && item[ attrs.disableProperty ] === true ) { + return true; + } + else { + if ( $scope.isDisabled === true ) { + return true; + } + else { + return false; + } + } + + } + + // A simple function to parse the item label settings. Used on the buttons and checkbox labels. + $scope.writeLabel = function( item, type ) { + // type is either 'itemLabel' or 'buttonLabel' + var temp = attrs[ type ].split( ' ' ); + var label = ''; + + angular.forEach( temp, function( value ) { + item[ value ] && ( label += ' ' + value.split( '.' ).reduce( function( prev, current ) { + return prev[ current ]; + }, item )); + }); + + if ( type.toUpperCase() === 'BUTTONLABEL' ) { + return label; + } + // return $sce.trustAsHtml( label ); + return $sanitize(label); + } + + // UI operations to show/hide checkboxes based on click event.. + $scope.toggleCheckboxes = function( ) { + + // We grab the button + var clickedEl = element.children()[0]; + + // Just to make sure.. had a bug where key events were recorded twice + angular.element( document ).off( 'click', $scope.externalClickListener ); + angular.element( document ).off( 'keydown', $scope.keyboardListener ); + + // The idea below was taken from another multi-select directive - https://github.com/amitava82/angular-multiselect + // His version is awesome if you need a more simple multi-select approach. + + // close + if ( angular.element( checkBoxLayer ).hasClass( 'show' )) { + + angular.element( checkBoxLayer ).removeClass( 'show' ); + angular.element( clickedEl ).removeClass( 'buttonClicked' ); + angular.element( document ).off( 'click', $scope.externalClickListener ); + angular.element( document ).off( 'keydown', $scope.keyboardListener ); + + // clear the focused element; + $scope.removeFocusStyle( $scope.tabIndex ); + if ( typeof formElements[ $scope.tabIndex ] !== 'undefined' ) { + formElements[ $scope.tabIndex ].blur(); + } + + // close callback + $timeout( function() { + $scope.onClose(); + }, 0 ); + + // set focus on button again + element.children().children()[ 0 ].focus(); + } + // open + else + { + // clear filter + $scope.inputLabel.labelFilter = ''; + $scope.updateFilter(); + + helperItems = []; + helperItemsLength = 0; + + angular.element( checkBoxLayer ).addClass( 'show' ); + angular.element( clickedEl ).addClass( 'buttonClicked' ); + + // Attach change event listener on the input filter. + // We need this because ng-change is apparently not an event listener. + angular.element( document ).on( 'click', $scope.externalClickListener ); + angular.element( document ).on( 'keydown', $scope.keyboardListener ); + + // to get the initial tab index, depending on how many helper elements we have. + // priority is to always focus it on the input filter + $scope.getFormElements(); + $scope.tabIndex = 0; + + var helperContainer = angular.element( element[ 0 ].querySelector( '.helperContainer' ) )[0]; + + if ( typeof helperContainer !== 'undefined' ) { + for ( var i = 0; i < helperContainer.getElementsByTagName( 'BUTTON' ).length ; i++ ) { + helperItems[ i ] = helperContainer.getElementsByTagName( 'BUTTON' )[ i ]; + } + helperItemsLength = helperItems.length + helperContainer.getElementsByTagName( 'INPUT' ).length; + } + + // focus on the filter element on open. + if ( element[ 0 ].querySelector( '.inputFilter' ) ) { + element[ 0 ].querySelector( '.inputFilter' ).focus(); + $scope.tabIndex = $scope.tabIndex + helperItemsLength - 2; + // blur button in vain + angular.element( element ).children()[ 0 ].blur(); + } + // if there's no filter then just focus on the first checkbox item + else { + if ( !$scope.isDisabled ) { + $scope.tabIndex = $scope.tabIndex + helperItemsLength; + if ( $scope.inputModel.length > 0 ) { + formElements[ $scope.tabIndex ].focus(); + $scope.setFocusStyle( $scope.tabIndex ); + // blur button in vain + angular.element( element ).children()[ 0 ].blur(); + } + } + } + + // open callback + $scope.onOpen(); + } + } + + // handle clicks outside the button / multi select layer + $scope.externalClickListener = function( e ) { + + var targetsArr = element.find( e.target.tagName ); + for (var i = 0; i < targetsArr.length; i++) { + if ( e.target == targetsArr[i] ) { + return; + } + } + + angular.element( checkBoxLayer.previousSibling ).removeClass( 'buttonClicked' ); + angular.element( checkBoxLayer ).removeClass( 'show' ); + angular.element( document ).off( 'click', $scope.externalClickListener ); + angular.element( document ).off( 'keydown', $scope.keyboardListener ); + + // close callback + $timeout( function() { + $scope.onClose(); + }, 0 ); + + // set focus on button again + element.children().children()[ 0 ].focus(); + } + + // select All / select None / reset buttons + $scope.select = function( type, e ) { + + var helperIndex = helperItems.indexOf( e.target ); + $scope.tabIndex = helperIndex; + + switch( type.toUpperCase() ) { + case 'ALL': + angular.forEach( $scope.filteredModel, function( value ) { + if ( typeof value !== 'undefined' && value[ attrs.disableProperty ] !== true ) { + if ( typeof value[ attrs.groupProperty ] === 'undefined' ) { + value[ $scope.tickProperty ] = true; + } + } + }); + $scope.refreshOutputModel(); + $scope.refreshButton(); + $scope.onSelectAll(); + break; + case 'NONE': + angular.forEach( $scope.filteredModel, function( value ) { + if ( typeof value !== 'undefined' && value[ attrs.disableProperty ] !== true ) { + if ( typeof value[ attrs.groupProperty ] === 'undefined' ) { + value[ $scope.tickProperty ] = false; + } + } + }); + $scope.refreshOutputModel(); + $scope.refreshButton(); + $scope.onSelectNone(); + break; + case 'RESET': + angular.forEach( $scope.filteredModel, function( value ) { + if ( typeof value[ attrs.groupProperty ] === 'undefined' && typeof value !== 'undefined' && value[ attrs.disableProperty ] !== true ) { + var temp = value[ $scope.indexProperty ]; + value[ $scope.tickProperty ] = $scope.backUp[ temp ][ $scope.tickProperty ]; + } + }); + $scope.refreshOutputModel(); + $scope.refreshButton(); + $scope.onReset(); + break; + case 'CLEAR': + $scope.tabIndex = $scope.tabIndex + 1; + $scope.onClear(); + break; + case 'FILTER': + $scope.tabIndex = helperItems.length - 1; + break; + default: + } + } + + // just to create a random variable name + function genRandomString( length ) { + var possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + var temp = ''; + for( var i=0; i < length; i++ ) { + temp += possible.charAt( Math.floor( Math.random() * possible.length )); + } + return temp; + } + + // count leading spaces + $scope.prepareGrouping = function() { + var spacing = 0; + angular.forEach( $scope.filteredModel, function( value ) { + value[ $scope.spacingProperty ] = spacing; + if ( value[ attrs.groupProperty ] === true ) { + spacing+=2; + } + else if ( value[ attrs.groupProperty ] === false ) { + spacing-=2; + } + }); + } + + // prepare original index + $scope.prepareIndex = function() { + var ctr = 0; + angular.forEach( $scope.filteredModel, function( value ) { + value[ $scope.indexProperty ] = ctr; + ctr++; + }); + } + + // navigate using up and down arrow + $scope.keyboardListener = function( e ) { + + var key = e.keyCode ? e.keyCode : e.which; + var isNavigationKey = false; + + // ESC key (close) + if ( key === 27 ) { + e.preventDefault(); + e.stopPropagation(); + $scope.toggleCheckboxes( e ); + } + + + // next element ( tab, down & right key ) + else if ( key === 40 || key === 39 || ( !e.shiftKey && key == 9 ) ) { + + isNavigationKey = true; + prevTabIndex = $scope.tabIndex; + $scope.tabIndex++; + if ( $scope.tabIndex > formElements.length - 1 ) { + $scope.tabIndex = 0; + prevTabIndex = formElements.length - 1; + } + while ( formElements[ $scope.tabIndex ].disabled === true ) { + $scope.tabIndex++; + if ( $scope.tabIndex > formElements.length - 1 ) { + $scope.tabIndex = 0; + } + if ( $scope.tabIndex === prevTabIndex ) { + break; + } + } + } + + // prev element ( shift+tab, up & left key ) + else if ( key === 38 || key === 37 || ( e.shiftKey && key == 9 ) ) { + isNavigationKey = true; + prevTabIndex = $scope.tabIndex; + $scope.tabIndex--; + if ( $scope.tabIndex < 0 ) { + $scope.tabIndex = formElements.length - 1; + prevTabIndex = 0; + } + while ( formElements[ $scope.tabIndex ].disabled === true ) { + $scope.tabIndex--; + if ( $scope.tabIndex === prevTabIndex ) { + break; + } + if ( $scope.tabIndex < 0 ) { + $scope.tabIndex = formElements.length - 1; + } + } + } + + if ( isNavigationKey === true ) { + + e.preventDefault(); + + // set focus on the checkbox + formElements[ $scope.tabIndex ].focus(); + var actEl = document.activeElement; + + if ( actEl.type.toUpperCase() === 'CHECKBOX' ) { + $scope.setFocusStyle( $scope.tabIndex ); + $scope.removeFocusStyle( prevTabIndex ); + } + else { + $scope.removeFocusStyle( prevTabIndex ); + $scope.removeFocusStyle( helperItemsLength ); + $scope.removeFocusStyle( formElements.length - 1 ); + } + } + + isNavigationKey = false; + } + + // set (add) CSS style on selected row + $scope.setFocusStyle = function( tabIndex ) { + angular.element( formElements[ tabIndex ] ).parent().parent().parent().addClass( 'multiSelectFocus' ); + } + + // remove CSS style on selected row + $scope.removeFocusStyle = function( tabIndex ) { + angular.element( formElements[ tabIndex ] ).parent().parent().parent().removeClass( 'multiSelectFocus' ); + } + + /********************* + ********************* + * + * 1) Initializations + * + ********************* + *********************/ + + // attrs to $scope - attrs-$scope - attrs - $scope + // Copy some properties that will be used on the template. They need to be in the $scope. + $scope.groupProperty = attrs.groupProperty; + $scope.tickProperty = attrs.tickProperty; + $scope.directiveId = attrs.directiveId; + + // Unfortunately I need to add these grouping properties into the input model + var tempStr = genRandomString( 5 ); + $scope.indexProperty = 'idx_' + tempStr; + $scope.spacingProperty = 'spc_' + tempStr; + + // set orientation css + if ( typeof attrs.orientation !== 'undefined' ) { + + if ( attrs.orientation.toUpperCase() === 'HORIZONTAL' ) { + $scope.orientationH = true; + $scope.orientationV = false; + } + else + { + $scope.orientationH = false; + $scope.orientationV = true; + } + } + + // get elements required for DOM operation + checkBoxLayer = element.children().children().next()[0]; + + // set max-height property if provided + if ( typeof attrs.maxHeight !== 'undefined' ) { + var layer = element.children().children().children()[0]; + angular.element( layer ).attr( "style", "height:" + attrs.maxHeight + "; overflow-y:scroll;" ); + } + + // some flags for easier checking + for ( var property in $scope.helperStatus ) { + if ( $scope.helperStatus.hasOwnProperty( property )) { + if ( + typeof attrs.helperElements !== 'undefined' + && attrs.helperElements.toUpperCase().indexOf( property.toUpperCase() ) === -1 + ) { + $scope.helperStatus[ property ] = false; + } + } + } + if ( typeof attrs.selectionMode !== 'undefined' && attrs.selectionMode.toUpperCase() === 'SINGLE' ) { + $scope.helperStatus[ 'all' ] = false; + $scope.helperStatus[ 'none' ] = false; + } + + // helper button icons.. I guess you can use html tag here if you want to. + $scope.icon = {}; + $scope.icon.selectAll = '✓'; // a tick icon + $scope.icon.selectNone = '×'; // x icon + $scope.icon.reset = '↶'; // undo icon + // this one is for the selected items + $scope.icon.tickMark = '✓'; // a tick icon + + // configurable button labels + // if ( typeof attrs.translation !== 'undefined' ) { + // $scope.lang.selectAll = $sce.trustAsHtml( $scope.icon.selectAll + '  ' + $scope.translation.selectAll ); + // $scope.lang.selectNone = $sce.trustAsHtml( $scope.icon.selectNone + '  ' + $scope.translation.selectNone ); + // $scope.lang.reset = $sce.trustAsHtml( $scope.icon.reset + '  ' + $scope.translation.reset ); + // $scope.lang.search = $scope.translation.search; + // $scope.lang.nothingSelected = $sce.trustAsHtml( $scope.translation.nothingSelected ); + // } + // else { + // $scope.lang.selectAll = $sce.trustAsHtml( $scope.icon.selectAll + '  Select All' ); + // $scope.lang.selectNone = $sce.trustAsHtml( $scope.icon.selectNone + '  Select None' ); + // $scope.lang.reset = $sce.trustAsHtml( $scope.icon.reset + '  Reset' ); + // $scope.lang.search = 'Search...'; + // $scope.lang.nothingSelected = 'None Selected'; + // } + // $scope.icon.tickMark = $sce.trustAsHtml( $scope.icon.tickMark ); + if ( typeof attrs.translation !== 'undefined' ) { + $scope.lang.selectAll = $sanitize( $scope.icon.selectAll + '  ' + $scope.translation.selectAll ); + $scope.lang.selectNone = $sanitize( $scope.icon.selectNone + '  ' + $scope.translation.selectNone ); + $scope.lang.reset = $sanitize( $scope.icon.reset + '  ' + $scope.translation.reset ); + $scope.lang.search = $scope.translation.search; + $scope.lang.nothingSelected = $sanitize( $scope.translation.nothingSelected ); + } + else { + $scope.lang.selectAll = $sanitize( $scope.icon.selectAll + '  Select All' ); + $scope.lang.selectNone = $sanitize( $scope.icon.selectNone + '  Select None' ); + $scope.lang.reset = $sanitize( $scope.icon.reset + '  Reset' ); + $scope.lang.search = 'Search...'; + $scope.lang.nothingSelected = 'None Selected'; + } + $scope.icon.tickMark = $sanitize( $scope.icon.tickMark ); + + // min length of keyword to trigger the filter function + if ( typeof attrs.MinSearchLength !== 'undefined' && parseInt( attrs.MinSearchLength ) > 0 ) { + vMinSearchLength = Math.floor( parseInt( attrs.MinSearchLength ) ); + } + + /******************************************************* + ******************************************************* + * + * 2) Logic starts here, initiated by watch 1 & watch 2 + * + ******************************************************* + *******************************************************/ + + // watch1, for changes in input model property + // updates multi-select when user select/deselect a single checkbox programatically + // https://github.com/isteven/angular-multi-select/issues/8 + $scope.$watch( 'inputModel' , function( newVal ) { + if ( newVal ) { + $scope.refreshOutputModel(); + $scope.refreshButton(); + } + }, true ); + + // watch2 for changes in input model as a whole + // this on updates the multi-select when a user load a whole new input-model. We also update the $scope.backUp variable + $scope.$watch( 'inputModel' , function( newVal ) { + if ( newVal ) { + $scope.backUp = angular.copy( $scope.inputModel ); + $scope.updateFilter(); + $scope.prepareGrouping(); + $scope.prepareIndex(); + $scope.refreshOutputModel(); + $scope.refreshButton(); + } + }); + + // watch for changes in directive state (disabled or enabled) + $scope.$watch( 'isDisabled' , function( newVal ) { + $scope.isDisabled = newVal; + }); + + // this is for touch enabled devices. We don't want to hide checkboxes on scroll. + var onTouchStart = function() { + $scope.$apply( function() { + $scope.scrolled = false; + }); + }; + angular.element( document ).bind( 'touchstart', onTouchStart); + var onTouchMove = function() { + $scope.$apply( function() { + $scope.scrolled = true; + }); + }; + angular.element( document ).bind( 'touchmove', onTouchMove); + + // unbind document events to prevent memory leaks + $scope.$on( '$destroy', function () { + angular.element( document ).unbind( 'touchstart', onTouchStart); + angular.element( document ).unbind( 'touchmove', onTouchMove); + }); + } + } +}]).run( [ '$templateCache' , function( $templateCache ) { + var template = + '' + + // main button + '' + + // overlay layer + '
' + + // container of the helper elements + '
' + + // container of the first 3 buttons, select all, none and reset + '
' + + // select all + ''+ + // select none + ''+ + // reset + '' + + '
' + + // the search box + '
'+ + // textfield + ''+ + // clear button + ' '+ + '
'+ + '
'+ + // selection items + '
'+ + '
'+ + // this is the spacing for grouped items + '
'+ + '
'+ + '
'+ + ''+ + '
'+ + // the tick/check mark + ''+ + '
'+ + '
'+ + '
'+ + '
'; + $templateCache.put( 'isteven-multi-select.htm' , template ); +}]); diff --git a/app/libraries/isteven-angular-multiselect/package.json b/app/libraries/isteven-angular-multiselect/package.json new file mode 100644 index 000000000..9aa2e3960 --- /dev/null +++ b/app/libraries/isteven-angular-multiselect/package.json @@ -0,0 +1,22 @@ +{ + "name": "isteven-angular-multiselect", + "version": "v4.0.0", + "description": "A multi select dropdown directive for AngularJS", + "main": [ + "isteven-multi-select.js", + "isteven-multi-select.css" + ], + "repository": { + "type": "git", + "url": "https://github.com/isteven/angular-multi-select.git" + }, + "keywords": [ + "angular" + ], + "author": "Ignatius Steven (https://github.com/isteven)", + "license": "MIT", + "bugs": { + "url": "https://github.com/isteven/angular-multi-select/issues" + }, + "homepage": "https://github.com/isteven/angular-multi-select" +} diff --git a/app/portainer/__module.js b/app/portainer/__module.js index 2c8529140..5d96da47a 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -56,7 +56,8 @@ angular.module('portainer.app', []) views: { 'content@': { templateUrl: './views/auth/auth.html', - controller: 'AuthenticationController' + controller: 'AuthenticationController', + controllerAs: 'ctrl' }, 'sidebar@': {} }, diff --git a/app/portainer/components/access-datatable/accessDatatable.html b/app/portainer/components/access-datatable/accessDatatable.html index 2d33f779e..8368580ff 100644 --- a/app/portainer/components/access-datatable/accessDatatable.html +++ b/app/portainer/components/access-datatable/accessDatatable.html @@ -23,7 +23,7 @@
diff --git a/app/portainer/components/access-datatable/accessDatatableController.js b/app/portainer/components/access-datatable/accessDatatableController.js index 2ed056421..c53f26c27 100644 --- a/app/portainer/components/access-datatable/accessDatatableController.js +++ b/app/portainer/components/access-datatable/accessDatatableController.js @@ -15,6 +15,7 @@ angular.module('portainer.app') this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -41,7 +42,6 @@ angular.module('portainer.app') this.settings.open = false; } this.onSettingsRepeaterChange(); - this.state.orderBy = this.orderBy; }; } ]); \ No newline at end of file diff --git a/app/portainer/components/access-table/accessTable.html b/app/portainer/components/access-table/accessTable.html index a69147435..0664d1c48 100644 --- a/app/portainer/components/access-table/accessTable.html +++ b/app/portainer/components/access-table/accessTable.html @@ -2,7 +2,7 @@
- +
diff --git a/app/portainer/components/datatables/genericDatatableController.js b/app/portainer/components/datatables/genericDatatableController.js index 991869481..4658c03ea 100644 --- a/app/portainer/components/datatables/genericDatatableController.js +++ b/app/portainer/components/datatables/genericDatatableController.js @@ -119,6 +119,7 @@ function ($interval, PaginationService, DatatableService, PAGINATION_MAX_ITEMS) this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -145,7 +146,6 @@ function ($interval, PaginationService, DatatableService, PAGINATION_MAX_ITEMS) this.settings.open = false; } this.onSettingsRepeaterChange(); - this.state.orderBy = this.orderBy; }; /** diff --git a/app/portainer/components/datatables/groups-datatable/groupsDatatable.html b/app/portainer/components/datatables/groups-datatable/groupsDatatable.html index 64a63c413..0deec20f0 100644 --- a/app/portainer/components/datatables/groups-datatable/groupsDatatable.html +++ b/app/portainer/components/datatables/groups-datatable/groupsDatatable.html @@ -17,7 +17,7 @@
diff --git a/app/portainer/components/datatables/registries-datatable/registriesDatatable.html b/app/portainer/components/datatables/registries-datatable/registriesDatatable.html index 45ebc13a8..9952686fb 100644 --- a/app/portainer/components/datatables/registries-datatable/registriesDatatable.html +++ b/app/portainer/components/datatables/registries-datatable/registriesDatatable.html @@ -17,7 +17,7 @@
diff --git a/app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.html b/app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.html index 00b395086..c278e7bd9 100644 --- a/app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.html +++ b/app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.html @@ -11,7 +11,7 @@
diff --git a/app/portainer/components/datatables/schedules-datatable/schedulesDatatable.html b/app/portainer/components/datatables/schedules-datatable/schedulesDatatable.html index 86134c34e..035d3706b 100644 --- a/app/portainer/components/datatables/schedules-datatable/schedulesDatatable.html +++ b/app/portainer/components/datatables/schedules-datatable/schedulesDatatable.html @@ -17,7 +17,7 @@
diff --git a/app/portainer/components/datatables/schedules-datatable/schedulesDatatableController.js b/app/portainer/components/datatables/schedules-datatable/schedulesDatatableController.js index 9a61f74d9..cba684691 100644 --- a/app/portainer/components/datatables/schedules-datatable/schedulesDatatableController.js +++ b/app/portainer/components/datatables/schedules-datatable/schedulesDatatableController.js @@ -15,6 +15,7 @@ angular.module('portainer.app') this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -41,7 +42,6 @@ angular.module('portainer.app') this.settings.open = false; } this.onSettingsRepeaterChange(); - this.state.orderBy = this.orderBy; }; } ]); diff --git a/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html b/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html index 480888746..43f6104c6 100644 --- a/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html +++ b/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html @@ -55,7 +55,7 @@
diff --git a/app/portainer/components/datatables/stacks-datatable/stacksDatatableController.js b/app/portainer/components/datatables/stacks-datatable/stacksDatatableController.js index 15b2a11b7..34e40387a 100644 --- a/app/portainer/components/datatables/stacks-datatable/stacksDatatableController.js +++ b/app/portainer/components/datatables/stacks-datatable/stacksDatatableController.js @@ -15,6 +15,7 @@ function ($scope, $controller, DatatableService) { this.setDefaults(); this.prepareTableFromDataset(); + this.state.orderBy = this.orderBy; var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -41,7 +42,6 @@ function ($scope, $controller, DatatableService) { this.settings.open = false; } this.onSettingsRepeaterChange(); - this.state.orderBy = this.orderBy; }; }]); diff --git a/app/portainer/components/datatables/tags-datatable/tagsDatatable.html b/app/portainer/components/datatables/tags-datatable/tagsDatatable.html index a34d8b8a2..c42a46638 100644 --- a/app/portainer/components/datatables/tags-datatable/tagsDatatable.html +++ b/app/portainer/components/datatables/tags-datatable/tagsDatatable.html @@ -14,7 +14,7 @@
diff --git a/app/portainer/components/datatables/teams-datatable/teamsDatatable.html b/app/portainer/components/datatables/teams-datatable/teamsDatatable.html index 268526737..9906e979d 100644 --- a/app/portainer/components/datatables/teams-datatable/teamsDatatable.html +++ b/app/portainer/components/datatables/teams-datatable/teamsDatatable.html @@ -14,7 +14,7 @@
diff --git a/app/portainer/components/datatables/users-datatable/usersDatatable.html b/app/portainer/components/datatables/users-datatable/usersDatatable.html index 74ce4b5b0..9b92819a7 100644 --- a/app/portainer/components/datatables/users-datatable/usersDatatable.html +++ b/app/portainer/components/datatables/users-datatable/usersDatatable.html @@ -14,7 +14,7 @@
diff --git a/app/portainer/components/forms/schedule-form/schedule-form.js b/app/portainer/components/forms/schedule-form/schedule-form.js index 7d3685eff..1ca8de98b 100644 --- a/app/portainer/components/forms/schedule-form/schedule-form.js +++ b/app/portainer/components/forms/schedule-form/schedule-form.js @@ -10,15 +10,15 @@ angular.module('portainer.app').component('scheduleForm', { }; ctrl.scheduleValues = [{ - displayed: 'Every hour', - cron: '0 0 * * *' - }, + displayed: 'Every hour', + cron: '0 * * * *' + }, { displayed: 'Every 2 hours', - cron: '0 0 0/2 * *' + cron: '0 */2 * * *' }, { displayed: 'Every day', - cron: '0 0 0 * *' + cron: '0 0 * * *' } ]; diff --git a/app/portainer/components/template-list/templateList.html b/app/portainer/components/template-list/templateList.html index 1f3d818a2..cd241c343 100644 --- a/app/portainer/components/template-list/templateList.html +++ b/app/portainer/components/template-list/templateList.html @@ -37,7 +37,7 @@
diff --git a/app/portainer/helpers/urlHelper.js b/app/portainer/helpers/urlHelper.js index 2b40c7274..d8103a3a2 100644 --- a/app/portainer/helpers/urlHelper.js +++ b/app/portainer/helpers/urlHelper.js @@ -23,7 +23,7 @@ angular.module('portainer.app') } function cleanParameters() { - $window.location.search = ''; + $window.location.replace($window.location.origin + $window.location.pathname + $window.location.hash); } return helper; diff --git a/app/portainer/models/settings.js b/app/portainer/models/settings.js index 4ac71e8a3..aa58dfd8e 100644 --- a/app/portainer/models/settings.js +++ b/app/portainer/models/settings.js @@ -6,6 +6,7 @@ export function SettingsViewModel(data) { this.OAuthSettings = new OAuthSettingsViewModel(data.OAuthSettings); this.AllowBindMountsForRegularUsers = data.AllowBindMountsForRegularUsers; this.AllowPrivilegedModeForRegularUsers = data.AllowPrivilegedModeForRegularUsers; + this.AllowVolumeBrowserForRegularUsers = data.AllowVolumeBrowserForRegularUsers; this.SnapshotInterval = data.SnapshotInterval; this.TemplatesURL = data.TemplatesURL; this.ExternalTemplates = data.ExternalTemplates; @@ -16,6 +17,7 @@ export function SettingsViewModel(data) { export function PublicSettingsViewModel(settings) { this.AllowBindMountsForRegularUsers = settings.AllowBindMountsForRegularUsers; this.AllowPrivilegedModeForRegularUsers = settings.AllowPrivilegedModeForRegularUsers; + this.AllowVolumeBrowserForRegularUsers = settings.AllowVolumeBrowserForRegularUsers; this.AuthenticationMethod = settings.AuthenticationMethod; this.EnableHostManagementFeatures = settings.EnableHostManagementFeatures; this.ExternalTemplates = settings.ExternalTemplates; diff --git a/app/portainer/models/status.js b/app/portainer/models/status.js index 713d4f1fe..d91ae9baf 100644 --- a/app/portainer/models/status.js +++ b/app/portainer/models/status.js @@ -5,3 +5,8 @@ export function StatusViewModel(data) { this.Analytics = data.Analytics; this.Version = data.Version; } + +export function StatusVersionViewModel(data) { + this.UpdateAvailable = data.UpdateAvailable; + this.LatestVersion = data.LatestVersion; +} \ No newline at end of file diff --git a/app/portainer/models/user.js b/app/portainer/models/user.js index e86be99f0..dbd31cfef 100644 --- a/app/portainer/models/user.js +++ b/app/portainer/models/user.js @@ -2,6 +2,8 @@ export function UserViewModel(data) { this.Id = data.Id; this.Username = data.Username; this.Role = data.Role; + this.EndpointAuthorizations = data.EndpointAuthorizations; + this.PortainerAuthorizations = data.PortainerAuthorizations; if (data.Role === 1) { this.RoleName = 'administrator'; } else { diff --git a/app/portainer/rest/status.js b/app/portainer/rest/status.js index 888a948bf..9a9e55123 100644 --- a/app/portainer/rest/status.js +++ b/app/portainer/rest/status.js @@ -1,7 +1,8 @@ angular.module('portainer.app') .factory('Status', ['$resource', 'API_ENDPOINT_STATUS', function StatusFactory($resource, API_ENDPOINT_STATUS) { 'use strict'; - return $resource(API_ENDPOINT_STATUS, {}, { - get: { method: 'GET' } + return $resource(API_ENDPOINT_STATUS + '/:action', {}, { + get: { method: 'GET' }, + version: { method: 'GET', params: { action: 'version' } } }); }]); diff --git a/app/portainer/rest/support.js b/app/portainer/rest/support.js new file mode 100644 index 000000000..7fb3a5a90 --- /dev/null +++ b/app/portainer/rest/support.js @@ -0,0 +1,7 @@ +angular.module('portainer.app') +.factory('Support', ['$resource', 'API_ENDPOINT_SUPPORT', function SupportFactory($resource, API_ENDPOINT_SUPPORT) { + 'use strict'; + return $resource(API_ENDPOINT_SUPPORT, {}, { + get: { method: 'GET', isArray: true }, + }); +}]); diff --git a/app/portainer/services/api/statusService.js b/app/portainer/services/api/statusService.js index eb7c1e343..05b5f87a1 100644 --- a/app/portainer/services/api/statusService.js +++ b/app/portainer/services/api/statusService.js @@ -1,4 +1,4 @@ -import { StatusViewModel } from "../../models/status"; +import {StatusVersionViewModel, StatusViewModel} from '../../models/status'; angular.module('portainer.app') .factory('StatusService', ['$q', 'Status', function StatusServiceFactory($q, Status) { @@ -20,5 +20,20 @@ angular.module('portainer.app') return deferred.promise; }; + service.version = function() { + var deferred = $q.defer(); + + Status.version().$promise + .then(function success(data) { + var status = new StatusVersionViewModel(data); + deferred.resolve(status); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve application version info', err: err }); + }); + + return deferred.promise; + }; + return service; }]); diff --git a/app/portainer/services/api/supportService.js b/app/portainer/services/api/supportService.js new file mode 100644 index 000000000..9bbcb56aa --- /dev/null +++ b/app/portainer/services/api/supportService.js @@ -0,0 +1,21 @@ +angular.module('portainer.app') +.factory('SupportService', ['$q', 'Support', function SupportServiceFactory($q, Support) { + 'use strict'; + var service = {}; + + service.supportProducts = function() { + var deferred = $q.defer(); + + Support.get().$promise + .then(function success(data) { + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve support options', err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/portainer/services/authentication.js b/app/portainer/services/authentication.js index d42c284fb..6272b72b2 100644 --- a/app/portainer/services/authentication.js +++ b/app/portainer/services/authentication.js @@ -1,7 +1,7 @@ angular.module('portainer.app') .factory('Authentication', [ -'Auth', 'OAuth', 'jwtHelper', 'LocalStorage', 'StateManager', 'EndpointProvider', -function AuthenticationFactory(Auth, OAuth, jwtHelper, LocalStorage, StateManager, EndpointProvider) { +'$async', 'Auth', 'OAuth', 'jwtHelper', 'LocalStorage', 'StateManager', 'EndpointProvider', 'UserService', +function AuthenticationFactory($async, Auth, OAuth, jwtHelper, LocalStorage, StateManager, EndpointProvider, UserService) { 'use strict'; var service = {}; @@ -15,6 +15,7 @@ function AuthenticationFactory(Auth, OAuth, jwtHelper, LocalStorage, StateManage service.getUserDetails = getUserDetails; service.isAdmin = isAdmin; service.hasAuthorizations = hasAuthorizations; + service.retrievePermissions = retrievePermissions; function init() { var jwt = LocalStorage.getJWT(); @@ -24,24 +25,29 @@ function AuthenticationFactory(Auth, OAuth, jwtHelper, LocalStorage, StateManage } } + async function OAuthLoginAsync(code) { + const response = await OAuth.validate({ code: code }).$promise; + setUser(response.jwt); + } + function OAuthLogin(code) { - return OAuth.validate({ code: code }).$promise - .then(function onLoginSuccess(response) { - return setUser(response.jwt); - }); + return $async(OAuthLoginAsync, code) + } + + async function loginAsync(username, password) { + const response = await Auth.login({ username: username, password: password }).$promise; + setUser(response.jwt); } function login(username, password) { - return Auth.login({ username: username, password: password }).$promise - .then(function onLoginSuccess(response) { - return setUser(response.jwt); - }); + return $async(loginAsync, username, password); } function logout() { StateManager.clean(); EndpointProvider.clean(); LocalStorage.clean(); + LocalStorage.storeLoginStateUUID(''); } function isAuthenticated() { @@ -53,14 +59,20 @@ function AuthenticationFactory(Auth, OAuth, jwtHelper, LocalStorage, StateManage return user; } + function retrievePermissions() { + return UserService.user(user.ID) + .then((data) => { + user.endpointAuthorizations = data.EndpointAuthorizations; + user.portainerAuthorizations = data.PortainerAuthorizations; + }); + } + function setUser(jwt) { LocalStorage.storeJWT(jwt); var tokenPayload = jwtHelper.decodeToken(jwt); user.username = tokenPayload.username; user.ID = tokenPayload.id; user.role = tokenPayload.role; - user.endpointAuthorizations = tokenPayload.endpointAuthorizations; - user.portainerAuthorizations = tokenPayload.portainerAuthorizations; } function isAdmin() { diff --git a/app/portainer/services/localStorage.js b/app/portainer/services/localStorage.js index 134eaff60..2accbca33 100644 --- a/app/portainer/services/localStorage.js +++ b/app/portainer/services/localStorage.js @@ -14,6 +14,12 @@ angular.module('portainer.app') getEndpointPublicURL: function() { return localStorageService.get('ENDPOINT_PUBLIC_URL'); }, + storeLoginStateUUID: function(uuid) { + localStorageService.cookie.set('LOGIN_STATE_UUID', uuid); + }, + getLoginStateUUID: function() { + return localStorageService.cookie.get('LOGIN_STATE_UUID'); + }, storeOfflineMode: function(isOffline) { localStorageService.set('ENDPOINT_OFFLINE_MODE', isOffline); }, diff --git a/app/portainer/services/modalService.js b/app/portainer/services/modalService.js index dceffadca..4b896859d 100644 --- a/app/portainer/services/modalService.js +++ b/app/portainer/services/modalService.js @@ -1,7 +1,7 @@ import bootbox from 'bootbox'; angular.module('portainer.app') -.factory('ModalService', [function ModalServiceFactory() { +.factory('ModalService', [ '$sanitize', function ModalServiceFactory($sanitize) { 'use strict'; var service = {}; @@ -17,17 +17,18 @@ angular.module('portainer.app') var confirmButtons = function(options) { var buttons = { confirm: { - label: options.buttons.confirm.label, - className: options.buttons.confirm.className + label: $sanitize(options.buttons.confirm.label), + className: $sanitize(options.buttons.confirm.className) }, cancel: { - label: options.buttons.cancel && options.buttons.cancel.label ? options.buttons.cancel.label : 'Cancel' + label: options.buttons.cancel && options.buttons.cancel.label ? $sanitize(options.buttons.cancel.label) : 'Cancel' } }; return buttons; }; service.enlargeImage = function(image) { + image = $sanitize(image); bootbox.dialog({ message: '', className: 'image-zoom-modal', @@ -45,7 +46,7 @@ angular.module('portainer.app') applyBoxCSS(box); }; - service.prompt = function(options){ + function prompt(options){ var box = bootbox.prompt({ title: options.title, inputType: options.inputType, @@ -54,9 +55,9 @@ angular.module('portainer.app') callback: options.callback }); applyBoxCSS(box); - }; + } - service.customPrompt = function(options, optionToggled) { + function customPrompt(options, optionToggled) { var box = bootbox.prompt({ title: options.title, inputType: options.inputType, @@ -67,7 +68,7 @@ angular.module('portainer.app') applyBoxCSS(box); box.find('.bootbox-body').prepend('

' + options.message + '

'); box.find('.bootbox-input-checkbox').prop('checked', optionToggled); - }; + } service.confirmAccessControlUpdate = function(callback) { service.confirm({ @@ -98,6 +99,7 @@ angular.module('portainer.app') }; service.confirmDeletion = function(message, callback) { + message = $sanitize(message); service.confirm({ title: 'Are you sure ?', message: message, @@ -112,7 +114,7 @@ angular.module('portainer.app') }; service.confirmContainerDeletion = function(title, callback) { - service.prompt({ + prompt({ title: title, inputType: 'checkbox', inputOptions: [ @@ -132,7 +134,7 @@ angular.module('portainer.app') }; service.confirmContainerRecreation = function(callback) { - service.customPrompt({ + customPrompt({ title: 'Are you sure?', message: 'You\'re about to re-create this container, any non-persisted data will be lost. This container will be removed and another one will be created using the same configuration.', inputType: 'checkbox', @@ -181,7 +183,7 @@ angular.module('portainer.app') }; service.confirmServiceForceUpdate = function(message, callback) { - service.customPrompt({ + customPrompt({ title: 'Are you sure ?', message: message, inputType: 'checkbox', diff --git a/app/portainer/services/stateManager.js b/app/portainer/services/stateManager.js index 69ceecb0c..65ee29476 100644 --- a/app/portainer/services/stateManager.js +++ b/app/portainer/services/stateManager.js @@ -19,6 +19,11 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin extensions: [] }; + manager.setVersionInfo = function(versionInfo) { + state.application.versionStatus = versionInfo; + LocalStorage.storeApplicationState(state.application); + }; + manager.dismissInformationPanel = function(id) { state.UI.dismissedInfoPanels[id] = true; LocalStorage.storeUIState(state.UI); @@ -53,6 +58,11 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin LocalStorage.storeApplicationState(state.application); }; + manager.updateEnableVolumeBrowserForNonAdminUsers = function(enableVolumeBrowserForNonAdminUsers) { + state.application.enableVolumeBrowserForNonAdminUsers = enableVolumeBrowserForNonAdminUsers; + LocalStorage.storeApplicationState(state.application); + }; + function assignStateFromStatusAndSettings(status, settings) { state.application.authentication = status.Authentication; state.application.analytics = status.Analytics; @@ -62,6 +72,7 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin state.application.logo = settings.LogoURL; state.application.snapshotInterval = settings.SnapshotInterval; state.application.enableHostManagementFeatures = settings.EnableHostManagementFeatures; + state.application.enableVolumeBrowserForNonAdminUsers = settings.AllowVolumeBrowserForRegularUsers; state.application.validity = moment().unix(); } diff --git a/app/portainer/views/auth/auth.html b/app/portainer/views/auth/auth.html index 4f1caf491..1aaa7a1df 100644 --- a/app/portainer/views/auth/auth.html +++ b/app/portainer/views/auth/auth.html @@ -4,53 +4,53 @@
- - + +
-
+
- +
- +
- -
+ +
Login with Microsoft
-
+
Login with Google
-
+
Login with Github
-
+
Login with OAuth
- - + - {{ state.AuthenticationError }} + {{ ctrl.state.AuthenticationError }}
@@ -61,10 +61,10 @@
-
+
- OAuth authentication in progress... + Authentication in progress...
diff --git a/app/portainer/views/auth/authController.js b/app/portainer/views/auth/authController.js index 120de69af..27a625785 100644 --- a/app/portainer/views/auth/authController.js +++ b/app/portainer/views/auth/authController.js @@ -1,109 +1,73 @@ -angular.module('portainer.app') -.controller('AuthenticationController', ['$async', '$q', '$scope', '$state', '$stateParams', '$sanitize', 'Authentication', 'UserService', 'EndpointService', 'ExtensionService', 'StateManager', 'Notifications', 'SettingsService', 'URLHelper', -function($async, $q, $scope, $state, $stateParams, $sanitize, Authentication, UserService, EndpointService, ExtensionService, StateManager, Notifications, SettingsService, URLHelper) { - $scope.logo = StateManager.getState().application.logo; +import angular from 'angular'; +import uuidv4 from 'uuid/v4'; - $scope.formValues = { - Username: '', - Password: '' - }; +class AuthenticationController { + /* @ngInject */ + constructor($async, $scope, $state, $stateParams, $sanitize, Authentication, UserService, EndpointService, ExtensionService, StateManager, Notifications, SettingsService, URLHelper, LocalStorage, StatusService) { + this.$async = $async; + this.$scope = $scope; + this.$state = $state; + this.$stateParams = $stateParams; + this.$sanitize = $sanitize; + this.Authentication = Authentication; + this.UserService = UserService; + this.EndpointService = EndpointService; + this.ExtensionService = ExtensionService; + this.StateManager = StateManager; + this.Notifications = Notifications; + this.SettingsService = SettingsService; + this.URLHelper = URLHelper; + this.LocalStorage = LocalStorage; + this.StatusService = StatusService; - $scope.state = { - AuthenticationError: '', - isInOAuthProcess: true, - OAuthProvider: '' - }; + this.logo = this.StateManager.getState().application.logo; + this.formValues = { + Username: '', + Password: '' + }; + this.state = { + AuthenticationError: '', + loginInProgress: true, + OAuthProvider: '' + }; - function retrieveAndSaveEnabledExtensions() { - return $async(retrieveAndSaveEnabledExtensionsAsync); + this.retrieveAndSaveEnabledExtensionsAsync = this.retrieveAndSaveEnabledExtensionsAsync.bind(this); + this.retrievePermissionsAsync = this.retrievePermissionsAsync.bind(this); + this.checkForEndpointsAsync = this.checkForEndpointsAsync.bind(this); + this.checkForLatestVersionAsync = this.checkForLatestVersionAsync.bind(this); + this.postLoginSteps = this.postLoginSteps.bind(this); + + this.oAuthLoginAsync = this.oAuthLoginAsync.bind(this); + this.retryLoginSanitizeAsync = this.retryLoginSanitizeAsync.bind(this); + this.internalLoginAsync = this.internalLoginAsync.bind(this); + + this.authenticateUserAsync = this.authenticateUserAsync.bind(this); + + this.manageOauthCodeReturn = this.manageOauthCodeReturn.bind(this); + this.authEnabledFlowAsync = this.authEnabledFlowAsync.bind(this); + this.onInit = this.onInit.bind(this); } - async function retrieveAndSaveEnabledExtensionsAsync() { - try { - await ExtensionService.retrieveAndSaveEnabledExtensions(); - } catch (err) { - Notifications.error('Failure', err, 'Unable to retrieve enabled extensions'); - $scope.state.loginInProgress = false; + /** + * UTILS FUNCTIONS SECTION + */ + + logout() { + this.Authentication.logout(); + this.state.loginInProgress = false; + this.generateOAuthLoginURI(); + } + + error(err, message) { + this.state.AuthenticationError = message; + if (!err) { + err = {}; } + this.Notifications.error('Failure', err, message); + this.state.loginInProgress = false; } - $scope.authenticateUser = function() { - var username = $scope.formValues.Username; - var password = $scope.formValues.Password; - $scope.state.loginInProgress = true; - - Authentication.login(username, password) - .then(function success() { - return retrieveAndSaveEnabledExtensions(); - }) - .then(function () { - checkForEndpoints(); - }) - .catch(function error() { - SettingsService.publicSettings() - .then(function success(settings) { - if (settings.AuthenticationMethod === 1) { - return Authentication.login($sanitize(username), $sanitize(password)); - } - return $q.reject(); - }) - .then(function success() { - return retrieveAndSaveEnabledExtensions(); - }) - .then(function() { - $state.go('portainer.updatePassword'); - }) - .catch(function error() { - $scope.state.AuthenticationError = 'Invalid credentials'; - $scope.state.loginInProgress = false; - }); - }); - }; - - function unauthenticatedFlow() { - EndpointService.endpoints(0, 100) - .then(function success(endpoints) { - if (endpoints.value.length === 0) { - $state.go('portainer.init.endpoint'); - } else { - $state.go('portainer.home'); - } - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve endpoints'); - }); - } - - function authenticatedFlow() { - UserService.administratorExists() - .then(function success(exists) { - if (!exists) { - $state.go('portainer.init.admin'); - } - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to verify administrator account existence'); - }); - } - - function checkForEndpoints() { - EndpointService.endpoints(0, 100) - .then(function success(data) { - var endpoints = data.value; - - if (endpoints.length === 0 && Authentication.isAdmin()) { - $state.go('portainer.init.endpoint'); - } else { - $state.go('portainer.home'); - } - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve endpoints'); - $scope.state.loginInProgress = false; - }); - } - - function determineOauthProvider(LoginURI) { + determineOauthProvider(LoginURI) { if (LoginURI.indexOf('login.microsoftonline.com') !== -1) { return 'Microsoft'; } @@ -116,54 +80,217 @@ function($async, $q, $scope, $state, $stateParams, $sanitize, Authentication, Us return 'OAuth'; } - function initView() { - SettingsService.publicSettings() - .then(function success(settings) { - $scope.AuthenticationMethod = settings.AuthenticationMethod; - $scope.OAuthLoginURI = settings.OAuthLoginURI; - $scope.state.OAuthProvider = determineOauthProvider(settings.OAuthLoginURI); - }); + generateState() { + const uuid = uuidv4(); + this.LocalStorage.storeLoginStateUUID(uuid); + return '&state=' + uuid; + } - if ($stateParams.logout || $stateParams.error) { - Authentication.logout(); - $scope.state.AuthenticationError = $stateParams.error; - $scope.state.isInOAuthProcess = false; - return; - } + generateOAuthLoginURI() { + this.OAuthLoginURI = this.state.OAuthLoginURI + this.generateState(); + } - if (Authentication.isAuthenticated()) { - $state.go('portainer.home'); - } + hasValidState(state) { + const savedUUID = this.LocalStorage.getLoginStateUUID(); + return savedUUID && state && savedUUID === state; + } - var authenticationEnabled = $scope.applicationState.application.authentication; - if (!authenticationEnabled) { - unauthenticatedFlow(); - } else { - authenticatedFlow(); - } + /** + * END UTILS FUNCTIONS SECTION + */ - var code = URLHelper.getParameter('code'); - if (code) { - oAuthLogin(code); - } else { - $scope.state.isInOAuthProcess = false; + /** + * POST LOGIN STEPS SECTION + */ + + async retrievePermissionsAsync() { + try { + await this.Authentication.retrievePermissions(); + } catch (err) { + this.state.permissionsError = true; + this.logout(); + this.error(err, 'Unable to retrieve permissions.'); } } - function oAuthLogin(code) { - return Authentication.OAuthLogin(code) - .then(function success() { - return retrieveAndSaveEnabledExtensions(); - }) - .then(function() { - URLHelper.cleanParameters(); - }) - .catch(function error() { - $scope.state.AuthenticationError = 'Unable to login via OAuth'; - $scope.state.isInOAuthProcess = false; - }); + async retrieveAndSaveEnabledExtensionsAsync() { + try { + await this.ExtensionService.retrieveAndSaveEnabledExtensions(); + } catch (err) { + this.error(err, 'Unable to retrieve enabled extensions'); + } } + async checkForEndpointsAsync(noAuth) { + try { + const endpoints = await this.EndpointService.endpoints(0, 1); + const isAdmin = noAuth || this.Authentication.isAdmin(); - initView(); -}]); + if (endpoints.value.length === 0 && isAdmin) { + return this.$state.go('portainer.init.endpoint'); + } else { + return this.$state.go('portainer.home'); + } + } catch (err) { + this.error(err, 'Unable to retrieve endpoints'); + } + } + + async checkForLatestVersionAsync() { + let versionInfo = { + UpdateAvailable: false, + LatestVersion: '' + }; + + try { + const versionStatus = await this.StatusService.version(); + if (versionStatus.UpdateAvailable) { + versionInfo.UpdateAvailable = true; + versionInfo.LatestVersion = versionStatus.LatestVersion; + } + } finally { + this.StateManager.setVersionInfo(versionInfo); + } + } + + async postLoginSteps() { + await this.retrievePermissionsAsync(); + await this.retrieveAndSaveEnabledExtensionsAsync(); + await this.checkForEndpointsAsync(false); + await this.checkForLatestVersionAsync(); + } + /** + * END POST LOGIN STEPS SECTION + */ + + /** + * LOGIN METHODS SECTION + */ + + async oAuthLoginAsync(code) { + try { + await this.Authentication.OAuthLogin(code); + this.URLHelper.cleanParameters(); + } catch (err) { + this.error(err, 'Unable to login via OAuth'); + } + } + + async retryLoginSanitizeAsync(username, password) { + try { + await this.internalLoginAsync(this.$sanitize(username), this.$sanitize(password)); + this.$state.go('portainer.updatePassword'); + } catch (err) { + this.error(err, 'Invalid credentials'); + } + } + + async internalLoginAsync(username, password) { + await this.Authentication.login(username, password); + await this.postLoginSteps(); + } + + /** + * END LOGIN METHODS SECTION + */ + + /** + * AUTHENTICATE USER SECTION + */ + + async authenticateUserAsync() { + try { + var username = this.formValues.Username; + var password = this.formValues.Password; + this.state.loginInProgress = true; + await this.internalLoginAsync(username, password); + } catch (err) { + if (this.state.permissionsError) { + return; + } + // This login retry is necessary to avoid conflicts with databases + // containing users created before Portainer 1.19.2 + // See https://github.com/portainer/portainer/issues/2199 for more info + await this.retryLoginSanitizeAsync(username, password); + } + } + + authenticateUser() { + return this.$async(this.authenticateUserAsync) + } + + /** + * END AUTHENTICATE USER SECTION + */ + + /** + * ON INIT SECTION + */ + async manageOauthCodeReturn(code, state) { + if (this.hasValidState(state)) { + await this.oAuthLoginAsync(code); + } else { + this.error(null, 'Invalid OAuth state, try again.'); + } + } + + async authEnabledFlowAsync() { + try { + const exists = await this.UserService.administratorExists(); + if (!exists) { + this.$state.go('portainer.init.admin'); + } + } catch (err) { + this.error(err, 'Unable to verify administrator account existence') + } + } + + async onInit() { + try { + const settings = await this.SettingsService.publicSettings(); + this.AuthenticationMethod = settings.AuthenticationMethod; + this.state.OAuthProvider = this.determineOauthProvider(settings.OAuthLoginURI); + this.state.OAuthLoginURI = settings.OAuthLoginURI; + + const code = this.URLHelper.getParameter('code'); + const state = this.URLHelper.getParameter('state'); + if (code && state) { + await this.manageOauthCodeReturn(code, state); + this.generateOAuthLoginURI(); + return; + } + this.generateOAuthLoginURI(); + + if (this.$stateParams.logout || this.$stateParams.error) { + this.logout(); + this.state.AuthenticationError = this.$stateParams.error; + return; + } + + if (this.Authentication.isAuthenticated()) { + await this.postLoginSteps(); + } + this.state.loginInProgress = false; + + const authenticationEnabled = this.$scope.applicationState.application.authentication; + if (!authenticationEnabled) { + await this.checkForEndpointsAsync(true); + } else { + await this.authEnabledFlowAsync(); + } + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve public settings'); + } + } + + $onInit() { + return this.$async(this.onInit); + } + + /** + * END ON INIT SECTION + */ +} + +export default AuthenticationController; +angular.module('portainer.app').controller('AuthenticationController', AuthenticationController); diff --git a/app/portainer/views/endpoints/create/createEndpointController.js b/app/portainer/views/endpoints/create/createEndpointController.js index b2fe0dcee..1cdf54409 100644 --- a/app/portainer/views/endpoints/create/createEndpointController.js +++ b/app/portainer/views/endpoints/create/createEndpointController.js @@ -5,7 +5,7 @@ angular.module('portainer.app') function ($q, $scope, $state, $filter, clipboard, EndpointService, GroupService, TagService, Notifications) { $scope.state = { - EnvironmentType: 'docker', + EnvironmentType: 'agent', actionInProgress: false }; diff --git a/app/portainer/views/endpoints/create/createendpoint.html b/app/portainer/views/endpoints/create/createendpoint.html index a011472ea..b930e2518 100644 --- a/app/portainer/views/endpoints/create/createendpoint.html +++ b/app/portainer/views/endpoints/create/createendpoint.html @@ -16,16 +16,6 @@
-
- - -
+
+ + +