diff --git a/.babelrc b/.babelrc index 32beabad5..6db8c8c05 100644 --- a/.babelrc +++ b/.babelrc @@ -5,7 +5,7 @@ "@babel/preset-env", { "modules": false, - "useBuiltIns": "usage" + "useBuiltIns": "entry" } ] ] diff --git a/.eslintrc.yml b/.eslintrc.yml index eb1b1779a..8e667de7c 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -24,7 +24,7 @@ rules: # no-cond-assign: error # no-console: off # no-constant-condition: error -# no-control-regex: error + no-control-regex: off # no-debugger: error # no-dupe-args: error # no-dupe-keys: error diff --git a/api/access_control.go b/api/access_control.go new file mode 100644 index 000000000..7c767ce03 --- /dev/null +++ b/api/access_control.go @@ -0,0 +1,154 @@ +package portainer + +// NewPrivateResourceControl will create a new private resource control associated to the resource specified by the +// identifier and type parameters. It automatically assigns it to the user specified by the userID parameter. +func NewPrivateResourceControl(resourceIdentifier string, resourceType ResourceControlType, userID UserID) *ResourceControl { + return &ResourceControl{ + Type: resourceType, + ResourceID: resourceIdentifier, + SubResourceIDs: []string{}, + UserAccesses: []UserResourceAccess{ + { + UserID: userID, + AccessLevel: ReadWriteAccessLevel, + }, + }, + TeamAccesses: []TeamResourceAccess{}, + AdministratorsOnly: false, + Public: false, + System: false, + } +} + +// NewSystemResourceControl will create a new public resource control with the System flag set to true. +// These kind of resource control are not persisted and are created on the fly by the Portainer API. +func NewSystemResourceControl(resourceIdentifier string, resourceType ResourceControlType) *ResourceControl { + return &ResourceControl{ + Type: resourceType, + ResourceID: resourceIdentifier, + SubResourceIDs: []string{}, + UserAccesses: []UserResourceAccess{}, + TeamAccesses: []TeamResourceAccess{}, + AdministratorsOnly: false, + Public: true, + System: true, + } +} + +// NewPublicResourceControl will create a new public resource control. +func NewPublicResourceControl(resourceIdentifier string, resourceType ResourceControlType) *ResourceControl { + return &ResourceControl{ + Type: resourceType, + ResourceID: resourceIdentifier, + SubResourceIDs: []string{}, + UserAccesses: []UserResourceAccess{}, + TeamAccesses: []TeamResourceAccess{}, + AdministratorsOnly: false, + Public: true, + System: false, + } +} + +// NewRestrictedResourceControl will create a new resource control with user and team accesses restrictions. +func NewRestrictedResourceControl(resourceIdentifier string, resourceType ResourceControlType, userIDs []UserID, teamIDs []TeamID) *ResourceControl { + userAccesses := make([]UserResourceAccess, 0) + teamAccesses := make([]TeamResourceAccess, 0) + + for _, id := range userIDs { + access := UserResourceAccess{ + UserID: id, + AccessLevel: ReadWriteAccessLevel, + } + + userAccesses = append(userAccesses, access) + } + + for _, id := range teamIDs { + access := TeamResourceAccess{ + TeamID: id, + AccessLevel: ReadWriteAccessLevel, + } + + teamAccesses = append(teamAccesses, access) + } + + return &ResourceControl{ + Type: resourceType, + ResourceID: resourceIdentifier, + SubResourceIDs: []string{}, + UserAccesses: userAccesses, + TeamAccesses: teamAccesses, + AdministratorsOnly: false, + Public: false, + System: false, + } +} + +// DecorateStacks will iterate through a list of stacks, check for an associated resource control for each +// stack and decorate the stack element if a resource control is found. +func DecorateStacks(stacks []Stack, resourceControls []ResourceControl) []Stack { + for idx, stack := range stacks { + + resourceControl := GetResourceControlByResourceIDAndType(stack.Name, StackResourceControl, resourceControls) + if resourceControl != nil { + stacks[idx].ResourceControl = resourceControl + } + } + + return stacks +} + +// FilterAuthorizedStacks returns a list of decorated stacks filtered through resource control access checks. +func FilterAuthorizedStacks(stacks []Stack, user *User, userTeamIDs []TeamID, rbacEnabled bool) []Stack { + authorizedStacks := make([]Stack, 0) + + for _, stack := range stacks { + _, ok := user.EndpointAuthorizations[stack.EndpointID][EndpointResourcesAccess] + if rbacEnabled && ok { + authorizedStacks = append(authorizedStacks, stack) + continue + } + + if stack.ResourceControl != nil && UserCanAccessResource(user.ID, userTeamIDs, stack.ResourceControl) { + authorizedStacks = append(authorizedStacks, stack) + } + } + + return authorizedStacks +} + +// UserCanAccessResource will valide that a user has permissions defined in the specified resource control +// based on its identifier and the team(s) he is part of. +func UserCanAccessResource(userID UserID, userTeamIDs []TeamID, resourceControl *ResourceControl) bool { + for _, authorizedUserAccess := range resourceControl.UserAccesses { + if userID == authorizedUserAccess.UserID { + return true + } + } + + for _, authorizedTeamAccess := range resourceControl.TeamAccesses { + for _, userTeamID := range userTeamIDs { + if userTeamID == authorizedTeamAccess.TeamID { + return true + } + } + } + + return resourceControl.Public +} + +// GetResourceControlByResourceIDAndType retrieves the first matching resource control in a set of resource controls +// based on the specified id and resource type parameters. +func GetResourceControlByResourceIDAndType(resourceID string, resourceType ResourceControlType, resourceControls []ResourceControl) *ResourceControl { + for _, resourceControl := range resourceControls { + if resourceID == resourceControl.ResourceID && resourceType == resourceControl.Type { + return &resourceControl + } + for _, subResourceID := range resourceControl.SubResourceIDs { + if resourceID == subResourceID { + return &resourceControl + } + } + } + return nil +} diff --git a/api/archive/zip.go b/api/archive/zip.go index 1c46c9f10..5952e98d0 100644 --- a/api/archive/zip.go +++ b/api/archive/zip.go @@ -17,32 +17,38 @@ func UnzipArchive(archiveData []byte, dest string) error { } for _, zipFile := range zipReader.File { - - f, err := zipFile.Open() + err := extractFileFromArchive(zipFile, dest) if err != nil { return err } - defer f.Close() - - data, err := ioutil.ReadAll(f) - if err != nil { - return err - } - - fpath := filepath.Join(dest, zipFile.Name) - - outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, zipFile.Mode()) - if err != nil { - return err - } - - _, err = io.Copy(outFile, bytes.NewReader(data)) - if err != nil { - return err - } - - outFile.Close() } return nil } + +func extractFileFromArchive(file *zip.File, dest string) error { + f, err := file.Open() + if err != nil { + return err + } + defer f.Close() + + data, err := ioutil.ReadAll(f) + if err != nil { + return err + } + + fpath := filepath.Join(dest, file.Name) + + outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode()) + if err != nil { + return err + } + + _, err = io.Copy(outFile, bytes.NewReader(data)) + if err != nil { + return err + } + + return outFile.Close() +} diff --git a/api/authorizations.go b/api/authorizations.go index e2101bdae..2309aec23 100644 --- a/api/authorizations.go +++ b/api/authorizations.go @@ -34,6 +34,394 @@ func NewAuthorizationService(parameters *AuthorizationServiceParameters) *Author } } +// DefaultEndpointAuthorizationsForEndpointAdministratorRole returns the default endpoint authorizations +// associated to the endpoint administrator role. +func DefaultEndpointAuthorizationsForEndpointAdministratorRole() Authorizations { + return map[Authorization]bool{ + OperationDockerContainerArchiveInfo: true, + OperationDockerContainerList: true, + OperationDockerContainerExport: true, + OperationDockerContainerChanges: true, + OperationDockerContainerInspect: true, + OperationDockerContainerTop: true, + OperationDockerContainerLogs: true, + OperationDockerContainerStats: true, + OperationDockerContainerAttachWebsocket: true, + OperationDockerContainerArchive: true, + OperationDockerContainerCreate: true, + OperationDockerContainerPrune: true, + OperationDockerContainerKill: true, + OperationDockerContainerPause: true, + OperationDockerContainerUnpause: true, + OperationDockerContainerRestart: true, + OperationDockerContainerStart: true, + OperationDockerContainerStop: true, + OperationDockerContainerWait: true, + OperationDockerContainerResize: true, + OperationDockerContainerAttach: true, + OperationDockerContainerExec: true, + OperationDockerContainerRename: true, + OperationDockerContainerUpdate: true, + OperationDockerContainerPutContainerArchive: true, + OperationDockerContainerDelete: true, + OperationDockerImageList: true, + OperationDockerImageSearch: true, + OperationDockerImageGetAll: true, + OperationDockerImageGet: true, + OperationDockerImageHistory: true, + OperationDockerImageInspect: true, + OperationDockerImageLoad: true, + OperationDockerImageCreate: true, + OperationDockerImagePrune: true, + OperationDockerImagePush: true, + OperationDockerImageTag: true, + OperationDockerImageDelete: true, + OperationDockerImageCommit: true, + OperationDockerImageBuild: true, + OperationDockerNetworkList: true, + OperationDockerNetworkInspect: true, + OperationDockerNetworkCreate: true, + OperationDockerNetworkConnect: true, + OperationDockerNetworkDisconnect: true, + OperationDockerNetworkPrune: true, + OperationDockerNetworkDelete: true, + OperationDockerVolumeList: true, + OperationDockerVolumeInspect: true, + OperationDockerVolumeCreate: true, + OperationDockerVolumePrune: true, + OperationDockerVolumeDelete: true, + OperationDockerExecInspect: true, + OperationDockerExecStart: true, + OperationDockerExecResize: true, + OperationDockerSwarmInspect: true, + OperationDockerSwarmUnlockKey: true, + OperationDockerSwarmInit: true, + OperationDockerSwarmJoin: true, + OperationDockerSwarmLeave: true, + OperationDockerSwarmUpdate: true, + OperationDockerSwarmUnlock: true, + OperationDockerNodeList: true, + OperationDockerNodeInspect: true, + OperationDockerNodeUpdate: true, + OperationDockerNodeDelete: true, + OperationDockerServiceList: true, + OperationDockerServiceInspect: true, + OperationDockerServiceLogs: true, + OperationDockerServiceCreate: true, + OperationDockerServiceUpdate: true, + OperationDockerServiceDelete: true, + OperationDockerSecretList: true, + OperationDockerSecretInspect: true, + OperationDockerSecretCreate: true, + OperationDockerSecretUpdate: true, + OperationDockerSecretDelete: true, + OperationDockerConfigList: true, + OperationDockerConfigInspect: true, + OperationDockerConfigCreate: true, + OperationDockerConfigUpdate: true, + OperationDockerConfigDelete: true, + OperationDockerTaskList: true, + OperationDockerTaskInspect: true, + OperationDockerTaskLogs: true, + OperationDockerPluginList: true, + OperationDockerPluginPrivileges: true, + OperationDockerPluginInspect: true, + OperationDockerPluginPull: true, + OperationDockerPluginCreate: true, + OperationDockerPluginEnable: true, + OperationDockerPluginDisable: true, + OperationDockerPluginPush: true, + OperationDockerPluginUpgrade: true, + OperationDockerPluginSet: true, + OperationDockerPluginDelete: true, + OperationDockerSessionStart: true, + OperationDockerDistributionInspect: true, + OperationDockerBuildPrune: true, + OperationDockerBuildCancel: true, + OperationDockerPing: true, + OperationDockerInfo: true, + OperationDockerVersion: true, + OperationDockerEvents: true, + OperationDockerSystem: true, + OperationDockerUndefined: true, + OperationDockerAgentPing: true, + OperationDockerAgentList: true, + OperationDockerAgentHostInfo: true, + OperationDockerAgentBrowseDelete: true, + OperationDockerAgentBrowseGet: true, + OperationDockerAgentBrowseList: true, + OperationDockerAgentBrowsePut: true, + OperationDockerAgentBrowseRename: true, + OperationDockerAgentUndefined: true, + OperationPortainerResourceControlCreate: true, + OperationPortainerResourceControlUpdate: true, + OperationPortainerStackList: true, + OperationPortainerStackInspect: true, + OperationPortainerStackFile: true, + OperationPortainerStackCreate: true, + OperationPortainerStackMigrate: true, + OperationPortainerStackUpdate: true, + OperationPortainerStackDelete: true, + OperationPortainerWebsocketExec: true, + OperationPortainerWebhookList: true, + OperationPortainerWebhookCreate: true, + OperationPortainerWebhookDelete: true, + OperationIntegrationStoridgeAdmin: true, + EndpointResourcesAccess: true, + } +} + +// DefaultEndpointAuthorizationsForHelpDeskRole returns the default endpoint authorizations +// associated to the helpdesk role. +func DefaultEndpointAuthorizationsForHelpDeskRole(volumeBrowsingAuthorizations bool) Authorizations { + authorizations := map[Authorization]bool{ + OperationDockerContainerArchiveInfo: true, + OperationDockerContainerList: true, + OperationDockerContainerChanges: true, + OperationDockerContainerInspect: true, + OperationDockerContainerTop: true, + OperationDockerContainerLogs: true, + OperationDockerContainerStats: true, + OperationDockerImageList: true, + OperationDockerImageSearch: true, + OperationDockerImageGetAll: true, + OperationDockerImageGet: true, + OperationDockerImageHistory: true, + OperationDockerImageInspect: true, + OperationDockerNetworkList: true, + OperationDockerNetworkInspect: true, + OperationDockerVolumeList: true, + OperationDockerVolumeInspect: true, + OperationDockerSwarmInspect: true, + OperationDockerNodeList: true, + OperationDockerNodeInspect: true, + OperationDockerServiceList: true, + OperationDockerServiceInspect: true, + OperationDockerServiceLogs: true, + OperationDockerSecretList: true, + OperationDockerSecretInspect: true, + OperationDockerConfigList: true, + OperationDockerConfigInspect: true, + OperationDockerTaskList: true, + OperationDockerTaskInspect: true, + OperationDockerTaskLogs: true, + OperationDockerPluginList: true, + OperationDockerDistributionInspect: true, + OperationDockerPing: true, + OperationDockerInfo: true, + OperationDockerVersion: true, + OperationDockerEvents: true, + OperationDockerSystem: true, + OperationDockerAgentPing: true, + OperationDockerAgentList: true, + OperationDockerAgentHostInfo: true, + OperationPortainerStackList: true, + OperationPortainerStackInspect: true, + OperationPortainerStackFile: true, + OperationPortainerWebhookList: true, + EndpointResourcesAccess: true, + } + + if volumeBrowsingAuthorizations { + authorizations[OperationDockerAgentBrowseGet] = true + authorizations[OperationDockerAgentBrowseList] = true + } + + return authorizations +} + +// DefaultEndpointAuthorizationsForStandardUserRole returns the default endpoint authorizations +// associated to the standard user role. +func DefaultEndpointAuthorizationsForStandardUserRole(volumeBrowsingAuthorizations bool) Authorizations { + authorizations := map[Authorization]bool{ + OperationDockerContainerArchiveInfo: true, + OperationDockerContainerList: true, + OperationDockerContainerExport: true, + OperationDockerContainerChanges: true, + OperationDockerContainerInspect: true, + OperationDockerContainerTop: true, + OperationDockerContainerLogs: true, + OperationDockerContainerStats: true, + OperationDockerContainerAttachWebsocket: true, + OperationDockerContainerArchive: true, + OperationDockerContainerCreate: true, + OperationDockerContainerKill: true, + OperationDockerContainerPause: true, + OperationDockerContainerUnpause: true, + OperationDockerContainerRestart: true, + OperationDockerContainerStart: true, + OperationDockerContainerStop: true, + OperationDockerContainerWait: true, + OperationDockerContainerResize: true, + OperationDockerContainerAttach: true, + OperationDockerContainerExec: true, + OperationDockerContainerRename: true, + OperationDockerContainerUpdate: true, + OperationDockerContainerPutContainerArchive: true, + OperationDockerContainerDelete: true, + OperationDockerImageList: true, + OperationDockerImageSearch: true, + OperationDockerImageGetAll: true, + OperationDockerImageGet: true, + OperationDockerImageHistory: true, + OperationDockerImageInspect: true, + OperationDockerImageLoad: true, + OperationDockerImageCreate: true, + OperationDockerImagePush: true, + OperationDockerImageTag: true, + OperationDockerImageDelete: true, + OperationDockerImageCommit: true, + OperationDockerImageBuild: true, + OperationDockerNetworkList: true, + OperationDockerNetworkInspect: true, + OperationDockerNetworkCreate: true, + OperationDockerNetworkConnect: true, + OperationDockerNetworkDisconnect: true, + OperationDockerNetworkDelete: true, + OperationDockerVolumeList: true, + OperationDockerVolumeInspect: true, + OperationDockerVolumeCreate: true, + OperationDockerVolumeDelete: true, + OperationDockerExecInspect: true, + OperationDockerExecStart: true, + OperationDockerExecResize: true, + OperationDockerSwarmInspect: true, + OperationDockerSwarmUnlockKey: true, + OperationDockerSwarmInit: true, + OperationDockerSwarmJoin: true, + OperationDockerSwarmLeave: true, + OperationDockerSwarmUpdate: true, + OperationDockerSwarmUnlock: true, + OperationDockerNodeList: true, + OperationDockerNodeInspect: true, + OperationDockerNodeUpdate: true, + OperationDockerNodeDelete: true, + OperationDockerServiceList: true, + OperationDockerServiceInspect: true, + OperationDockerServiceLogs: true, + OperationDockerServiceCreate: true, + OperationDockerServiceUpdate: true, + OperationDockerServiceDelete: true, + OperationDockerSecretList: true, + OperationDockerSecretInspect: true, + OperationDockerSecretCreate: true, + OperationDockerSecretUpdate: true, + OperationDockerSecretDelete: true, + OperationDockerConfigList: true, + OperationDockerConfigInspect: true, + OperationDockerConfigCreate: true, + OperationDockerConfigUpdate: true, + OperationDockerConfigDelete: true, + OperationDockerTaskList: true, + OperationDockerTaskInspect: true, + OperationDockerTaskLogs: true, + OperationDockerPluginList: true, + OperationDockerPluginPrivileges: true, + OperationDockerPluginInspect: true, + OperationDockerPluginPull: true, + OperationDockerPluginCreate: true, + OperationDockerPluginEnable: true, + OperationDockerPluginDisable: true, + OperationDockerPluginPush: true, + OperationDockerPluginUpgrade: true, + OperationDockerPluginSet: true, + OperationDockerPluginDelete: true, + OperationDockerSessionStart: true, + OperationDockerDistributionInspect: true, + OperationDockerBuildPrune: true, + OperationDockerBuildCancel: true, + OperationDockerPing: true, + OperationDockerInfo: true, + OperationDockerVersion: true, + OperationDockerEvents: true, + OperationDockerSystem: true, + OperationDockerUndefined: true, + OperationDockerAgentPing: true, + OperationDockerAgentList: true, + OperationDockerAgentHostInfo: true, + OperationDockerAgentUndefined: true, + OperationPortainerResourceControlUpdate: true, + OperationPortainerStackList: true, + OperationPortainerStackInspect: true, + OperationPortainerStackFile: true, + OperationPortainerStackCreate: true, + OperationPortainerStackMigrate: true, + OperationPortainerStackUpdate: true, + OperationPortainerStackDelete: true, + OperationPortainerWebsocketExec: true, + OperationPortainerWebhookList: true, + OperationPortainerWebhookCreate: true, + } + + if volumeBrowsingAuthorizations { + authorizations[OperationDockerAgentBrowseGet] = true + authorizations[OperationDockerAgentBrowseList] = true + authorizations[OperationDockerAgentBrowseDelete] = true + authorizations[OperationDockerAgentBrowsePut] = true + authorizations[OperationDockerAgentBrowseRename] = true + } + + return authorizations +} + +// DefaultEndpointAuthorizationsForReadOnlyUserRole returns the default endpoint authorizations +// associated to the readonly user role. +func DefaultEndpointAuthorizationsForReadOnlyUserRole(volumeBrowsingAuthorizations bool) Authorizations { + authorizations := map[Authorization]bool{ + OperationDockerContainerArchiveInfo: true, + OperationDockerContainerList: true, + OperationDockerContainerChanges: true, + OperationDockerContainerInspect: true, + OperationDockerContainerTop: true, + OperationDockerContainerLogs: true, + OperationDockerContainerStats: true, + OperationDockerImageList: true, + OperationDockerImageSearch: true, + OperationDockerImageGetAll: true, + OperationDockerImageGet: true, + OperationDockerImageHistory: true, + OperationDockerImageInspect: true, + OperationDockerNetworkList: true, + OperationDockerNetworkInspect: true, + OperationDockerVolumeList: true, + OperationDockerVolumeInspect: true, + OperationDockerSwarmInspect: true, + OperationDockerNodeList: true, + OperationDockerNodeInspect: true, + OperationDockerServiceList: true, + OperationDockerServiceInspect: true, + OperationDockerServiceLogs: true, + OperationDockerSecretList: true, + OperationDockerSecretInspect: true, + OperationDockerConfigList: true, + OperationDockerConfigInspect: true, + OperationDockerTaskList: true, + OperationDockerTaskInspect: true, + OperationDockerTaskLogs: true, + OperationDockerPluginList: true, + OperationDockerDistributionInspect: true, + OperationDockerPing: true, + OperationDockerInfo: true, + OperationDockerVersion: true, + OperationDockerEvents: true, + OperationDockerSystem: true, + OperationDockerAgentPing: true, + OperationDockerAgentList: true, + OperationDockerAgentHostInfo: true, + OperationPortainerStackList: true, + OperationPortainerStackInspect: true, + OperationPortainerStackFile: true, + OperationPortainerWebhookList: true, + } + + if volumeBrowsingAuthorizations { + authorizations[OperationDockerAgentBrowseGet] = true + authorizations[OperationDockerAgentBrowseList] = true + } + + return authorizations +} + // DefaultPortainerAuthorizations returns the default Portainer authorizations used by non-admin users. func DefaultPortainerAuthorizations() Authorizations { return map[Authorization]bool{ @@ -164,7 +552,7 @@ func (service *AuthorizationService) RemoveTeamAccessPolicies(teamID TeamID) err } } - return nil + return service.UpdateUsersAuthorizations() } // RemoveUserAccessPolicies will remove all existing access policies associated to the specified user @@ -383,37 +771,25 @@ func getAuthorizationsFromTeamEndpointGroupPolicies(memberships []TeamMembership } func getAuthorizationsFromRoles(roleIdentifiers []RoleID, roles []Role) Authorizations { - var roleAuthorizations []Authorizations + var associatedRoles []Role + for _, id := range roleIdentifiers { for _, role := range roles { if role.ID == id { - roleAuthorizations = append(roleAuthorizations, role.Authorizations) + associatedRoles = append(associatedRoles, role) break } } } - processedAuthorizations := make(Authorizations) - if len(roleAuthorizations) > 0 { - processedAuthorizations = roleAuthorizations[0] - for idx, authorizations := range roleAuthorizations { - if idx == 0 { - continue - } - processedAuthorizations = mergeAuthorizations(processedAuthorizations, authorizations) + var authorizations Authorizations + highestPriority := 0 + for _, role := range associatedRoles { + if role.Priority > highestPriority { + highestPriority = role.Priority + authorizations = role.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 + return authorizations } diff --git a/api/bolt/init.go b/api/bolt/init.go index a9b941179..bc6e39be5 100644 --- a/api/bolt/init.go +++ b/api/bolt/init.go @@ -32,141 +32,10 @@ func (store *Store) Init() error { if len(roles) == 0 { environmentAdministratorRole := &portainer.Role{ - Name: "Endpoint administrator", - Description: "Full control of all resources in an endpoint", - Authorizations: map[portainer.Authorization]bool{ - portainer.OperationDockerContainerArchiveInfo: true, - portainer.OperationDockerContainerList: true, - portainer.OperationDockerContainerExport: true, - portainer.OperationDockerContainerChanges: true, - portainer.OperationDockerContainerInspect: true, - portainer.OperationDockerContainerTop: true, - portainer.OperationDockerContainerLogs: true, - portainer.OperationDockerContainerStats: true, - portainer.OperationDockerContainerAttachWebsocket: true, - portainer.OperationDockerContainerArchive: true, - portainer.OperationDockerContainerCreate: true, - portainer.OperationDockerContainerPrune: true, - portainer.OperationDockerContainerKill: true, - portainer.OperationDockerContainerPause: true, - portainer.OperationDockerContainerUnpause: true, - portainer.OperationDockerContainerRestart: true, - portainer.OperationDockerContainerStart: true, - portainer.OperationDockerContainerStop: true, - portainer.OperationDockerContainerWait: true, - portainer.OperationDockerContainerResize: true, - portainer.OperationDockerContainerAttach: true, - portainer.OperationDockerContainerExec: true, - portainer.OperationDockerContainerRename: true, - portainer.OperationDockerContainerUpdate: true, - portainer.OperationDockerContainerPutContainerArchive: true, - portainer.OperationDockerContainerDelete: true, - portainer.OperationDockerImageList: true, - portainer.OperationDockerImageSearch: true, - portainer.OperationDockerImageGetAll: true, - portainer.OperationDockerImageGet: true, - portainer.OperationDockerImageHistory: true, - portainer.OperationDockerImageInspect: true, - portainer.OperationDockerImageLoad: true, - portainer.OperationDockerImageCreate: true, - portainer.OperationDockerImagePrune: true, - portainer.OperationDockerImagePush: true, - portainer.OperationDockerImageTag: true, - portainer.OperationDockerImageDelete: true, - portainer.OperationDockerImageCommit: true, - portainer.OperationDockerImageBuild: true, - portainer.OperationDockerNetworkList: true, - portainer.OperationDockerNetworkInspect: true, - portainer.OperationDockerNetworkCreate: true, - portainer.OperationDockerNetworkConnect: true, - portainer.OperationDockerNetworkDisconnect: true, - portainer.OperationDockerNetworkPrune: true, - portainer.OperationDockerNetworkDelete: true, - portainer.OperationDockerVolumeList: true, - portainer.OperationDockerVolumeInspect: true, - portainer.OperationDockerVolumeCreate: true, - portainer.OperationDockerVolumePrune: true, - portainer.OperationDockerVolumeDelete: true, - portainer.OperationDockerExecInspect: true, - portainer.OperationDockerExecStart: true, - portainer.OperationDockerExecResize: true, - portainer.OperationDockerSwarmInspect: true, - portainer.OperationDockerSwarmUnlockKey: true, - portainer.OperationDockerSwarmInit: true, - portainer.OperationDockerSwarmJoin: true, - portainer.OperationDockerSwarmLeave: true, - portainer.OperationDockerSwarmUpdate: true, - portainer.OperationDockerSwarmUnlock: true, - portainer.OperationDockerNodeList: true, - portainer.OperationDockerNodeInspect: true, - portainer.OperationDockerNodeUpdate: true, - portainer.OperationDockerNodeDelete: true, - portainer.OperationDockerServiceList: true, - portainer.OperationDockerServiceInspect: true, - portainer.OperationDockerServiceLogs: true, - portainer.OperationDockerServiceCreate: true, - portainer.OperationDockerServiceUpdate: true, - portainer.OperationDockerServiceDelete: true, - portainer.OperationDockerSecretList: true, - portainer.OperationDockerSecretInspect: true, - portainer.OperationDockerSecretCreate: true, - portainer.OperationDockerSecretUpdate: true, - portainer.OperationDockerSecretDelete: true, - portainer.OperationDockerConfigList: true, - portainer.OperationDockerConfigInspect: true, - portainer.OperationDockerConfigCreate: true, - portainer.OperationDockerConfigUpdate: true, - portainer.OperationDockerConfigDelete: true, - portainer.OperationDockerTaskList: true, - portainer.OperationDockerTaskInspect: true, - portainer.OperationDockerTaskLogs: true, - portainer.OperationDockerPluginList: true, - portainer.OperationDockerPluginPrivileges: true, - portainer.OperationDockerPluginInspect: true, - portainer.OperationDockerPluginPull: true, - portainer.OperationDockerPluginCreate: true, - portainer.OperationDockerPluginEnable: true, - portainer.OperationDockerPluginDisable: true, - portainer.OperationDockerPluginPush: true, - portainer.OperationDockerPluginUpgrade: true, - portainer.OperationDockerPluginSet: true, - portainer.OperationDockerPluginDelete: true, - portainer.OperationDockerSessionStart: true, - portainer.OperationDockerDistributionInspect: true, - portainer.OperationDockerBuildPrune: true, - portainer.OperationDockerBuildCancel: true, - portainer.OperationDockerPing: true, - portainer.OperationDockerInfo: true, - portainer.OperationDockerVersion: true, - portainer.OperationDockerEvents: true, - portainer.OperationDockerSystem: true, - portainer.OperationDockerUndefined: true, - 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, - portainer.OperationPortainerResourceControlDelete: true, - portainer.OperationPortainerStackList: true, - portainer.OperationPortainerStackInspect: true, - portainer.OperationPortainerStackFile: true, - portainer.OperationPortainerStackCreate: true, - portainer.OperationPortainerStackMigrate: true, - portainer.OperationPortainerStackUpdate: true, - portainer.OperationPortainerStackDelete: true, - portainer.OperationPortainerWebsocketExec: true, - portainer.OperationPortainerWebhookList: true, - portainer.OperationPortainerWebhookCreate: true, - portainer.OperationPortainerWebhookDelete: true, - portainer.OperationIntegrationStoridgeAdmin: true, - portainer.EndpointResourcesAccess: true, - }, + Name: "Endpoint administrator", + Description: "Full control of all resources in an endpoint", + Priority: 1, + Authorizations: portainer.DefaultEndpointAuthorizationsForEndpointAdministratorRole(), } err = store.RoleService.CreateRole(environmentAdministratorRole) @@ -175,55 +44,10 @@ func (store *Store) Init() error { } environmentReadOnlyUserRole := &portainer.Role{ - Name: "Helpdesk", - Description: "Read-only access of all resources in an endpoint", - Authorizations: map[portainer.Authorization]bool{ - portainer.OperationDockerContainerArchiveInfo: true, - portainer.OperationDockerContainerList: true, - portainer.OperationDockerContainerChanges: true, - portainer.OperationDockerContainerInspect: true, - portainer.OperationDockerContainerTop: true, - portainer.OperationDockerContainerLogs: true, - portainer.OperationDockerContainerStats: true, - portainer.OperationDockerImageList: true, - portainer.OperationDockerImageSearch: true, - portainer.OperationDockerImageGetAll: true, - portainer.OperationDockerImageGet: true, - portainer.OperationDockerImageHistory: true, - portainer.OperationDockerImageInspect: true, - portainer.OperationDockerNetworkList: true, - portainer.OperationDockerNetworkInspect: true, - portainer.OperationDockerVolumeList: true, - portainer.OperationDockerVolumeInspect: true, - portainer.OperationDockerSwarmInspect: true, - portainer.OperationDockerNodeList: true, - portainer.OperationDockerNodeInspect: true, - portainer.OperationDockerServiceList: true, - portainer.OperationDockerServiceInspect: true, - portainer.OperationDockerServiceLogs: true, - portainer.OperationDockerSecretList: true, - portainer.OperationDockerSecretInspect: true, - portainer.OperationDockerConfigList: true, - portainer.OperationDockerConfigInspect: true, - portainer.OperationDockerTaskList: true, - portainer.OperationDockerTaskInspect: true, - portainer.OperationDockerTaskLogs: true, - portainer.OperationDockerPluginList: true, - portainer.OperationDockerDistributionInspect: true, - portainer.OperationDockerPing: true, - portainer.OperationDockerInfo: true, - portainer.OperationDockerVersion: true, - portainer.OperationDockerEvents: true, - portainer.OperationDockerSystem: true, - portainer.OperationDockerAgentPing: true, - portainer.OperationDockerAgentList: true, - portainer.OperationDockerAgentHostInfo: true, - portainer.OperationPortainerStackList: true, - portainer.OperationPortainerStackInspect: true, - portainer.OperationPortainerStackFile: true, - portainer.OperationPortainerWebhookList: true, - portainer.EndpointResourcesAccess: true, - }, + Name: "Helpdesk", + Description: "Read-only access of all resources in an endpoint", + Priority: 2, + Authorizations: portainer.DefaultEndpointAuthorizationsForHelpDeskRole(false), } err = store.RoleService.CreateRole(environmentReadOnlyUserRole) @@ -232,129 +56,10 @@ func (store *Store) Init() error { } standardUserRole := &portainer.Role{ - Name: "Standard user", - Description: "Full control of assigned resources in an endpoint", - Authorizations: map[portainer.Authorization]bool{ - portainer.OperationDockerContainerArchiveInfo: true, - portainer.OperationDockerContainerList: true, - portainer.OperationDockerContainerExport: true, - portainer.OperationDockerContainerChanges: true, - portainer.OperationDockerContainerInspect: true, - portainer.OperationDockerContainerTop: true, - portainer.OperationDockerContainerLogs: true, - portainer.OperationDockerContainerStats: true, - portainer.OperationDockerContainerAttachWebsocket: true, - portainer.OperationDockerContainerArchive: true, - portainer.OperationDockerContainerCreate: true, - portainer.OperationDockerContainerKill: true, - portainer.OperationDockerContainerPause: true, - portainer.OperationDockerContainerUnpause: true, - portainer.OperationDockerContainerRestart: true, - portainer.OperationDockerContainerStart: true, - portainer.OperationDockerContainerStop: true, - portainer.OperationDockerContainerWait: true, - portainer.OperationDockerContainerResize: true, - portainer.OperationDockerContainerAttach: true, - portainer.OperationDockerContainerExec: true, - portainer.OperationDockerContainerRename: true, - portainer.OperationDockerContainerUpdate: true, - portainer.OperationDockerContainerPutContainerArchive: true, - portainer.OperationDockerContainerDelete: true, - portainer.OperationDockerImageList: true, - portainer.OperationDockerImageSearch: true, - portainer.OperationDockerImageGetAll: true, - portainer.OperationDockerImageGet: true, - portainer.OperationDockerImageHistory: true, - portainer.OperationDockerImageInspect: true, - portainer.OperationDockerImageLoad: true, - portainer.OperationDockerImageCreate: true, - portainer.OperationDockerImagePush: true, - portainer.OperationDockerImageTag: true, - portainer.OperationDockerImageDelete: true, - portainer.OperationDockerImageCommit: true, - portainer.OperationDockerImageBuild: true, - portainer.OperationDockerNetworkList: true, - portainer.OperationDockerNetworkInspect: true, - portainer.OperationDockerNetworkCreate: true, - portainer.OperationDockerNetworkConnect: true, - portainer.OperationDockerNetworkDisconnect: true, - portainer.OperationDockerNetworkDelete: true, - portainer.OperationDockerVolumeList: true, - portainer.OperationDockerVolumeInspect: true, - portainer.OperationDockerVolumeCreate: true, - portainer.OperationDockerVolumeDelete: true, - portainer.OperationDockerExecInspect: true, - portainer.OperationDockerExecStart: true, - portainer.OperationDockerExecResize: true, - portainer.OperationDockerSwarmInspect: true, - portainer.OperationDockerSwarmUnlockKey: true, - portainer.OperationDockerSwarmInit: true, - portainer.OperationDockerSwarmJoin: true, - portainer.OperationDockerSwarmLeave: true, - portainer.OperationDockerSwarmUpdate: true, - portainer.OperationDockerSwarmUnlock: true, - portainer.OperationDockerNodeList: true, - portainer.OperationDockerNodeInspect: true, - portainer.OperationDockerNodeUpdate: true, - portainer.OperationDockerNodeDelete: true, - portainer.OperationDockerServiceList: true, - portainer.OperationDockerServiceInspect: true, - portainer.OperationDockerServiceLogs: true, - portainer.OperationDockerServiceCreate: true, - portainer.OperationDockerServiceUpdate: true, - portainer.OperationDockerServiceDelete: true, - portainer.OperationDockerSecretList: true, - portainer.OperationDockerSecretInspect: true, - portainer.OperationDockerSecretCreate: true, - portainer.OperationDockerSecretUpdate: true, - portainer.OperationDockerSecretDelete: true, - portainer.OperationDockerConfigList: true, - portainer.OperationDockerConfigInspect: true, - portainer.OperationDockerConfigCreate: true, - portainer.OperationDockerConfigUpdate: true, - portainer.OperationDockerConfigDelete: true, - portainer.OperationDockerTaskList: true, - portainer.OperationDockerTaskInspect: true, - portainer.OperationDockerTaskLogs: true, - portainer.OperationDockerPluginList: true, - portainer.OperationDockerPluginPrivileges: true, - portainer.OperationDockerPluginInspect: true, - portainer.OperationDockerPluginPull: true, - portainer.OperationDockerPluginCreate: true, - portainer.OperationDockerPluginEnable: true, - portainer.OperationDockerPluginDisable: true, - portainer.OperationDockerPluginPush: true, - portainer.OperationDockerPluginUpgrade: true, - portainer.OperationDockerPluginSet: true, - portainer.OperationDockerPluginDelete: true, - portainer.OperationDockerSessionStart: true, - portainer.OperationDockerDistributionInspect: true, - portainer.OperationDockerBuildPrune: true, - portainer.OperationDockerBuildCancel: true, - portainer.OperationDockerPing: true, - portainer.OperationDockerInfo: true, - portainer.OperationDockerVersion: true, - portainer.OperationDockerEvents: true, - portainer.OperationDockerSystem: true, - portainer.OperationDockerUndefined: true, - portainer.OperationDockerAgentPing: true, - portainer.OperationDockerAgentList: true, - portainer.OperationDockerAgentHostInfo: true, - portainer.OperationDockerAgentUndefined: true, - portainer.OperationPortainerResourceControlCreate: true, - portainer.OperationPortainerResourceControlUpdate: true, - portainer.OperationPortainerResourceControlDelete: true, - portainer.OperationPortainerStackList: true, - portainer.OperationPortainerStackInspect: true, - portainer.OperationPortainerStackFile: true, - portainer.OperationPortainerStackCreate: true, - portainer.OperationPortainerStackMigrate: true, - portainer.OperationPortainerStackUpdate: true, - portainer.OperationPortainerStackDelete: true, - portainer.OperationPortainerWebsocketExec: true, - portainer.OperationPortainerWebhookList: true, - portainer.OperationPortainerWebhookCreate: true, - }, + Name: "Standard user", + Description: "Full control of assigned resources in an endpoint", + Priority: 3, + Authorizations: portainer.DefaultEndpointAuthorizationsForStandardUserRole(false), } err = store.RoleService.CreateRole(standardUserRole) @@ -363,54 +68,10 @@ func (store *Store) Init() error { } readOnlyUserRole := &portainer.Role{ - Name: "Read-only user", - Description: "Read-only access of assigned resources in an endpoint", - Authorizations: map[portainer.Authorization]bool{ - portainer.OperationDockerContainerArchiveInfo: true, - portainer.OperationDockerContainerList: true, - portainer.OperationDockerContainerChanges: true, - portainer.OperationDockerContainerInspect: true, - portainer.OperationDockerContainerTop: true, - portainer.OperationDockerContainerLogs: true, - portainer.OperationDockerContainerStats: true, - portainer.OperationDockerImageList: true, - portainer.OperationDockerImageSearch: true, - portainer.OperationDockerImageGetAll: true, - portainer.OperationDockerImageGet: true, - portainer.OperationDockerImageHistory: true, - portainer.OperationDockerImageInspect: true, - portainer.OperationDockerNetworkList: true, - portainer.OperationDockerNetworkInspect: true, - portainer.OperationDockerVolumeList: true, - portainer.OperationDockerVolumeInspect: true, - portainer.OperationDockerSwarmInspect: true, - portainer.OperationDockerNodeList: true, - portainer.OperationDockerNodeInspect: true, - portainer.OperationDockerServiceList: true, - portainer.OperationDockerServiceInspect: true, - portainer.OperationDockerServiceLogs: true, - portainer.OperationDockerSecretList: true, - portainer.OperationDockerSecretInspect: true, - portainer.OperationDockerConfigList: true, - portainer.OperationDockerConfigInspect: true, - portainer.OperationDockerTaskList: true, - portainer.OperationDockerTaskInspect: true, - portainer.OperationDockerTaskLogs: true, - portainer.OperationDockerPluginList: true, - portainer.OperationDockerDistributionInspect: true, - portainer.OperationDockerPing: true, - portainer.OperationDockerInfo: true, - portainer.OperationDockerVersion: true, - portainer.OperationDockerEvents: true, - portainer.OperationDockerSystem: true, - portainer.OperationDockerAgentPing: true, - portainer.OperationDockerAgentList: true, - portainer.OperationDockerAgentHostInfo: true, - portainer.OperationPortainerStackList: true, - portainer.OperationPortainerStackInspect: true, - portainer.OperationPortainerStackFile: true, - portainer.OperationPortainerWebhookList: true, - }, + Name: "Read-only user", + Description: "Read-only access of assigned resources in an endpoint", + Priority: 4, + Authorizations: portainer.DefaultEndpointAuthorizationsForReadOnlyUserRole(false), } err = store.RoleService.CreateRole(readOnlyUserRole) diff --git a/api/bolt/internal/db.go b/api/bolt/internal/db.go index 8cbb1d2d0..9689fa691 100644 --- a/api/bolt/internal/db.go +++ b/api/bolt/internal/db.go @@ -82,13 +82,15 @@ func DeleteObject(db *bolt.DB, bucketName string, key []byte) error { func GetNextIdentifier(db *bolt.DB, bucketName string) int { var identifier int - db.View(func(tx *bolt.Tx) error { + db.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(bucketName)) - id := bucket.Sequence() + id, err := bucket.NextSequence() + if err != nil { + return err + } identifier = int(id) return nil }) - identifier++ return identifier } diff --git a/api/bolt/migrator/migrate_dbversion20.go b/api/bolt/migrator/migrate_dbversion20.go new file mode 100644 index 000000000..1698f10c3 --- /dev/null +++ b/api/bolt/migrator/migrate_dbversion20.go @@ -0,0 +1,89 @@ +package migrator + +import portainer "github.com/portainer/portainer/api" + +func (m *Migrator) updateResourceControlsToDBVersion22() error { + legacyResourceControls, err := m.resourceControlService.ResourceControls() + if err != nil { + return err + } + + for _, resourceControl := range legacyResourceControls { + resourceControl.AdministratorsOnly = false + + err := m.resourceControlService.UpdateResourceControl(resourceControl.ID, &resourceControl) + if err != nil { + return err + } + } + + return nil +} + +func (m *Migrator) updateUsersAndRolesToDBVersion22() error { + legacyUsers, err := m.userService.Users() + if err != nil { + return err + } + + settings, err := m.settingsService.Settings() + if err != nil { + return err + } + + for _, user := range legacyUsers { + user.PortainerAuthorizations = portainer.DefaultPortainerAuthorizations() + err = m.userService.UpdateUser(user.ID, &user) + if err != nil { + return err + } + } + + endpointAdministratorRole, err := m.roleService.Role(portainer.RoleID(1)) + if err != nil { + return err + } + endpointAdministratorRole.Priority = 1 + endpointAdministratorRole.Authorizations = portainer.DefaultEndpointAuthorizationsForEndpointAdministratorRole() + + err = m.roleService.UpdateRole(endpointAdministratorRole.ID, endpointAdministratorRole) + + helpDeskRole, err := m.roleService.Role(portainer.RoleID(2)) + if err != nil { + return err + } + helpDeskRole.Priority = 2 + helpDeskRole.Authorizations = portainer.DefaultEndpointAuthorizationsForHelpDeskRole(settings.AllowVolumeBrowserForRegularUsers) + + err = m.roleService.UpdateRole(helpDeskRole.ID, helpDeskRole) + + standardUserRole, err := m.roleService.Role(portainer.RoleID(3)) + if err != nil { + return err + } + standardUserRole.Priority = 3 + standardUserRole.Authorizations = portainer.DefaultEndpointAuthorizationsForStandardUserRole(settings.AllowVolumeBrowserForRegularUsers) + + err = m.roleService.UpdateRole(standardUserRole.ID, standardUserRole) + + readOnlyUserRole, err := m.roleService.Role(portainer.RoleID(4)) + if err != nil { + return err + } + readOnlyUserRole.Priority = 4 + readOnlyUserRole.Authorizations = portainer.DefaultEndpointAuthorizationsForReadOnlyUserRole(settings.AllowVolumeBrowserForRegularUsers) + + err = m.roleService.UpdateRole(readOnlyUserRole.ID, readOnlyUserRole) + + 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() +} diff --git a/api/bolt/migrator/migrator.go b/api/bolt/migrator/migrator.go index 048ab11ac..885cc9ab8 100644 --- a/api/bolt/migrator/migrator.go +++ b/api/bolt/migrator/migrator.go @@ -287,5 +287,19 @@ func (m *Migrator) Migrate() error { } } + // Portainer 1.23.0 + // DBVersion 21 is missing as it was shipped as via hotfix 1.22.2 + if m.currentDBVersion < 22 { + err := m.updateResourceControlsToDBVersion22() + if err != nil { + return err + } + + err = m.updateUsersAndRolesToDBVersion22() + if err != nil { + return err + } + } + return m.versionService.StoreDBVersion(portainer.DBVersion) } diff --git a/api/bolt/resourcecontrol/resourcecontrol.go b/api/bolt/resourcecontrol/resourcecontrol.go index d4fdb3026..ef07aff03 100644 --- a/api/bolt/resourcecontrol/resourcecontrol.go +++ b/api/bolt/resourcecontrol/resourcecontrol.go @@ -42,9 +42,10 @@ func (service *Service) ResourceControl(ID portainer.ResourceControlID) (*portai return &resourceControl, nil } -// ResourceControlByResourceID returns a ResourceControl object by checking if the resourceID is equal -// to the main ResourceID or in SubResourceIDs -func (service *Service) ResourceControlByResourceID(resourceID string) (*portainer.ResourceControl, error) { +// ResourceControlByResourceIDAndType returns a ResourceControl object by checking if the resourceID is equal +// to the main ResourceID or in SubResourceIDs. It also performs a check on the resource type. Return nil +// if no ResourceControl was found. +func (service *Service) ResourceControlByResourceIDAndType(resourceID string, resourceType portainer.ResourceControlType) (*portainer.ResourceControl, error) { var resourceControl *portainer.ResourceControl err := service.db.View(func(tx *bolt.Tx) error { @@ -58,7 +59,7 @@ func (service *Service) ResourceControlByResourceID(resourceID string) (*portain return err } - if rc.ResourceID == resourceID { + if rc.ResourceID == resourceID && rc.Type == resourceType { resourceControl = &rc break } @@ -71,10 +72,6 @@ func (service *Service) ResourceControlByResourceID(resourceID string) (*portain } } - if resourceControl == nil { - return portainer.ErrObjectNotFound - } - return nil }) diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 88b0a4e7c..d5ab7cd59 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -489,26 +489,11 @@ func initJobService(dockerClientFactory *docker.ClientFactory) portainer.JobServ func initExtensionManager(fileService portainer.FileService, extensionService portainer.ExtensionService) (portainer.ExtensionManager, error) { extensionManager := exec.NewExtensionManager(fileService, extensionService) - extensions, err := extensionService.Extensions() + err := extensionManager.StartExtensions() if err != nil { return nil, err } - for _, extension := range extensions { - err := extensionManager.EnableExtension(&extension, extension.License.LicenseKey) - if err != nil { - log.Printf("Unable to enable extension: %s [extension: %s]", err.Error(), extension.Name) - extension.Enabled = false - extension.License.Valid = false - } - - err = extensionService.Persist(&extension) - if err != nil { - return nil, err - } - - } - return extensionManager, nil } @@ -639,7 +624,7 @@ func main() { } if len(users) == 0 { - log.Printf("Creating admin user with password hash %s", adminPasswordHash) + log.Println("Created admin user with the given password.") user := &portainer.User{ Username: "admin", Role: portainer.AdministratorRole, diff --git a/api/docker/client.go b/api/docker/client.go index b093f23cf..9b63484e9 100644 --- a/api/docker/client.go +++ b/api/docker/client.go @@ -14,6 +14,7 @@ import ( const ( unsupportedEnvironmentType = portainer.Error("Environment not supported") defaultDockerRequestTimeout = 60 + dockerClientVersion = "1.40" ) // ClientFactory is used to create Docker clients @@ -51,7 +52,7 @@ func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeNam func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) { return client.NewClientWithOpts( client.WithHost(endpoint.URL), - client.WithVersion(portainer.SupportedDockerAPIVersion), + client.WithVersion(dockerClientVersion), ) } @@ -63,7 +64,7 @@ func createTCPClient(endpoint *portainer.Endpoint) (*client.Client, error) { return client.NewClientWithOpts( client.WithHost(endpoint.URL), - client.WithVersion(portainer.SupportedDockerAPIVersion), + client.WithVersion(dockerClientVersion), client.WithHTTPClient(httpCli), ) } @@ -84,7 +85,7 @@ func createEdgeClient(endpoint *portainer.Endpoint, reverseTunnelService portain return client.NewClientWithOpts( client.WithHost(endpointURL), - client.WithVersion(portainer.SupportedDockerAPIVersion), + client.WithVersion(dockerClientVersion), client.WithHTTPClient(httpCli), client.WithHTTPHeaders(headers), ) @@ -112,7 +113,7 @@ func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer. return client.NewClientWithOpts( client.WithHost(endpoint.URL), - client.WithVersion(portainer.SupportedDockerAPIVersion), + client.WithVersion(dockerClientVersion), client.WithHTTPClient(httpCli), client.WithHTTPHeaders(headers), ) diff --git a/api/exec/extension.go b/api/exec/extension.go index d4bed5961..e41cf1f49 100644 --- a/api/exec/extension.go +++ b/api/exec/extension.go @@ -4,20 +4,26 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "log" + "os" "os/exec" "path" + "regexp" "runtime" "strconv" "strings" "time" + "github.com/coreos/go-semver/semver" + "github.com/orcaman/concurrent-map" "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/client" ) -var extensionDownloadBaseURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com/extensions/" +var extensionDownloadBaseURL = portainer.AssetsServerURL + "/extensions/" +var extensionVersionRegexp = regexp.MustCompile(`\d+(\.\d+)+`) var extensionBinaryMap = map[portainer.ExtensionID]string{ portainer.RegistryManagementExtension: "extension-registry-management", @@ -47,20 +53,11 @@ func processKey(ID portainer.ExtensionID) string { } func buildExtensionURL(extension *portainer.Extension) string { - extensionURL := extensionDownloadBaseURL - extensionURL += extensionBinaryMap[extension.ID] - extensionURL += "-" + runtime.GOOS + "-" + runtime.GOARCH - extensionURL += "-" + extension.Version - extensionURL += ".zip" - return extensionURL + return fmt.Sprintf("%s%s-%s-%s-%s.zip", extensionDownloadBaseURL, extensionBinaryMap[extension.ID], runtime.GOOS, runtime.GOARCH, extension.Version) } func buildExtensionPath(binaryPath string, extension *portainer.Extension) string { - - extensionFilename := extensionBinaryMap[extension.ID] - extensionFilename += "-" + runtime.GOOS + "-" + runtime.GOARCH - extensionFilename += "-" + extension.Version - + extensionFilename := fmt.Sprintf("%s-%s-%s-%s", extensionBinaryMap[extension.ID], runtime.GOOS, runtime.GOARCH, extension.Version) if runtime.GOOS == "windows" { extensionFilename += ".exe" } @@ -73,11 +70,20 @@ func buildExtensionPath(binaryPath string, extension *portainer.Extension) strin } // FetchExtensionDefinitions will fetch the list of available -// extension definitions from the official Portainer assets server +// extension definitions from the official Portainer assets server. +// If it cannot retrieve the data from the Internet it will fallback to the locally cached +// manifest file. func (manager *ExtensionManager) FetchExtensionDefinitions() ([]portainer.Extension, error) { - extensionData, err := client.Get(portainer.ExtensionDefinitionsURL, 30) + var extensionData []byte + + extensionData, err := client.Get(portainer.ExtensionDefinitionsURL, 5) if err != nil { - return nil, err + log.Printf("[WARN] [exec,extensions] [message: unable to retrieve extensions manifest via Internet. Extensions will be retrieved from local cache and might not be up to date] [err: %s]", err) + + extensionData, err = manager.fileService.GetFileContent(portainer.LocalExtensionManifestFile) + if err != nil { + return nil, err + } } var extensions []portainer.Extension @@ -89,6 +95,37 @@ func (manager *ExtensionManager) FetchExtensionDefinitions() ([]portainer.Extens return extensions, nil } +// InstallExtension will install the extension from an archive. It will extract the extension version number from +// the archive file name first and return an error if the file name is not valid (cannot find extension version). +// It will then extract the archive and execute the EnableExtension function to enable the extension. +// Since we're missing information about this extension (stored on Portainer.io server) we need to assume +// default information based on the extension ID. +func (manager *ExtensionManager) InstallExtension(extension *portainer.Extension, licenseKey string, archiveFileName string, extensionArchive []byte) error { + extensionVersion := extensionVersionRegexp.FindString(archiveFileName) + if extensionVersion == "" { + return errors.New("invalid extension archive filename: unable to retrieve extension version") + } + + err := manager.fileService.ExtractExtensionArchive(extensionArchive) + if err != nil { + return err + } + + switch extension.ID { + case portainer.RegistryManagementExtension: + extension.Name = "Registry Manager" + case portainer.OAuthAuthenticationExtension: + extension.Name = "External Authentication" + case portainer.RBACExtension: + extension.Name = "Role-Based Access Control" + } + extension.ShortDescription = "Extension enabled offline" + extension.Version = extensionVersion + extension.Available = true + + return manager.EnableExtension(extension, licenseKey) +} + // EnableExtension will check for the existence of the extension binary on the filesystem // first. If it does not exist, it will download it from the official Portainer assets server. // After installing the binary on the filesystem, it will execute the binary in license check @@ -145,6 +182,61 @@ func (manager *ExtensionManager) DisableExtension(extension *portainer.Extension return manager.fileService.RemoveDirectory(extensionBinaryPath) } +// StartExtensions will retrieve the extensions definitions from the Internet and check if a new version of each +// extension is available. If so, it will automatically install the new version of the extension. If no update is +// available it will simply start the extension. +// The purpose of this function is to be ran at startup, as such most of the error handling won't block the program execution +// and will log warning messages instead. +func (manager *ExtensionManager) StartExtensions() error { + extensions, err := manager.extensionService.Extensions() + if err != nil { + return err + } + + definitions, err := manager.FetchExtensionDefinitions() + if err != nil { + log.Printf("[WARN] [exec,extensions] [message: unable to retrieve extension information from Internet. Skipping extensions update check.] [err: %s]", err) + return nil + } + + return manager.updateAndStartExtensions(extensions, definitions) +} + +func (manager *ExtensionManager) updateAndStartExtensions(extensions []portainer.Extension, definitions []portainer.Extension) error { + for _, definition := range definitions { + for _, extension := range extensions { + if extension.ID == definition.ID { + definitionVersion := semver.New(definition.Version) + extensionVersion := semver.New(extension.Version) + + if extensionVersion.LessThan(*definitionVersion) { + log.Printf("[INFO] [exec,extensions] [message: new version detected, updating extension] [extension: %s] [current_version: %s] [available_version: %s]", extension.Name, extension.Version, definition.Version) + err := manager.UpdateExtension(&extension, definition.Version) + if err != nil { + log.Printf("[WARN] [exec,extensions] [message: unable to update extension automatically] [extension: %s] [current_version: %s] [available_version: %s] [err: %s]", extension.Name, extension.Version, definition.Version, err) + } + } else { + err := manager.EnableExtension(&extension, extension.License.LicenseKey) + if err != nil { + log.Printf("[WARN] [exec,extensions] [message: unable to start extension] [extension: %s] [err: %s]", extension.Name, err) + extension.Enabled = false + extension.License.Valid = false + } + } + + err := manager.extensionService.Persist(&extension) + if err != nil { + return err + } + + break + } + } + } + + return nil +} + // UpdateExtension will download the new extension binary from the official Portainer assets // server, disable the previous extension via DisableExtension, trigger a license check // and then start the extension process and add it to the processes map @@ -195,7 +287,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") + return nil, errors.New("invalid extension license key") } output := string(cmdOutput.Bytes()) @@ -205,8 +297,12 @@ func validateLicense(binaryPath, licenseKey string) ([]string, error) { func (manager *ExtensionManager) startExtensionProcess(extension *portainer.Extension, binaryPath string) error { extensionProcess := exec.Command(binaryPath, "-license", extension.License.LicenseKey) + extensionProcess.Stdout = os.Stdout + extensionProcess.Stderr = os.Stderr + err := extensionProcess.Start() if err != nil { + log.Printf("[DEBUG] [exec,extension] [message: unable to start extension process] [err: %s]", err) return err } diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go index cf4e06977..c2ee9be3a 100644 --- a/api/filesystem/filesystem.go +++ b/api/filesystem/filesystem.go @@ -87,12 +87,7 @@ func (service *Service) GetBinaryFolder() string { // ExtractExtensionArchive extracts the content of an extension archive // specified as raw data into the binary store on the filesystem func (service *Service) ExtractExtensionArchive(data []byte) error { - err := archive.UnzipArchive(data, path.Join(service.fileStorePath, BinaryStorePath)) - if err != nil { - return err - } - - return nil + return archive.UnzipArchive(data, path.Join(service.fileStorePath, BinaryStorePath)) } // RemoveDirectory removes a directory on the filesystem. diff --git a/api/http/client/client.go b/api/http/client/client.go index 11b812a86..fb690105f 100644 --- a/api/http/client/client.go +++ b/api/http/client/client.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io/ioutil" + "log" "net/http" "net/url" "strings" @@ -87,6 +88,7 @@ func Get(url string, timeout int) ([]byte, error) { defer response.Body.Close() if response.StatusCode != http.StatusOK { + log.Printf("[ERROR] [http,client] [message: unexpected status code] [status_code: %d]", response.StatusCode) return nil, errInvalidResponseStatus } diff --git a/api/http/handler/auth/authenticate.go b/api/http/handler/auth/authenticate.go index 02159c275..56441c33a 100644 --- a/api/http/handler/auth/authenticate.go +++ b/api/http/handler/auth/authenticate.go @@ -113,6 +113,11 @@ func (handler *Handler) authenticateLDAPAndCreateUser(w http.ResponseWriter, use log.Printf("Warning: unable to automatically add user into teams: %s\n", err.Error()) } + err = handler.AuthorizationService.UpdateUsersAuthorizations() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} + } + return handler.writeToken(w, user) } diff --git a/api/http/handler/auth/authenticate_oauth.go b/api/http/handler/auth/authenticate_oauth.go index 849859d9f..de0cbd8dc 100644 --- a/api/http/handler/auth/authenticate_oauth.go +++ b/api/http/handler/auth/authenticate_oauth.go @@ -133,6 +133,11 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist team membership inside the database", err} } } + + err = handler.AuthorizationService.UpdateUsersAuthorizations() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} + } } return handler.writeToken(w, user) diff --git a/api/http/handler/auth/handler.go b/api/http/handler/auth/handler.go index 8d10abf29..24a211f94 100644 --- a/api/http/handler/auth/handler.go +++ b/api/http/handler/auth/handler.go @@ -34,6 +34,7 @@ type Handler struct { EndpointGroupService portainer.EndpointGroupService RoleService portainer.RoleService ProxyManager *proxy.Manager + AuthorizationService *portainer.AuthorizationService } // NewHandler creates a handler to manage authentication operations. diff --git a/api/http/handler/endpointproxy/proxy_azure.go b/api/http/handler/endpointproxy/proxy_azure.go index 782f81a25..6ffc4598a 100644 --- a/api/http/handler/endpointproxy/proxy_azure.go +++ b/api/http/handler/endpointproxy/proxy_azure.go @@ -29,9 +29,9 @@ func (handler *Handler) proxyRequestsToAzureAPI(w http.ResponseWriter, r *http.R } var proxy http.Handler - proxy = handler.ProxyManager.GetProxy(endpoint) + proxy = handler.ProxyManager.GetEndpointProxy(endpoint) if proxy == nil { - proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) + proxy, err = handler.ProxyManager.CreateAndRegisterEndpointProxy(endpoint) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create proxy", err} } diff --git a/api/http/handler/endpointproxy/proxy_docker.go b/api/http/handler/endpointproxy/proxy_docker.go index 35490a8ff..d84aacc62 100644 --- a/api/http/handler/endpointproxy/proxy_docker.go +++ b/api/http/handler/endpointproxy/proxy_docker.go @@ -37,7 +37,7 @@ func (handler *Handler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http. tunnel := handler.ReverseTunnelService.GetTunnelDetails(endpoint.ID) if tunnel.Status == portainer.EdgeAgentIdle { - handler.ProxyManager.DeleteProxy(endpoint) + handler.ProxyManager.DeleteEndpointProxy(endpoint) err := handler.ReverseTunnelService.SetTunnelStatusToRequired(endpoint.ID) if err != nil { @@ -55,9 +55,9 @@ func (handler *Handler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http. } var proxy http.Handler - proxy = handler.ProxyManager.GetProxy(endpoint) + proxy = handler.ProxyManager.GetEndpointProxy(endpoint) if proxy == nil { - proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) + proxy, err = handler.ProxyManager.CreateAndRegisterEndpointProxy(endpoint) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create proxy", err} } diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go index 4d82f9c60..acd85e03c 100644 --- a/api/http/handler/endpoints/endpoint_delete.go +++ b/api/http/handler/endpoints/endpoint_delete.go @@ -41,7 +41,7 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove endpoint from the database", err} } - handler.ProxyManager.DeleteProxy(endpoint) + handler.ProxyManager.DeleteEndpointProxy(endpoint) if len(endpoint.UserAccessPolicies) > 0 || len(endpoint.TeamAccessPolicies) > 0 { err = handler.AuthorizationService.UpdateUsersAuthorizations() diff --git a/api/http/handler/endpoints/endpoint_update.go b/api/http/handler/endpoints/endpoint_update.go index 6816ec0d7..dd87c22f2 100644 --- a/api/http/handler/endpoints/endpoint_update.go +++ b/api/http/handler/endpoints/endpoint_update.go @@ -166,7 +166,7 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * } if payload.URL != nil || payload.TLS != nil || endpoint.Type == portainer.AzureEnvironment { - _, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) + _, err = handler.ProxyManager.CreateAndRegisterEndpointProxy(endpoint) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to register HTTP proxy for the endpoint", err} } diff --git a/api/http/handler/extensions/upgrade.go b/api/http/handler/extensions/data.go similarity index 51% rename from api/http/handler/extensions/upgrade.go rename to api/http/handler/extensions/data.go index b0f37f5d7..8c950e608 100644 --- a/api/http/handler/extensions/upgrade.go +++ b/api/http/handler/extensions/data.go @@ -59,3 +59,59 @@ func (handler *Handler) upgradeRBACData() error { return handler.AuthorizationService.UpdateUsersAuthorizations() } + +func updateUserAccessPolicyToNoRole(policies portainer.UserAccessPolicies, key portainer.UserID) { + tmp := policies[key] + tmp.RoleID = 0 + policies[key] = tmp +} + +func updateTeamAccessPolicyToNoRole(policies portainer.TeamAccessPolicies, key portainer.TeamID) { + tmp := policies[key] + tmp.RoleID = 0 + policies[key] = tmp +} + +func (handler *Handler) downgradeRBACData() error { + endpointGroups, err := handler.EndpointGroupService.EndpointGroups() + if err != nil { + return err + } + + for _, endpointGroup := range endpointGroups { + for key := range endpointGroup.UserAccessPolicies { + updateUserAccessPolicyToNoRole(endpointGroup.UserAccessPolicies, key) + } + + for key := range endpointGroup.TeamAccessPolicies { + updateTeamAccessPolicyToNoRole(endpointGroup.TeamAccessPolicies, key) + } + + err := handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, &endpointGroup) + if err != nil { + return err + } + } + + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range endpoints { + for key := range endpoint.UserAccessPolicies { + updateUserAccessPolicyToNoRole(endpoint.UserAccessPolicies, key) + } + + for key := range endpoint.TeamAccessPolicies { + updateTeamAccessPolicyToNoRole(endpoint.TeamAccessPolicies, key) + } + + err := handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + } + + return handler.AuthorizationService.UpdateUsersAuthorizations() +} diff --git a/api/http/handler/extensions/extension_delete.go b/api/http/handler/extensions/extension_delete.go index 6f59afc2b..3f9853016 100644 --- a/api/http/handler/extensions/extension_delete.go +++ b/api/http/handler/extensions/extension_delete.go @@ -29,6 +29,13 @@ func (handler *Handler) extensionDelete(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete extension", err} } + if extensionID == portainer.RBACExtension { + err = handler.downgradeRBACData() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "An error occured during database update", err} + } + } + err = handler.ExtensionService.DeleteExtension(extensionID) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete the extension from the database", err} diff --git a/api/http/handler/extensions/extension_inspect.go b/api/http/handler/extensions/extension_inspect.go index 74ac59254..6af8b1940 100644 --- a/api/http/handler/extensions/extension_inspect.go +++ b/api/http/handler/extensions/extension_inspect.go @@ -1,15 +1,14 @@ package extensions import ( - "encoding/json" "net/http" - "github.com/coreos/go-semver/semver" + "github.com/portainer/portainer/api/http/client" + httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/client" ) // GET request on /api/extensions/:id @@ -18,46 +17,39 @@ func (handler *Handler) extensionInspect(w http.ResponseWriter, r *http.Request) if err != nil { return &httperror.HandlerError{http.StatusBadRequest, "Invalid extension identifier route variable", err} } + extensionID := portainer.ExtensionID(extensionIdentifier) - extensionData, err := client.Get(portainer.ExtensionDefinitionsURL, 30) + definitions, err := handler.ExtensionManager.FetchExtensionDefinitions() if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extension definitions", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extensions informations", err} } - var extensions []portainer.Extension - err = json.Unmarshal(extensionData, &extensions) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to parse external extension definitions", err} + localExtension, err := handler.ExtensionService.Extension(extensionID) + if err != nil && err != portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extension information from the database", err} } var extension portainer.Extension - for _, p := range extensions { - if p.ID == extensionID { - extension = p - if extension.DescriptionURL != "" { - description, _ := client.Get(extension.DescriptionURL, 10) - extension.Description = string(description) - } + var extensionDefinition portainer.Extension + + for _, definition := range definitions { + if definition.ID == extensionID { + extensionDefinition = definition break } } - storedExtension, err := handler.ExtensionService.Extension(extensionID) - if err == portainer.ErrObjectNotFound { - return response.JSON(w, extension) - } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err} + if localExtension == nil { + extension = extensionDefinition + } else { + extension = *localExtension } - extension.Enabled = storedExtension.Enabled + mergeExtensionAndDefinition(&extension, &extensionDefinition) - extensionVer := semver.New(extension.Version) - pVer := semver.New(storedExtension.Version) - - if pVer.LessThan(*extensionVer) { - extension.UpdateAvailable = true - } + description, _ := client.Get(extension.DescriptionURL, 5) + extension.Description = string(description) return response.JSON(w, extension) } diff --git a/api/http/handler/extensions/extension_list.go b/api/http/handler/extensions/extension_list.go index ed7325c25..e96e103c8 100644 --- a/api/http/handler/extensions/extension_list.go +++ b/api/http/handler/extensions/extension_list.go @@ -3,54 +3,28 @@ package extensions import ( "net/http" - "github.com/coreos/go-semver/semver" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" ) // GET request on /api/extensions?store= func (handler *Handler) extensionList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - storeDetails, _ := request.RetrieveBooleanQueryParameter(r, "store", true) + fetchManifestInformation, _ := request.RetrieveBooleanQueryParameter(r, "store", true) extensions, err := handler.ExtensionService.Extensions() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extensions from the database", err} } - if storeDetails { + if fetchManifestInformation { definitions, err := handler.ExtensionManager.FetchExtensionDefinitions() if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extensions", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extensions informations", err} } - for idx := range definitions { - associateExtensionData(&definitions[idx], extensions) - } - - extensions = definitions + extensions = mergeExtensionsAndDefinitions(extensions, definitions) } return response.JSON(w, extensions) } - -func associateExtensionData(definition *portainer.Extension, extensions []portainer.Extension) { - for _, extension := range extensions { - if extension.ID == definition.ID { - - definition.Enabled = extension.Enabled - definition.License.Company = extension.License.Company - definition.License.Expiration = extension.License.Expiration - definition.License.Valid = extension.License.Valid - - definitionVersion := semver.New(definition.Version) - extensionVersion := semver.New(extension.Version) - if extensionVersion.LessThan(*definitionVersion) { - definition.UpdateAvailable = true - } - - break - } - } -} diff --git a/api/http/handler/extensions/extension_upload.go b/api/http/handler/extensions/extension_upload.go new file mode 100644 index 000000000..46d403fc6 --- /dev/null +++ b/api/http/handler/extensions/extension_upload.go @@ -0,0 +1,75 @@ +package extensions + +import ( + "net/http" + "strconv" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" +) + +type extensionUploadPayload struct { + License string + ExtensionArchive []byte + ArchiveFileName string +} + +func (payload *extensionUploadPayload) Validate(r *http.Request) error { + license, err := request.RetrieveMultiPartFormValue(r, "License", false) + if err != nil { + return portainer.Error("Invalid license") + } + payload.License = license + + fileData, fileName, err := request.RetrieveMultiPartFormFile(r, "file") + if err != nil { + return portainer.Error("Invalid extension archive file. Ensure that the file is uploaded correctly") + } + payload.ExtensionArchive = fileData + payload.ArchiveFileName = fileName + + return nil +} + +func (handler *Handler) extensionUpload(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + payload := &extensionUploadPayload{} + err := payload.Validate(r) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + extensionIdentifier, err := strconv.Atoi(string(payload.License[0])) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid license format", err} + } + extensionID := portainer.ExtensionID(extensionIdentifier) + + extension := &portainer.Extension{ + ID: extensionID, + } + + _ = handler.ExtensionManager.DisableExtension(extension) + + err = handler.ExtensionManager.InstallExtension(extension, payload.License, payload.ArchiveFileName, payload.ExtensionArchive) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to install extension", err} + } + + extension.Enabled = true + + if extension.ID == portainer.RBACExtension { + err = handler.upgradeRBACData() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "An error occured during database update", err} + } + } + + err = handler.ExtensionService.Persist(extension) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist extension status inside the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/extensions/handler.go b/api/http/handler/extensions/handler.go index d77347594..15df2ce17 100644 --- a/api/http/handler/extensions/handler.go +++ b/api/http/handler/extensions/handler.go @@ -3,6 +3,8 @@ package extensions import ( "net/http" + "github.com/coreos/go-semver/semver" + "github.com/gorilla/mux" httperror "github.com/portainer/libhttp/error" "github.com/portainer/portainer/api" @@ -30,6 +32,8 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.RestrictedAccess(httperror.LoggerHandler(h.extensionList))).Methods(http.MethodGet) h.Handle("/extensions", bouncer.AdminAccess(httperror.LoggerHandler(h.extensionCreate))).Methods(http.MethodPost) + h.Handle("/extensions/upload", + bouncer.AdminAccess(httperror.LoggerHandler(h.extensionUpload))).Methods(http.MethodPost) h.Handle("/extensions/{id}", bouncer.AdminAccess(httperror.LoggerHandler(h.extensionInspect))).Methods(http.MethodGet) h.Handle("/extensions/{id}", @@ -39,3 +43,44 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { return h } + +func mergeExtensionsAndDefinitions(extensions, definitions []portainer.Extension) []portainer.Extension { + for _, definition := range definitions { + foundInDB := false + + for idx, extension := range extensions { + if extension.ID == definition.ID { + foundInDB = true + mergeExtensionAndDefinition(&extensions[idx], &definition) + break + } + } + + if !foundInDB { + extensions = append(extensions, definition) + } + } + + return extensions +} + +func mergeExtensionAndDefinition(extension, definition *portainer.Extension) { + extension.Name = definition.Name + extension.ShortDescription = definition.ShortDescription + extension.Deal = definition.Deal + extension.Available = definition.Available + extension.DescriptionURL = definition.DescriptionURL + extension.Images = definition.Images + extension.Logo = definition.Logo + extension.Price = definition.Price + extension.PriceDescription = definition.PriceDescription + extension.ShopURL = definition.ShopURL + + definitionVersion := semver.New(definition.Version) + extensionVersion := semver.New(extension.Version) + if extensionVersion.LessThan(*definitionVersion) { + extension.UpdateAvailable = true + } + + extension.Version = definition.Version +} diff --git a/api/http/handler/registries/handler.go b/api/http/handler/registries/handler.go index 202a81fdc..3b2646dcb 100644 --- a/api/http/handler/registries/handler.go +++ b/api/http/handler/registries/handler.go @@ -46,6 +46,9 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.AdminAccess(httperror.LoggerHandler(h.registryDelete))).Methods(http.MethodDelete) h.PathPrefix("/registries/{id}/v2").Handler( bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToRegistryAPI))) - + h.PathPrefix("/registries/{id}/proxies/gitlab").Handler( + bouncer.RestrictedAccess(httperror.LoggerHandler(h.proxyRequestsToGitlabAPIWithRegistry))) + h.PathPrefix("/registries/proxies/gitlab").Handler( + bouncer.AdminAccess(httperror.LoggerHandler(h.proxyRequestsToGitlabAPIWithoutRegistry))) return h } diff --git a/api/http/handler/registries/proxy.go b/api/http/handler/registries/proxy.go index d54520508..3f94bed4a 100644 --- a/api/http/handler/registries/proxy.go +++ b/api/http/handler/registries/proxy.go @@ -41,7 +41,7 @@ func (handler *Handler) proxyRequestsToRegistryAPI(w http.ResponseWriter, r *htt if proxy == nil { proxy, err = handler.ProxyManager.CreateExtensionProxy(portainer.RegistryManagementExtension) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to register registry proxy", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create extension proxy for registry manager", err} } } diff --git a/api/http/handler/registries/proxy_gitlab.go b/api/http/handler/registries/proxy_gitlab.go new file mode 100644 index 000000000..47b5f4169 --- /dev/null +++ b/api/http/handler/registries/proxy_gitlab.go @@ -0,0 +1,23 @@ +package registries + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" +) + +// request on /api/registries/proxies/gitlab +func (handler *Handler) proxyRequestsToGitlabAPIWithoutRegistry(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + domain := r.Header.Get("X-Gitlab-Domain") + if domain == "" { + return &httperror.HandlerError{http.StatusBadRequest, "No Gitlab domain provided", nil} + } + + proxy, err := handler.ProxyManager.CreateGitlabProxy(domain) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create gitlab proxy", err} + } + + http.StripPrefix("/registries/proxies/gitlab", proxy).ServeHTTP(w, r) + return nil +} diff --git a/api/http/handler/registries/proxy_management_gitlab.go b/api/http/handler/registries/proxy_management_gitlab.go new file mode 100644 index 000000000..28f1ead12 --- /dev/null +++ b/api/http/handler/registries/proxy_management_gitlab.go @@ -0,0 +1,66 @@ +package registries + +import ( + "encoding/json" + "net/http" + "strconv" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/portainer/api" +) + +// request on /api/registries/{id}/proxies/gitlab +func (handler *Handler) proxyRequestsToGitlabAPIWithRegistry(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} + } + + registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} + } + + err = handler.requestBouncer.RegistryAccess(r, registry) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access registry", portainer.ErrEndpointAccessDenied} + } + + extension, err := handler.ExtensionService.Extension(portainer.RegistryManagementExtension) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Registry management extension is not enabled", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err} + } + + var proxy http.Handler + proxy = handler.ProxyManager.GetExtensionProxy(portainer.RegistryManagementExtension) + if proxy == nil { + proxy, err = handler.ProxyManager.CreateExtensionProxy(portainer.RegistryManagementExtension) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create extension proxy for registry manager", err} + } + } + + config := &portainer.RegistryManagementConfiguration{ + Type: portainer.GitlabRegistry, + Password: registry.Password, + } + + encodedConfiguration, err := json.Marshal(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to encode management configuration", err} + } + + id := strconv.Itoa(int(registryID)) + r.Header.Set("X-RegistryManagement-Key", id+"-gitlab") + r.Header.Set("X-RegistryManagement-URI", registry.Gitlab.InstanceURL) + r.Header.Set("X-RegistryManagement-Config", string(encodedConfiguration)) + r.Header.Set("X-PortainerExtension-License", extension.License.LicenseKey) + + http.StripPrefix("/registries/"+id+"/proxies/gitlab", proxy).ServeHTTP(w, r) + return nil +} diff --git a/api/http/handler/registries/registry_create.go b/api/http/handler/registries/registry_create.go index 1b6dbf638..09f6d0a2e 100644 --- a/api/http/handler/registries/registry_create.go +++ b/api/http/handler/registries/registry_create.go @@ -12,11 +12,12 @@ import ( type registryCreatePayload struct { Name string - Type int + Type portainer.RegistryType URL string Authentication bool Username string Password string + Gitlab portainer.GitlabRegistryData } func (payload *registryCreatePayload) Validate(r *http.Request) error { @@ -29,8 +30,8 @@ func (payload *registryCreatePayload) 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") } - if payload.Type != 1 && payload.Type != 2 && payload.Type != 3 { - return portainer.Error("Invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry) or 3 (custom registry)") + if payload.Type != portainer.QuayRegistry && payload.Type != portainer.AzureRegistry && payload.Type != portainer.CustomRegistry && payload.Type != portainer.GitlabRegistry { + return portainer.Error("Invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry) or 4 (Gitlab registry)") } return nil } @@ -42,16 +43,6 @@ func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - 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 { - return &httperror.HandlerError{http.StatusConflict, "A registry with the same URL already exists", portainer.ErrRegistryAlreadyExists} - } - } - registry := &portainer.Registry{ Type: portainer.RegistryType(payload.Type), Name: payload.Name, @@ -61,6 +52,7 @@ func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) * Password: payload.Password, UserAccessPolicies: portainer.UserAccessPolicies{}, TeamAccessPolicies: portainer.TeamAccessPolicies{}, + Gitlab: payload.Gitlab, } err = handler.RegistryService.CreateRegistry(registry) diff --git a/api/http/handler/resourcecontrols/handler.go b/api/http/handler/resourcecontrols/handler.go index a2227f2c8..e6851c649 100644 --- a/api/http/handler/resourcecontrols/handler.go +++ b/api/http/handler/resourcecontrols/handler.go @@ -21,11 +21,10 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), } h.Handle("/resource_controls", - bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.resourceControlCreate))).Methods(http.MethodPost) + bouncer.AdminAccess(httperror.LoggerHandler(h.resourceControlCreate))).Methods(http.MethodPost) h.Handle("/resource_controls/{id}", bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.resourceControlUpdate))).Methods(http.MethodPut) h.Handle("/resource_controls/{id}", - bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.resourceControlDelete))).Methods(http.MethodDelete) - + bouncer.AdminAccess(httperror.LoggerHandler(h.resourceControlDelete))).Methods(http.MethodDelete) return h } diff --git a/api/http/handler/resourcecontrols/resourcecontrol_create.go b/api/http/handler/resourcecontrols/resourcecontrol_create.go index ad954805d..0a6696bd9 100644 --- a/api/http/handler/resourcecontrols/resourcecontrol_create.go +++ b/api/http/handler/resourcecontrols/resourcecontrol_create.go @@ -1,6 +1,7 @@ package resourcecontrols import ( + "errors" "net/http" "github.com/asaskevich/govalidator" @@ -8,29 +9,33 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/security" ) type resourceControlCreatePayload struct { - ResourceID string - Type string - Public bool - Users []int - Teams []int - SubResourceIDs []string + ResourceID string + Type string + Public bool + AdministratorsOnly bool + Users []int + Teams []int + SubResourceIDs []string } func (payload *resourceControlCreatePayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.ResourceID) { - return portainer.Error("Invalid resource identifier") + return errors.New("invalid payload: invalid resource identifier") } if govalidator.IsNull(payload.Type) { - return portainer.Error("Invalid type") + return errors.New("invalid payload: invalid type") } - if len(payload.Users) == 0 && len(payload.Teams) == 0 && !payload.Public { - return portainer.Error("Invalid resource control declaration. Must specify Users, Teams or Public") + if len(payload.Users) == 0 && len(payload.Teams) == 0 && !payload.Public && !payload.AdministratorsOnly { + return errors.New("invalid payload: must specify Users, Teams, Public or AdministratorsOnly") + } + + if payload.Public && payload.AdministratorsOnly { + return errors.New("invalid payload: cannot set both public and administrators only flags to true") } return nil } @@ -63,8 +68,8 @@ func (handler *Handler) resourceControlCreate(w http.ResponseWriter, r *http.Req return &httperror.HandlerError{http.StatusBadRequest, "Invalid type value. Value must be one of: container, service, volume, network, secret, stack or config", portainer.ErrInvalidResourceControlType} } - rc, err := handler.ResourceControlService.ResourceControlByResourceID(payload.ResourceID) - if err != nil && err != portainer.ErrObjectNotFound { + rc, err := handler.ResourceControlService.ResourceControlByResourceIDAndType(payload.ResourceID, resourceControlType) + if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve resource controls from the database", err} } if rc != nil { @@ -90,21 +95,13 @@ func (handler *Handler) resourceControlCreate(w http.ResponseWriter, r *http.Req } resourceControl := portainer.ResourceControl{ - ResourceID: payload.ResourceID, - SubResourceIDs: payload.SubResourceIDs, - Type: resourceControlType, - Public: payload.Public, - UserAccesses: userAccesses, - TeamAccesses: teamAccesses, - } - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} - } - - if !security.AuthorizedResourceControlCreation(&resourceControl, securityContext) { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to create a resource control for the specified resource", portainer.ErrResourceAccessDenied} + ResourceID: payload.ResourceID, + SubResourceIDs: payload.SubResourceIDs, + Type: resourceControlType, + Public: payload.Public, + AdministratorsOnly: payload.AdministratorsOnly, + UserAccesses: userAccesses, + TeamAccesses: teamAccesses, } err = handler.ResourceControlService.CreateResourceControl(&resourceControl) diff --git a/api/http/handler/resourcecontrols/resourcecontrol_delete.go b/api/http/handler/resourcecontrols/resourcecontrol_delete.go index 1d1ea8ddf..76794e423 100644 --- a/api/http/handler/resourcecontrols/resourcecontrol_delete.go +++ b/api/http/handler/resourcecontrols/resourcecontrol_delete.go @@ -7,7 +7,6 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/security" ) // DELETE request on /api/resource_controls/:id @@ -17,22 +16,13 @@ func (handler *Handler) resourceControlDelete(w http.ResponseWriter, r *http.Req return &httperror.HandlerError{http.StatusBadRequest, "Invalid resource control identifier route variable", err} } - resourceControl, err := handler.ResourceControlService.ResourceControl(portainer.ResourceControlID(resourceControlID)) + _, err = handler.ResourceControlService.ResourceControl(portainer.ResourceControlID(resourceControlID)) if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a resource control with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a resource control with with the specified identifier inside the database", err} } - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} - } - - if !security.AuthorizedResourceControlDeletion(resourceControl, securityContext) { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to delete the resource control", portainer.ErrResourceAccessDenied} - } - err = handler.ResourceControlService.DeleteResourceControl(portainer.ResourceControlID(resourceControlID)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the resource control from the database", err} diff --git a/api/http/handler/resourcecontrols/resourcecontrol_update.go b/api/http/handler/resourcecontrols/resourcecontrol_update.go index 360e704c9..fc170f1bd 100644 --- a/api/http/handler/resourcecontrols/resourcecontrol_update.go +++ b/api/http/handler/resourcecontrols/resourcecontrol_update.go @@ -1,6 +1,7 @@ package resourcecontrols import ( + "errors" "net/http" httperror "github.com/portainer/libhttp/error" @@ -11,14 +12,19 @@ import ( ) type resourceControlUpdatePayload struct { - Public bool - Users []int - Teams []int + Public bool + Users []int + Teams []int + AdministratorsOnly bool } func (payload *resourceControlUpdatePayload) Validate(r *http.Request) error { - if len(payload.Users) == 0 && len(payload.Teams) == 0 && !payload.Public { - return portainer.Error("Invalid resource control declaration. Must specify Users, Teams or Public") + if len(payload.Users) == 0 && len(payload.Teams) == 0 && !payload.Public && !payload.AdministratorsOnly { + return errors.New("invalid payload: must specify Users, Teams, Public or AdministratorsOnly") + } + + if payload.Public && payload.AdministratorsOnly { + return errors.New("invalid payload: cannot set public and administrators only") } return nil } @@ -49,10 +55,11 @@ func (handler *Handler) resourceControlUpdate(w http.ResponseWriter, r *http.Req } if !security.AuthorizedResourceControlAccess(resourceControl, securityContext) { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update the resource control", portainer.ErrResourceAccessDenied} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access the resource control", portainer.ErrResourceAccessDenied} } resourceControl.Public = payload.Public + resourceControl.AdministratorsOnly = payload.AdministratorsOnly var userAccesses = make([]portainer.UserResourceAccess, 0) for _, v := range payload.Users { diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go index 7cae40cf9..ebdcea252 100644 --- a/api/http/handler/stacks/create_compose_stack.go +++ b/api/http/handler/stacks/create_compose_stack.go @@ -4,18 +4,25 @@ import ( "errors" "net/http" "path" + "regexp" "strconv" "strings" "github.com/asaskevich/govalidator" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" - "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/http/security" ) +// this is coming from libcompose +// https://github.com/portainer/libcompose/blob/master/project/context.go#L117-L120 +func normalizeStackName(name string) string { + r := regexp.MustCompile("[^a-z0-9]+") + return r.ReplaceAllString(strings.ToLower(name), "") +} + type composeStackFromFileContentPayload struct { Name string StackFileContent string @@ -26,13 +33,14 @@ func (payload *composeStackFromFileContentPayload) Validate(r *http.Request) err if govalidator.IsNull(payload.Name) { return portainer.Error("Invalid stack name") } + payload.Name = normalizeStackName(payload.Name) if govalidator.IsNull(payload.StackFileContent) { return portainer.Error("Invalid stack file content") } return nil } -func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { +func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { var payload composeStackFromFileContentPayload err := request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { @@ -86,7 +94,7 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter, } doCleanUp = false - return response.JSON(w, stack) + return handler.decorateStackResponse(w, stack, userID) } type composeStackFromGitRepositoryPayload struct { @@ -104,6 +112,7 @@ func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) e if govalidator.IsNull(payload.Name) { return portainer.Error("Invalid stack name") } + payload.Name = normalizeStackName(payload.Name) if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) { return portainer.Error("Invalid repository URL. Must correspond to a valid URL format") } @@ -116,7 +125,7 @@ func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) e return nil } -func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { +func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { var payload composeStackFromGitRepositoryPayload err := request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { @@ -180,7 +189,7 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite } doCleanUp = false - return response.JSON(w, stack) + return handler.decorateStackResponse(w, stack, userID) } type composeStackFromFileUploadPayload struct { @@ -194,7 +203,7 @@ func (payload *composeStackFromFileUploadPayload) Validate(r *http.Request) erro if err != nil { return portainer.Error("Invalid stack name") } - payload.Name = name + payload.Name = normalizeStackName(name) composeFileContent, _, err := request.RetrieveMultiPartFormFile(r, "file") if err != nil { @@ -211,7 +220,7 @@ func (payload *composeStackFromFileUploadPayload) Validate(r *http.Request) erro return nil } -func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { +func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { payload := &composeStackFromFileUploadPayload{} err := payload.Validate(r) if err != nil { @@ -265,7 +274,7 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, } doCleanUp = false - return response.JSON(w, stack) + return handler.decorateStackResponse(w, stack, userID) } type composeStackDeploymentConfig struct { diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go index 4210cab0e..143292ea9 100644 --- a/api/http/handler/stacks/create_swarm_stack.go +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -10,7 +10,6 @@ import ( "github.com/asaskevich/govalidator" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" - "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/http/security" @@ -36,7 +35,7 @@ func (payload *swarmStackFromFileContentPayload) Validate(r *http.Request) error return nil } -func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { +func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { var payload swarmStackFromFileContentPayload err := request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { @@ -91,7 +90,7 @@ func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r } doCleanUp = false - return response.JSON(w, stack) + return handler.decorateStackResponse(w, stack, userID) } type swarmStackFromGitRepositoryPayload struct { @@ -125,7 +124,7 @@ func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) err return nil } -func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { +func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { var payload swarmStackFromGitRepositoryPayload err := request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { @@ -190,7 +189,7 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, } doCleanUp = false - return response.JSON(w, stack) + return handler.decorateStackResponse(w, stack, userID) } type swarmStackFromFileUploadPayload struct { @@ -228,7 +227,7 @@ func (payload *swarmStackFromFileUploadPayload) Validate(r *http.Request) error return nil } -func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { +func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { payload := &swarmStackFromFileUploadPayload{} err := payload.Validate(r) if err != nil { @@ -283,7 +282,7 @@ func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r } doCleanUp = false - return response.JSON(w, stack) + return handler.decorateStackResponse(w, stack, userID) } type swarmStackDeploymentConfig struct { diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go index b81270543..d0a6b4ea5 100644 --- a/api/http/handler/stacks/handler.go +++ b/api/http/handler/stacks/handler.go @@ -26,6 +26,8 @@ type Handler struct { SwarmStackManager portainer.SwarmStackManager ComposeStackManager portainer.ComposeStackManager SettingsService portainer.SettingsService + UserService portainer.UserService + ExtensionService portainer.ExtensionService } // NewHandler creates a handler to manage stack operations. @@ -52,3 +54,36 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackMigrate))).Methods(http.MethodPost) return h } + +func (handler *Handler) userCanAccessStack(securityContext *security.RestrictedRequestContext, endpointID portainer.EndpointID, resourceControl *portainer.ResourceControl) (bool, error) { + if securityContext.IsAdmin { + return true, nil + } + + userTeamIDs := make([]portainer.TeamID, 0) + for _, membership := range securityContext.UserMemberships { + userTeamIDs = append(userTeamIDs, membership.TeamID) + } + + if resourceControl != nil && portainer.UserCanAccessResource(securityContext.UserID, userTeamIDs, resourceControl) { + return true, nil + } + + _, err := handler.ExtensionService.Extension(portainer.RBACExtension) + if err == portainer.ErrObjectNotFound { + return false, nil + } else if err != nil && err != portainer.ErrObjectNotFound { + return false, err + } + + user, err := handler.UserService.User(securityContext.UserID) + if err != nil { + return false, err + } + + _, ok := user.EndpointAuthorizations[endpointID][portainer.EndpointResourcesAccess] + if ok { + return true, nil + } + return false, nil +} diff --git a/api/http/handler/stacks/stack_create.go b/api/http/handler/stacks/stack_create.go index aba79482c..dc374c4b1 100644 --- a/api/http/handler/stacks/stack_create.go +++ b/api/http/handler/stacks/stack_create.go @@ -5,12 +5,13 @@ import ( "log" "net/http" - "github.com/docker/cli/cli/compose/types" - "github.com/docker/cli/cli/compose/loader" + "github.com/docker/cli/cli/compose/types" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" ) func (handler *Handler) cleanUp(stack *portainer.Stack, doCleanUp *bool) error { @@ -54,38 +55,43 @@ func (handler *Handler) stackCreate(w http.ResponseWriter, r *http.Request) *htt return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user details from authentication token", err} + } + switch portainer.StackType(stackType) { case portainer.DockerSwarmStack: - return handler.createSwarmStack(w, r, method, endpoint) + return handler.createSwarmStack(w, r, method, endpoint, tokenData.ID) case portainer.DockerComposeStack: - return handler.createComposeStack(w, r, method, endpoint) + return handler.createComposeStack(w, r, method, endpoint, tokenData.ID) } return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: type. Value must be one of: 1 (Swarm stack) or 2 (Compose stack)", errors.New(request.ErrInvalidQueryParameter)} } -func (handler *Handler) createComposeStack(w http.ResponseWriter, r *http.Request, method string, endpoint *portainer.Endpoint) *httperror.HandlerError { +func (handler *Handler) createComposeStack(w http.ResponseWriter, r *http.Request, method string, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { switch method { case "string": - return handler.createComposeStackFromFileContent(w, r, endpoint) + return handler.createComposeStackFromFileContent(w, r, endpoint, userID) case "repository": - return handler.createComposeStackFromGitRepository(w, r, endpoint) + return handler.createComposeStackFromGitRepository(w, r, endpoint, userID) case "file": - return handler.createComposeStackFromFileUpload(w, r, endpoint) + return handler.createComposeStackFromFileUpload(w, r, endpoint, userID) } 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) createSwarmStack(w http.ResponseWriter, r *http.Request, method string, endpoint *portainer.Endpoint) *httperror.HandlerError { +func (handler *Handler) createSwarmStack(w http.ResponseWriter, r *http.Request, method string, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { switch method { case "string": - return handler.createSwarmStackFromFileContent(w, r, endpoint) + return handler.createSwarmStackFromFileContent(w, r, endpoint, userID) case "repository": - return handler.createSwarmStackFromGitRepository(w, r, endpoint) + return handler.createSwarmStackFromGitRepository(w, r, endpoint, userID) case "file": - return handler.createSwarmStackFromFileUpload(w, r, endpoint) + return handler.createSwarmStackFromFileUpload(w, r, endpoint, userID) } return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string, repository or file", errors.New(request.ErrInvalidQueryParameter)} @@ -125,3 +131,15 @@ func (handler *Handler) isValidStackFile(stackFileContent []byte) (bool, error) return true, nil } + +func (handler *Handler) decorateStackResponse(w http.ResponseWriter, stack *portainer.Stack, userID portainer.UserID) *httperror.HandlerError { + resourceControl := portainer.NewPrivateResourceControl(stack.Name, portainer.StackResourceControl, userID) + + err := handler.ResourceControlService.CreateResourceControl(resourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist resource control inside the database", err} + } + + stack.ResourceControl = resourceControl + return response.JSON(w, stack) +} diff --git a/api/http/handler/stacks/stack_delete.go b/api/http/handler/stacks/stack_delete.go index 2607fa10c..180279217 100644 --- a/api/http/handler/stacks/stack_delete.go +++ b/api/http/handler/stacks/stack_delete.go @@ -4,7 +4,6 @@ import ( "net/http" "strconv" - "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/security" httperror "github.com/portainer/libhttp/error" @@ -64,8 +63,8 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } - resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name) - if err != nil && err != portainer.ErrObjectNotFound { + resourceControl, err := handler.ResourceControlService.ResourceControlByResourceIDAndType(stack.Name, portainer.StackResourceControl) + if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} } @@ -74,10 +73,12 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} } - if !securityContext.IsAdmin { - if !proxy.CanAccessStack(stack, resourceControl, securityContext.UserID, securityContext.UserMemberships) { - return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} - } + access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} + } + if !access { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} } err = handler.deleteStack(stack, endpoint) @@ -90,6 +91,13 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the stack from the database", err} } + if resourceControl != nil { + err = handler.ResourceControlService.DeleteResourceControl(resourceControl.ID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the associated resource control from the database", err} + } + } + err = handler.FileService.RemoveDirectory(stack.ProjectPath) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove stack files from disk", err} diff --git a/api/http/handler/stacks/stack_file.go b/api/http/handler/stacks/stack_file.go index fd9767ffd..7c966a08e 100644 --- a/api/http/handler/stacks/stack_file.go +++ b/api/http/handler/stacks/stack_file.go @@ -8,7 +8,6 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/security" ) @@ -42,8 +41,8 @@ func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httpe return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } - resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name) - if err != nil && err != portainer.ErrObjectNotFound { + resourceControl, err := handler.ResourceControlService.ResourceControlByResourceIDAndType(stack.Name, portainer.StackResourceControl) + if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} } @@ -52,17 +51,12 @@ func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httpe return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} } - extendedStack := proxy.ExtendedStack{*stack, portainer.ResourceControl{}} - if !securityContext.IsAdmin && resourceControl == nil { - return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} + access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} } - - if resourceControl != nil { - if securityContext.IsAdmin || proxy.CanAccessStack(stack, resourceControl, securityContext.UserID, securityContext.UserMemberships) { - extendedStack.ResourceControl = *resourceControl - } else { - return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} - } + if !access { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} } stackFileContent, err := handler.FileService.GetFileContent(path.Join(stack.ProjectPath, stack.EntryPoint)) diff --git a/api/http/handler/stacks/stack_inspect.go b/api/http/handler/stacks/stack_inspect.go index 878a20361..42d0ad9c5 100644 --- a/api/http/handler/stacks/stack_inspect.go +++ b/api/http/handler/stacks/stack_inspect.go @@ -7,7 +7,6 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/security" ) @@ -37,28 +36,27 @@ func (handler *Handler) stackInspect(w http.ResponseWriter, r *http.Request) *ht return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } - resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name) - if err != nil && err != portainer.ErrObjectNotFound { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user info from request context", err} + } + + resourceControl, err := handler.ResourceControlService.ResourceControlByResourceIDAndType(stack.Name, portainer.StackResourceControl) + if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} } - securityContext, err := security.RetrieveRestrictedRequestContext(r) + access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} } - - extendedStack := proxy.ExtendedStack{*stack, portainer.ResourceControl{}} - if !securityContext.IsAdmin && resourceControl == nil { + if !access { return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} } if resourceControl != nil { - if securityContext.IsAdmin || proxy.CanAccessStack(stack, resourceControl, securityContext.UserID, securityContext.UserMemberships) { - extendedStack.ResourceControl = *resourceControl - } else { - return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} - } + stack.ResourceControl = resourceControl } - return response.JSON(w, extendedStack) + return response.JSON(w, stack) } diff --git a/api/http/handler/stacks/stack_list.go b/api/http/handler/stacks/stack_list.go index aabd68eee..2c87cb40b 100644 --- a/api/http/handler/stacks/stack_list.go +++ b/api/http/handler/stacks/stack_list.go @@ -7,7 +7,6 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/security" ) @@ -40,10 +39,31 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} } - filteredStacks := proxy.FilterStacks(stacks, resourceControls, securityContext.IsAdmin, - securityContext.UserID, securityContext.UserMemberships) + stacks = portainer.DecorateStacks(stacks, resourceControls) - return response.JSON(w, filteredStacks) + if !securityContext.IsAdmin { + rbacExtensionEnabled := true + _, err := handler.ExtensionService.Extension(portainer.RBACExtension) + if err == portainer.ErrObjectNotFound { + rbacExtensionEnabled = false + } else if err != nil && err != portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check if RBAC extension is enabled", err} + } + + user, err := handler.UserService.User(securityContext.UserID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user information from the database", err} + } + + userTeamIDs := make([]portainer.TeamID, 0) + for _, membership := range securityContext.UserMemberships { + userTeamIDs = append(userTeamIDs, membership.TeamID) + } + + stacks = portainer.FilterAuthorizedStacks(stacks, user, userTeamIDs, rbacExtensionEnabled) + } + + return response.JSON(w, stacks) } func filterStacks(stacks []portainer.Stack, filters *stackListOperationFilters) []portainer.Stack { diff --git a/api/http/handler/stacks/stack_migrate.go b/api/http/handler/stacks/stack_migrate.go index 340d57310..690622a7a 100644 --- a/api/http/handler/stacks/stack_migrate.go +++ b/api/http/handler/stacks/stack_migrate.go @@ -7,7 +7,6 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/security" ) @@ -56,8 +55,8 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } - resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name) - if err != nil && err != portainer.ErrObjectNotFound { + resourceControl, err := handler.ResourceControlService.ResourceControlByResourceIDAndType(stack.Name, portainer.StackResourceControl) + if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} } @@ -66,10 +65,12 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} } - if !securityContext.IsAdmin { - if !proxy.CanAccessStack(stack, resourceControl, securityContext.UserID, securityContext.UserMemberships) { - return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} - } + access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} + } + if !access { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} } // TODO: this is a work-around for stacks created with Portainer version >= 1.17.1 diff --git a/api/http/handler/stacks/stack_update.go b/api/http/handler/stacks/stack_update.go index 63f8eb323..1e4e08fdf 100644 --- a/api/http/handler/stacks/stack_update.go +++ b/api/http/handler/stacks/stack_update.go @@ -4,7 +4,6 @@ import ( "net/http" "strconv" - "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/security" "github.com/asaskevich/govalidator" @@ -76,8 +75,8 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } - resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name) - if err != nil && err != portainer.ErrObjectNotFound { + resourceControl, err := handler.ResourceControlService.ResourceControlByResourceIDAndType(stack.Name, portainer.StackResourceControl) + if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} } @@ -86,10 +85,12 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} } - if !securityContext.IsAdmin { - if !proxy.CanAccessStack(stack, resourceControl, securityContext.UserID, securityContext.UserMemberships) { - return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} - } + access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} + } + if !access { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} } updateError := handler.updateAndDeployStack(r, stack, endpoint) diff --git a/api/http/proxy/access_control.go b/api/http/proxy/access_control.go deleted file mode 100644 index fea0014ec..000000000 --- a/api/http/proxy/access_control.go +++ /dev/null @@ -1,166 +0,0 @@ -package proxy - -import ( - "github.com/portainer/portainer/api" -) - -type ( - // ExtendedStack represents a stack combined with its associated access control - ExtendedStack struct { - portainer.Stack - ResourceControl portainer.ResourceControl `json:"ResourceControl"` - } -) - -// applyResourceAccessControlFromLabel returns an optionally decorated object as the first return value and the -// access level for the user (granted or denied) as the second return value. -// It will retrieve an identifier from the labels object. If an identifier exists, it will check for -// an existing resource control associated to it. -// Returns a decorated object and authorized access (true) when a resource control is found and the user can access the resource. -// Returns the original object and denied access (false) when no resource control is found. -// Returns the original object and denied access (false) when a resource control is found and the user cannot access the resource. -func applyResourceAccessControlFromLabel(labelsObject, resourceObject map[string]interface{}, labelIdentifier string, - context *restrictedDockerOperationContext) (map[string]interface{}, bool) { - - if labelsObject != nil && labelsObject[labelIdentifier] != nil { - resourceIdentifier := labelsObject[labelIdentifier].(string) - return applyResourceAccessControl(resourceObject, resourceIdentifier, context) - } - return resourceObject, false -} - -// applyResourceAccessControl returns an optionally decorated object as the first return value and the -// access level for the user (granted or denied) as the second return value. -// Returns a decorated object and authorized access (true) when a resource control is found to the specified resource -// identifier and the user can access the resource. -// Returns the original object and authorized access (false) when no resource control is found for the specified -// resource identifier. -// Returns the original object and denied access (false) when a resource control is associated to the resource -// and the user cannot access the resource. -func applyResourceAccessControl(resourceObject map[string]interface{}, resourceIdentifier string, - context *restrictedDockerOperationContext) (map[string]interface{}, bool) { - - resourceControl := getResourceControlByResourceID(resourceIdentifier, context.resourceControls) - if resourceControl == nil { - return resourceObject, context.isAdmin || context.endpointResourceAccess - } - - if context.isAdmin || context.endpointResourceAccess || resourceControl.Public || canUserAccessResource(context.userID, context.userTeamIDs, resourceControl) { - resourceObject = decorateObject(resourceObject, resourceControl) - return resourceObject, true - } - - return resourceObject, false -} - -// decorateResourceWithAccessControlFromLabel will retrieve an identifier from the labels object. If an identifier exists, -// it will check for an existing resource control associated to it. If a resource control is found, the resource object will be -// decorated. If no identifier can be found in the labels or no resource control is associated to the identifier, the resource -// object will not be changed. -func decorateResourceWithAccessControlFromLabel(labelsObject, resourceObject map[string]interface{}, labelIdentifier string, - resourceControls []portainer.ResourceControl) map[string]interface{} { - - if labelsObject != nil && labelsObject[labelIdentifier] != nil { - resourceIdentifier := labelsObject[labelIdentifier].(string) - resourceObject = decorateResourceWithAccessControl(resourceObject, resourceIdentifier, resourceControls) - } - - return resourceObject -} - -// decorateResourceWithAccessControl will check if a resource control is associated to the specified resource identifier. -// If a resource control is found, the resource object will be decorated, otherwise it will not be changed. -func decorateResourceWithAccessControl(resourceObject map[string]interface{}, resourceIdentifier string, - resourceControls []portainer.ResourceControl) map[string]interface{} { - - resourceControl := getResourceControlByResourceID(resourceIdentifier, resourceControls) - if resourceControl != nil { - return decorateObject(resourceObject, resourceControl) - } - return resourceObject -} - -func canUserAccessResource(userID portainer.UserID, userTeamIDs []portainer.TeamID, resourceControl *portainer.ResourceControl) bool { - for _, authorizedUserAccess := range resourceControl.UserAccesses { - if userID == authorizedUserAccess.UserID { - return true - } - } - - for _, authorizedTeamAccess := range resourceControl.TeamAccesses { - for _, userTeamID := range userTeamIDs { - if userTeamID == authorizedTeamAccess.TeamID { - return true - } - } - } - - return resourceControl.Public -} - -func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} { - if object["Portainer"] == nil { - object["Portainer"] = make(map[string]interface{}) - } - - portainerMetadata := object["Portainer"].(map[string]interface{}) - portainerMetadata["ResourceControl"] = resourceControl - return object -} - -func getResourceControlByResourceID(resourceID string, resourceControls []portainer.ResourceControl) *portainer.ResourceControl { - for _, resourceControl := range resourceControls { - if resourceID == resourceControl.ResourceID { - return &resourceControl - } - for _, subResourceID := range resourceControl.SubResourceIDs { - if resourceID == subResourceID { - return &resourceControl - } - } - } - return nil -} - -// CanAccessStack checks if a user can access a stack -func CanAccessStack(stack *portainer.Stack, resourceControl *portainer.ResourceControl, userID portainer.UserID, memberships []portainer.TeamMembership) bool { - if resourceControl == nil { - return false - } - - userTeamIDs := make([]portainer.TeamID, 0) - for _, membership := range memberships { - userTeamIDs = append(userTeamIDs, membership.TeamID) - } - - if canUserAccessResource(userID, userTeamIDs, resourceControl) { - return true - } - - return resourceControl.Public -} - -// FilterStacks filters stacks based on user role and resource controls. -func FilterStacks(stacks []portainer.Stack, resourceControls []portainer.ResourceControl, isAdmin bool, - userID portainer.UserID, memberships []portainer.TeamMembership) []ExtendedStack { - - filteredStacks := make([]ExtendedStack, 0) - - userTeamIDs := make([]portainer.TeamID, 0) - for _, membership := range memberships { - userTeamIDs = append(userTeamIDs, membership.TeamID) - } - - for _, stack := range stacks { - extendedStack := ExtendedStack{stack, portainer.ResourceControl{}} - resourceControl := getResourceControlByResourceID(stack.Name, resourceControls) - if resourceControl == nil && isAdmin { - filteredStacks = append(filteredStacks, extendedStack) - } else if resourceControl != nil && (isAdmin || resourceControl.Public || canUserAccessResource(userID, userTeamIDs, resourceControl)) { - extendedStack.ResourceControl = *resourceControl - filteredStacks = append(filteredStacks, extendedStack) - } - } - - return filteredStacks -} diff --git a/api/http/proxy/configs.go b/api/http/proxy/configs.go deleted file mode 100644 index 6863b5971..000000000 --- a/api/http/proxy/configs.go +++ /dev/null @@ -1,107 +0,0 @@ -package proxy - -import ( - "net/http" - - "github.com/portainer/portainer/api" -) - -const ( - // ErrDockerConfigIdentifierNotFound defines an error raised when Portainer is unable to find a config identifier - ErrDockerConfigIdentifierNotFound = portainer.Error("Docker config identifier not found") - configIdentifier = "ID" -) - -// configListOperation extracts the response as a JSON object, loop through the configs array -// decorate and/or filter the configs based on resource controls before rewriting the response -func configListOperation(response *http.Response, executor *operationExecutor) error { - var err error - - // ConfigList response is a JSON array - // https://docs.docker.com/engine/api/v1.30/#operation/ConfigList - responseArray, err := getResponseAsJSONArray(response) - if err != nil { - return err - } - - if executor.operationContext.isAdmin || executor.operationContext.endpointResourceAccess { - responseArray, err = decorateConfigList(responseArray, executor.operationContext.resourceControls) - } else { - responseArray, err = filterConfigList(responseArray, executor.operationContext) - } - if err != nil { - return err - } - - return rewriteResponse(response, responseArray, http.StatusOK) -} - -// configInspectOperation extracts the response as a JSON object, verify that the user -// has access to the config based on resource control (check are done based on the configID and optional Swarm service ID) -// and either rewrite an access denied response or a decorated config. -func configInspectOperation(response *http.Response, executor *operationExecutor) error { - // ConfigInspect response is a JSON object - // https://docs.docker.com/engine/api/v1.30/#operation/ConfigInspect - responseObject, err := getResponseAsJSONOBject(response) - if err != nil { - return err - } - - if responseObject[configIdentifier] == nil { - return ErrDockerConfigIdentifierNotFound - } - - configID := responseObject[configIdentifier].(string) - responseObject, access := applyResourceAccessControl(responseObject, configID, executor.operationContext) - if !access { - return rewriteAccessDeniedResponse(response) - } - - return rewriteResponse(response, responseObject, http.StatusOK) -} - -// decorateConfigList loops through all configs and decorates any config with an existing resource control. -// Resource controls checks are based on: resource identifier. -// Config object schema reference: https://docs.docker.com/engine/api/v1.30/#operation/ConfigList -func decorateConfigList(configData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { - decoratedConfigData := make([]interface{}, 0) - - for _, config := range configData { - - configObject := config.(map[string]interface{}) - if configObject[configIdentifier] == nil { - return nil, ErrDockerConfigIdentifierNotFound - } - - configID := configObject[configIdentifier].(string) - configObject = decorateResourceWithAccessControl(configObject, configID, resourceControls) - - decoratedConfigData = append(decoratedConfigData, configObject) - } - - return decoratedConfigData, nil -} - -// filterConfigList loops through all configs and filters public configs (no associated resource control) -// as well as authorized configs (access granted to the user based on existing resource control). -// Authorized configs are decorated during the process. -// Resource controls checks are based on: resource identifier. -// Config object schema reference: https://docs.docker.com/engine/api/v1.30/#operation/ConfigList -func filterConfigList(configData []interface{}, context *restrictedDockerOperationContext) ([]interface{}, error) { - filteredConfigData := make([]interface{}, 0) - - for _, config := range configData { - configObject := config.(map[string]interface{}) - if configObject[configIdentifier] == nil { - return nil, ErrDockerConfigIdentifierNotFound - } - - configID := configObject[configIdentifier].(string) - configObject, access := applyResourceAccessControl(configObject, configID, context) - if access { - filteredConfigData = append(filteredConfigData, configObject) - } - } - - return filteredConfigData, nil -} diff --git a/api/http/proxy/containers.go b/api/http/proxy/containers.go deleted file mode 100644 index 81bde4c4f..000000000 --- a/api/http/proxy/containers.go +++ /dev/null @@ -1,204 +0,0 @@ -package proxy - -import ( - "net/http" - - "github.com/portainer/portainer/api" -) - -const ( - // ErrDockerContainerIdentifierNotFound defines an error raised when Portainer is unable to find a container identifier - ErrDockerContainerIdentifierNotFound = portainer.Error("Docker container identifier not found") - containerIdentifier = "Id" - containerLabelForServiceIdentifier = "com.docker.swarm.service.id" - containerLabelForSwarmStackIdentifier = "com.docker.stack.namespace" - containerLabelForComposeStackIdentifier = "com.docker.compose.project" -) - -// containerListOperation extracts the response as a JSON object, loop through the containers array -// decorate and/or filter the containers based on resource controls before rewriting the response -func containerListOperation(response *http.Response, executor *operationExecutor) error { - var err error - // ContainerList response is a JSON array - // https://docs.docker.com/engine/api/v1.28/#operation/ContainerList - responseArray, err := getResponseAsJSONArray(response) - if err != nil { - return err - } - - if executor.operationContext.isAdmin || executor.operationContext.endpointResourceAccess { - responseArray, err = decorateContainerList(responseArray, executor.operationContext.resourceControls) - } else { - responseArray, err = filterContainerList(responseArray, executor.operationContext) - } - if err != nil { - return err - } - - if executor.labelBlackList != nil { - responseArray, err = filterContainersWithBlackListedLabels(responseArray, executor.labelBlackList) - if err != nil { - return err - } - } - - return rewriteResponse(response, responseArray, http.StatusOK) -} - -// containerInspectOperation extracts the response as a JSON object, verify that the user -// has access to the container based on resource control (check are done based on the containerID and optional Swarm service ID) -// and either rewrite an access denied response or a decorated container. -func containerInspectOperation(response *http.Response, executor *operationExecutor) error { - // ContainerInspect response is a JSON object - // https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect - responseObject, err := getResponseAsJSONOBject(response) - if err != nil { - return err - } - - if responseObject[containerIdentifier] == nil { - return ErrDockerContainerIdentifierNotFound - } - - containerID := responseObject[containerIdentifier].(string) - responseObject, access := applyResourceAccessControl(responseObject, containerID, executor.operationContext) - if access { - return rewriteResponse(response, responseObject, http.StatusOK) - } - - containerLabels := extractContainerLabelsFromContainerInspectObject(responseObject) - responseObject, access = applyResourceAccessControlFromLabel(containerLabels, responseObject, containerLabelForServiceIdentifier, executor.operationContext) - if access { - return rewriteResponse(response, responseObject, http.StatusOK) - } - - responseObject, access = applyResourceAccessControlFromLabel(containerLabels, responseObject, containerLabelForSwarmStackIdentifier, executor.operationContext) - if access { - return rewriteResponse(response, responseObject, http.StatusOK) - } - - responseObject, access = applyResourceAccessControlFromLabel(containerLabels, responseObject, containerLabelForComposeStackIdentifier, executor.operationContext) - if access { - return rewriteResponse(response, responseObject, http.StatusOK) - } - - return rewriteAccessDeniedResponse(response) -} - -// extractContainerLabelsFromContainerInspectObject retrieve the Labels of the container if present. -// Container schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect -func extractContainerLabelsFromContainerInspectObject(responseObject map[string]interface{}) map[string]interface{} { - // Labels are stored under Config.Labels - containerConfigObject := extractJSONField(responseObject, "Config") - if containerConfigObject != nil { - containerLabelsObject := extractJSONField(containerConfigObject, "Labels") - return containerLabelsObject - } - return nil -} - -// extractContainerLabelsFromContainerListObject retrieve the Labels of the container if present. -// Container schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList -func extractContainerLabelsFromContainerListObject(responseObject map[string]interface{}) map[string]interface{} { - // Labels are stored under Labels - containerLabelsObject := extractJSONField(responseObject, "Labels") - return containerLabelsObject -} - -// decorateContainerList loops through all containers and decorates any container with an existing resource control. -// Resource controls checks are based on: resource identifier, service identifier (from label), stack identifier (from label). -// Container object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList -func decorateContainerList(containerData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { - decoratedContainerData := make([]interface{}, 0) - - for _, container := range containerData { - - containerObject := container.(map[string]interface{}) - if containerObject[containerIdentifier] == nil { - return nil, ErrDockerContainerIdentifierNotFound - } - - containerID := containerObject[containerIdentifier].(string) - containerObject = decorateResourceWithAccessControl(containerObject, containerID, resourceControls) - - containerLabels := extractContainerLabelsFromContainerListObject(containerObject) - containerObject = decorateResourceWithAccessControlFromLabel(containerLabels, containerObject, containerLabelForServiceIdentifier, resourceControls) - containerObject = decorateResourceWithAccessControlFromLabel(containerLabels, containerObject, containerLabelForSwarmStackIdentifier, resourceControls) - containerObject = decorateResourceWithAccessControlFromLabel(containerLabels, containerObject, containerLabelForComposeStackIdentifier, resourceControls) - - decoratedContainerData = append(decoratedContainerData, containerObject) - } - - return decoratedContainerData, nil -} - -// filterContainerList loops through all containers and filters public containers (no associated resource control) -// as well as authorized containers (access granted to the user based on existing resource control). -// Authorized containers are decorated during the process. -// Resource controls checks are based on: resource identifier, service identifier (from label), stack identifier (from label). -// Container object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList -func filterContainerList(containerData []interface{}, context *restrictedDockerOperationContext) ([]interface{}, error) { - filteredContainerData := make([]interface{}, 0) - - for _, container := range containerData { - containerObject := container.(map[string]interface{}) - if containerObject[containerIdentifier] == nil { - return nil, ErrDockerContainerIdentifierNotFound - } - - containerID := containerObject[containerIdentifier].(string) - containerObject, access := applyResourceAccessControl(containerObject, containerID, context) - if !access { - containerLabels := extractContainerLabelsFromContainerListObject(containerObject) - containerObject, access = applyResourceAccessControlFromLabel(containerLabels, containerObject, containerLabelForComposeStackIdentifier, context) - if !access { - containerObject, access = applyResourceAccessControlFromLabel(containerLabels, containerObject, containerLabelForServiceIdentifier, context) - if !access { - containerObject, access = applyResourceAccessControlFromLabel(containerLabels, containerObject, containerLabelForSwarmStackIdentifier, context) - } - } - } - - if access { - filteredContainerData = append(filteredContainerData, containerObject) - } - } - - return filteredContainerData, nil -} - -// filterContainersWithLabels loops through a list of containers, and filters containers that do not contains -// any labels in the labels black list. -func filterContainersWithBlackListedLabels(containerData []interface{}, labelBlackList []portainer.Pair) ([]interface{}, error) { - filteredContainerData := make([]interface{}, 0) - - for _, container := range containerData { - containerObject := container.(map[string]interface{}) - - containerLabels := extractContainerLabelsFromContainerListObject(containerObject) - if containerLabels != nil { - if !containerHasBlackListedLabel(containerLabels, labelBlackList) { - filteredContainerData = append(filteredContainerData, containerObject) - } - } else { - filteredContainerData = append(filteredContainerData, containerObject) - } - } - - return filteredContainerData, nil -} - -func containerHasBlackListedLabel(containerLabels map[string]interface{}, labelBlackList []portainer.Pair) bool { - for key, value := range containerLabels { - labelName := key - labelValue := value.(string) - - for _, blackListedLabel := range labelBlackList { - if blackListedLabel.Name == labelName && blackListedLabel.Value == labelValue { - return true - } - } - } - - return false -} diff --git a/api/http/proxy/docker_transport.go b/api/http/proxy/docker_transport.go deleted file mode 100644 index 8abb21ce3..000000000 --- a/api/http/proxy/docker_transport.go +++ /dev/null @@ -1,600 +0,0 @@ -package proxy - -import ( - "encoding/base64" - "encoding/json" - "net/http" - "path" - "regexp" - "strings" - - "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/security" -) - -var apiVersionRe = regexp.MustCompile(`(/v[0-9]\.[0-9]*)?`) - -type ( - proxyTransport struct { - 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 - } - restrictedDockerOperationContext struct { - isAdmin bool - endpointResourceAccess bool - userID portainer.UserID - userTeamIDs []portainer.TeamID - resourceControls []portainer.ResourceControl - } - registryAccessContext struct { - isAdmin bool - userID portainer.UserID - teamMemberships []portainer.TeamMembership - registries []portainer.Registry - dockerHub *portainer.DockerHub - } - registryAuthenticationHeader struct { - Username string `json:"username"` - Password string `json:"password"` - Serveraddress string `json:"serveraddress"` - } - operationExecutor struct { - operationContext *restrictedDockerOperationContext - labelBlackList []portainer.Pair - } - restrictedOperationRequest func(*http.Response, *operationExecutor) error - operationRequest func(*http.Request) error -) - -func (p *proxyTransport) RoundTrip(request *http.Request) (*http.Response, error) { - return p.proxyDockerRequest(request) -} - -func (p *proxyTransport) executeDockerRequest(request *http.Request) (*http.Response, error) { - response, err := p.dockerTransport.RoundTrip(request) - - if p.endpointType != portainer.EdgeAgentEnvironment { - return response, err - } - - if err == nil { - p.ReverseTunnelService.SetTunnelStatusToActive(p.endpointIdentifier) - } else { - p.ReverseTunnelService.SetTunnelStatusToIdle(p.endpointIdentifier) - } - - return response, err -} - -func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Response, error) { - path := apiVersionRe.ReplaceAllString(request.URL.Path, "") - request.URL.Path = path - - if p.enableSignature { - signature, err := p.SignatureService.CreateSignature(portainer.PortainerAgentSignatureMessage) - if err != nil { - return nil, err - } - - request.Header.Set(portainer.PortainerAgentPublicKeyHeader, p.SignatureService.EncodedPublicKey()) - request.Header.Set(portainer.PortainerAgentSignatureHeader, signature) - } - - switch { - case strings.HasPrefix(path, "/configs"): - return p.proxyConfigRequest(request) - case strings.HasPrefix(path, "/containers"): - return p.proxyContainerRequest(request) - case strings.HasPrefix(path, "/services"): - return p.proxyServiceRequest(request) - case strings.HasPrefix(path, "/volumes"): - return p.proxyVolumeRequest(request) - case strings.HasPrefix(path, "/networks"): - return p.proxyNetworkRequest(request) - case strings.HasPrefix(path, "/secrets"): - return p.proxySecretRequest(request) - case strings.HasPrefix(path, "/swarm"): - return p.proxySwarmRequest(request) - case strings.HasPrefix(path, "/nodes"): - return p.proxyNodeRequest(request) - case strings.HasPrefix(path, "/tasks"): - return p.proxyTaskRequest(request) - case strings.HasPrefix(path, "/build"): - 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": - return p.executeDockerRequest(request) - - case "/configs": - return p.rewriteOperation(request, configListOperation) - - default: - // assume /configs/{id} - if request.Method == http.MethodGet { - return p.rewriteOperation(request, configInspectOperation) - } - configID := path.Base(requestPath) - return p.restrictedOperation(request, configID) - } -} - -func (p *proxyTransport) proxyContainerRequest(request *http.Request) (*http.Response, error) { - switch requestPath := request.URL.Path; requestPath { - case "/containers/create": - return p.executeDockerRequest(request) - - case "/containers/prune": - return p.administratorOperation(request) - - case "/containers/json": - return p.rewriteOperationWithLabelFiltering(request, containerListOperation) - - default: - // This section assumes /containers/** - if match, _ := path.Match("/containers/*/*", requestPath); match { - // Handle /containers/{id}/{action} requests - containerID := path.Base(path.Dir(requestPath)) - action := path.Base(requestPath) - - if action == "json" { - return p.rewriteOperation(request, containerInspectOperation) - } - return p.restrictedOperation(request, containerID) - } else if match, _ := path.Match("/containers/*", requestPath); match { - // Handle /containers/{id} requests - containerID := path.Base(requestPath) - return p.restrictedOperation(request, containerID) - } - return p.executeDockerRequest(request) - } -} - -func (p *proxyTransport) proxyServiceRequest(request *http.Request) (*http.Response, error) { - switch requestPath := request.URL.Path; requestPath { - case "/services/create": - return p.replaceRegistryAuthenticationHeader(request) - - case "/services": - return p.rewriteOperation(request, serviceListOperation) - - default: - // This section assumes /services/** - if match, _ := path.Match("/services/*/*", requestPath); match { - // Handle /services/{id}/{action} requests - serviceID := path.Base(path.Dir(requestPath)) - return p.restrictedOperation(request, serviceID) - } else if match, _ := path.Match("/services/*", requestPath); match { - // Handle /services/{id} requests - serviceID := path.Base(requestPath) - - if request.Method == http.MethodGet { - return p.rewriteOperation(request, serviceInspectOperation) - } - return p.restrictedOperation(request, serviceID) - } - return p.executeDockerRequest(request) - } -} - -func (p *proxyTransport) proxyVolumeRequest(request *http.Request) (*http.Response, error) { - switch requestPath := request.URL.Path; requestPath { - case "/volumes/create": - return p.executeDockerRequest(request) - - case "/volumes/prune": - return p.administratorOperation(request) - - case "/volumes": - return p.rewriteOperation(request, volumeListOperation) - - default: - // assume /volumes/{name} - if request.Method == http.MethodGet { - return p.rewriteOperation(request, volumeInspectOperation) - } - volumeID := path.Base(requestPath) - return p.restrictedOperation(request, volumeID) - } -} - -func (p *proxyTransport) proxyNetworkRequest(request *http.Request) (*http.Response, error) { - switch requestPath := request.URL.Path; requestPath { - case "/networks/create": - return p.executeDockerRequest(request) - - case "/networks": - return p.rewriteOperation(request, networkListOperation) - - default: - // assume /networks/{id} - if request.Method == http.MethodGet { - return p.rewriteOperation(request, networkInspectOperation) - } - networkID := path.Base(requestPath) - return p.restrictedOperation(request, networkID) - } -} - -func (p *proxyTransport) proxySecretRequest(request *http.Request) (*http.Response, error) { - switch requestPath := request.URL.Path; requestPath { - case "/secrets/create": - return p.executeDockerRequest(request) - - case "/secrets": - return p.rewriteOperation(request, secretListOperation) - - default: - // assume /secrets/{id} - if request.Method == http.MethodGet { - return p.rewriteOperation(request, secretInspectOperation) - } - secretID := path.Base(requestPath) - return p.restrictedOperation(request, secretID) - } -} - -func (p *proxyTransport) proxyNodeRequest(request *http.Request) (*http.Response, error) { - requestPath := request.URL.Path - - // assume /nodes/{id} - if path.Base(requestPath) != "nodes" { - return p.administratorOperation(request) - } - - return p.executeDockerRequest(request) -} - -func (p *proxyTransport) proxySwarmRequest(request *http.Request) (*http.Response, error) { - switch requestPath := request.URL.Path; requestPath { - case "/swarm": - return p.rewriteOperation(request, swarmInspectOperation) - default: - // assume /swarm/{action} - return p.administratorOperation(request) - } -} - -func (p *proxyTransport) proxyTaskRequest(request *http.Request) (*http.Response, error) { - switch requestPath := request.URL.Path; requestPath { - case "/tasks": - return p.rewriteOperation(request, taskListOperation) - default: - // assume /tasks/{id} - return p.executeDockerRequest(request) - } -} - -func (p *proxyTransport) proxyBuildRequest(request *http.Request) (*http.Response, error) { - return p.interceptAndRewriteRequest(request, buildOperation) -} - -func (p *proxyTransport) proxyImageRequest(request *http.Request) (*http.Response, error) { - switch requestPath := request.URL.Path; requestPath { - case "/images/create": - return p.replaceRegistryAuthenticationHeader(request) - default: - if path.Base(requestPath) == "push" && request.Method == http.MethodPost { - return p.replaceRegistryAuthenticationHeader(request) - } - return p.executeDockerRequest(request) - } -} - -func (p *proxyTransport) replaceRegistryAuthenticationHeader(request *http.Request) (*http.Response, error) { - accessContext, err := p.createRegistryAccessContext(request) - if err != nil { - return nil, err - } - - originalHeader := request.Header.Get("X-Registry-Auth") - - if originalHeader != "" { - - decodedHeaderData, err := base64.StdEncoding.DecodeString(originalHeader) - if err != nil { - return nil, err - } - - var originalHeaderData registryAuthenticationHeader - err = json.Unmarshal(decodedHeaderData, &originalHeaderData) - if err != nil { - return nil, err - } - - authenticationHeader := createRegistryAuthenticationHeader(originalHeaderData.Serveraddress, accessContext) - - headerData, err := json.Marshal(authenticationHeader) - if err != nil { - return nil, err - } - - header := base64.StdEncoding.EncodeToString(headerData) - - request.Header.Set("X-Registry-Auth", header) - } - - return p.executeDockerRequest(request) -} - -// restrictedOperation ensures that the current user has the required authorizations -// before executing the original request. -func (p *proxyTransport) restrictedOperation(request *http.Request, resourceID string) (*http.Response, error) { - var err error - tokenData, err := security.RetrieveTokenData(request) - if err != nil { - return nil, err - } - - if tokenData.Role != portainer.AdministratorRole { - - teamMemberships, err := p.TeamMembershipService.TeamMembershipsByUserID(tokenData.ID) - if err != nil { - return nil, err - } - - userTeamIDs := make([]portainer.TeamID, 0) - for _, membership := range teamMemberships { - userTeamIDs = append(userTeamIDs, membership.TeamID) - } - - resourceControls, err := p.ResourceControlService.ResourceControls() - if err != nil { - return nil, err - } - - resourceControl := getResourceControlByResourceID(resourceID, resourceControls) - if resourceControl == nil || !canUserAccessResource(tokenData.ID, userTeamIDs, resourceControl) { - return writeAccessDeniedResponse() - } - } - - return p.executeDockerRequest(request) -} - -// 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() - } - } - - return p.executeDockerRequest(request) -} - -// rewriteOperationWithLabelFiltering will create a new operation context with data that will be used -// to decorate the original request's response as well as retrieve all the black listed labels -// to filter the resources. -func (p *proxyTransport) rewriteOperationWithLabelFiltering(request *http.Request, operation restrictedOperationRequest) (*http.Response, error) { - operationContext, err := p.createOperationContext(request) - if err != nil { - return nil, err - } - - settings, err := p.SettingsService.Settings() - if err != nil { - return nil, err - } - - executor := &operationExecutor{ - operationContext: operationContext, - labelBlackList: settings.BlackListedLabels, - } - - return p.executeRequestAndRewriteResponse(request, operation, executor) -} - -// rewriteOperation will create a new operation context with data that will be used -// to decorate the original request's response. -func (p *proxyTransport) rewriteOperation(request *http.Request, operation restrictedOperationRequest) (*http.Response, error) { - operationContext, err := p.createOperationContext(request) - if err != nil { - return nil, err - } - - executor := &operationExecutor{ - operationContext: operationContext, - } - - return p.executeRequestAndRewriteResponse(request, operation, executor) -} - -func (p *proxyTransport) interceptAndRewriteRequest(request *http.Request, operation operationRequest) (*http.Response, error) { - err := operation(request) - if err != nil { - return nil, err - } - - return p.executeDockerRequest(request) -} - -func (p *proxyTransport) executeRequestAndRewriteResponse(request *http.Request, operation restrictedOperationRequest, executor *operationExecutor) (*http.Response, error) { - response, err := p.executeDockerRequest(request) - if err != nil { - return response, err - } - - err = operation(response, executor) - return response, err -} - -// administratorOperation ensures that the user has administrator privileges -// before executing the original request. -func (p *proxyTransport) administratorOperation(request *http.Request) (*http.Response, error) { - tokenData, err := security.RetrieveTokenData(request) - if err != nil { - return nil, err - } - - if tokenData.Role != portainer.AdministratorRole { - return writeAccessDeniedResponse() - } - - return p.executeDockerRequest(request) -} - -func (p *proxyTransport) createRegistryAccessContext(request *http.Request) (*registryAccessContext, error) { - tokenData, err := security.RetrieveTokenData(request) - if err != nil { - return nil, err - } - - accessContext := ®istryAccessContext{ - isAdmin: true, - userID: tokenData.ID, - } - - hub, err := p.DockerHubService.DockerHub() - if err != nil { - return nil, err - } - accessContext.dockerHub = hub - - registries, err := p.RegistryService.Registries() - if err != nil { - return nil, err - } - accessContext.registries = registries - - if tokenData.Role != portainer.AdministratorRole { - accessContext.isAdmin = false - - teamMemberships, err := p.TeamMembershipService.TeamMembershipsByUserID(tokenData.ID) - if err != nil { - return nil, err - } - - accessContext.teamMemberships = teamMemberships - } - - return accessContext, nil -} - -func (p *proxyTransport) createOperationContext(request *http.Request) (*restrictedDockerOperationContext, error) { - var err error - tokenData, err := security.RetrieveTokenData(request) - if err != nil { - return nil, err - } - - resourceControls, err := p.ResourceControlService.ResourceControls() - if err != nil { - return nil, err - } - - operationContext := &restrictedDockerOperationContext{ - isAdmin: true, - userID: tokenData.ID, - resourceControls: resourceControls, - endpointResourceAccess: false, - } - - if tokenData.Role != portainer.AdministratorRole { - operationContext.isAdmin = false - - 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 - } - - teamMemberships, err := p.TeamMembershipService.TeamMembershipsByUserID(tokenData.ID) - if err != nil { - return nil, err - } - - userTeamIDs := make([]portainer.TeamID, 0) - for _, membership := range teamMemberships { - userTeamIDs = append(userTeamIDs, membership.TeamID) - } - operationContext.userTeamIDs = userTeamIDs - } - - return operationContext, nil -} diff --git a/api/http/proxy/factory.go b/api/http/proxy/factory.go deleted file mode 100644 index b9416707d..000000000 --- a/api/http/proxy/factory.go +++ /dev/null @@ -1,101 +0,0 @@ -package proxy - -import ( - "net" - "net/http" - "net/http/httputil" - "net/url" - - "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/crypto" -) - -// AzureAPIBaseURL is the URL where Azure API requests will be proxied. -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 { - u.Scheme = "http" - return httputil.NewSingleHostReverseProxy(u) -} - -func newAzureProxy(credentials *portainer.AzureCredentials) (http.Handler, error) { - remoteURL, err := url.Parse(AzureAPIBaseURL) - if err != nil { - return nil, err - } - - proxy := newSingleHostReverseProxyWithHostHeader(remoteURL) - proxy.Transport = NewAzureTransport(credentials) - - return proxy, nil -} - -func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, tlsConfig *portainer.TLSConfiguration, endpoint *portainer.Endpoint) (http.Handler, error) { - u.Scheme = "https" - - proxy := factory.createDockerReverseProxy(u, endpoint) - config, err := crypto.CreateTLSConfigurationFromDisk(tlsConfig.TLSCACertPath, tlsConfig.TLSCertPath, tlsConfig.TLSKeyPath, tlsConfig.TLSSkipVerify) - if err != nil { - return nil, err - } - - proxy.Transport.(*proxyTransport).dockerTransport.TLSClientConfig = config - return proxy, nil -} - -func (factory *proxyFactory) newDockerHTTPProxy(u *url.URL, endpoint *portainer.Endpoint) http.Handler { - u.Scheme = "http" - return factory.createDockerReverseProxy(u, endpoint) -} - -func (factory *proxyFactory) createDockerReverseProxy(u *url.URL, endpoint *portainer.Endpoint) *httputil.ReverseProxy { - proxy := newSingleHostReverseProxyWithHostHeader(u) - - enableSignature := false - if endpoint.Type == portainer.AgentOnDockerEnvironment { - enableSignature = true - } - - 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, - } - - if enableSignature { - transport.SignatureService = factory.SignatureService - } - - proxy.Transport = transport - return proxy -} - -func newSocketTransport(socketPath string) *http.Transport { - return &http.Transport{ - Dial: func(proto, addr string) (conn net.Conn, err error) { - return net.Dial("unix", socketPath) - }, - } -} diff --git a/api/http/proxy/factory/azure.go b/api/http/proxy/factory/azure.go new file mode 100644 index 000000000..27b8a26f8 --- /dev/null +++ b/api/http/proxy/factory/azure.go @@ -0,0 +1,20 @@ +package factory + +import ( + "net/http" + "net/url" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy/factory/azure" +) + +func newAzureProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + remoteURL, err := url.Parse(azureAPIBaseURL) + if err != nil { + return nil, err + } + + proxy := newSingleHostReverseProxyWithHostHeader(remoteURL) + proxy.Transport = azure.NewTransport(&endpoint.AzureCredentials) + return proxy, nil +} diff --git a/api/http/proxy/azure_transport.go b/api/http/proxy/factory/azure/transport.go similarity index 67% rename from api/http/proxy/azure_transport.go rename to api/http/proxy/factory/azure/transport.go index f0752b925..0c8505c8b 100644 --- a/api/http/proxy/azure_transport.go +++ b/api/http/proxy/factory/azure/transport.go @@ -1,4 +1,4 @@ -package proxy +package azure import ( "net/http" @@ -16,9 +16,7 @@ type ( expirationTime time.Time } - // AzureTransport represents a transport used when executing HTTP requests - // against the Azure API. - AzureTransport struct { + Transport struct { credentials *portainer.AzureCredentials client *client.HTTPClient token *azureAPIToken @@ -26,15 +24,27 @@ type ( } ) -// NewAzureTransport returns a pointer to an AzureTransport instance. -func NewAzureTransport(credentials *portainer.AzureCredentials) *AzureTransport { - return &AzureTransport{ +// NewTransport returns a pointer to a new instance of Transport that implements the HTTP Transport +// interface for proxying requests to the Azure API. +func NewTransport(credentials *portainer.AzureCredentials) *Transport { + return &Transport{ credentials: credentials, client: client.NewHTTPClient(), } } -func (transport *AzureTransport) authenticate() error { +// RoundTrip is the implementation of the the http.RoundTripper interface +func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) { + err := transport.retrieveAuthenticationToken() + if err != nil { + return nil, err + } + + request.Header.Set("Authorization", "Bearer "+transport.token.value) + return http.DefaultTransport.RoundTrip(request) +} + +func (transport *Transport) authenticate() error { token, err := transport.client.ExecuteAzureAuthenticationRequest(transport.credentials) if err != nil { return err @@ -53,7 +63,7 @@ func (transport *AzureTransport) authenticate() error { return nil } -func (transport *AzureTransport) retrieveAuthenticationToken() error { +func (transport *Transport) retrieveAuthenticationToken() error { transport.mutex.Lock() defer transport.mutex.Unlock() @@ -68,14 +78,3 @@ func (transport *AzureTransport) retrieveAuthenticationToken() error { return nil } - -// RoundTrip is the implementation of the Transport interface. -func (transport *AzureTransport) RoundTrip(request *http.Request) (*http.Response, error) { - err := transport.retrieveAuthenticationToken() - if err != nil { - return nil, err - } - - request.Header.Set("Authorization", "Bearer "+transport.token.value) - return http.DefaultTransport.RoundTrip(request) -} diff --git a/api/http/proxy/factory/docker.go b/api/http/proxy/factory/docker.go new file mode 100644 index 000000000..365abb2fb --- /dev/null +++ b/api/http/proxy/factory/docker.go @@ -0,0 +1,118 @@ +package factory + +import ( + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + + httperror "github.com/portainer/libhttp/error" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/api/http/proxy/factory/docker" +) + +func (factory *ProxyFactory) newDockerProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") { + return factory.newDockerLocalProxy(endpoint) + } + + return factory.newDockerHTTPProxy(endpoint) +} + +func (factory *ProxyFactory) newDockerLocalProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + endpointURL, err := url.Parse(endpoint.URL) + if err != nil { + return nil, err + } + + return factory.newOSBasedLocalProxy(endpointURL.Path, endpoint) +} + +func (factory *ProxyFactory) newDockerHTTPProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + if endpoint.Type == portainer.EdgeAgentEnvironment { + tunnel := factory.reverseTunnelService.GetTunnelDetails(endpoint.ID) + endpoint.URL = fmt.Sprintf("http://localhost:%d", tunnel.Port) + } + + endpointURL, err := url.Parse(endpoint.URL) + if err != nil { + return nil, err + } + + endpointURL.Scheme = "http" + httpTransport := &http.Transport{} + + if endpoint.TLSConfig.TLS || endpoint.TLSConfig.TLSSkipVerify { + config, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify) + if err != nil { + return nil, err + } + + httpTransport.TLSClientConfig = config + endpointURL.Scheme = "https" + } + + transportParameters := &docker.TransportParameters{ + Endpoint: endpoint, + ResourceControlService: factory.resourceControlService, + UserService: factory.userService, + TeamService: factory.teamService, + TeamMembershipService: factory.teamMembershipService, + RegistryService: factory.registryService, + DockerHubService: factory.dockerHubService, + SettingsService: factory.settingsService, + ReverseTunnelService: factory.reverseTunnelService, + ExtensionService: factory.extensionService, + SignatureService: factory.signatureService, + DockerClientFactory: factory.dockerClientFactory, + } + + dockerTransport, err := docker.NewTransport(transportParameters, httpTransport) + if err != nil { + return nil, err + } + + proxy := newSingleHostReverseProxyWithHostHeader(endpointURL) + proxy.Transport = dockerTransport + return proxy, nil +} + +type dockerLocalProxy struct { + transport *docker.Transport +} + +// ServeHTTP is the http.Handler interface implementation +// for a local (Unix socket or Windows named pipe) Docker proxy. +func (proxy *dockerLocalProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Force URL/domain to http/unixsocket to be able to + // use http.transport RoundTrip to do the requests via the socket + r.URL.Scheme = "http" + r.URL.Host = "unixsocket" + + res, err := proxy.transport.ProxyDockerRequest(r) + if err != nil { + code := http.StatusInternalServerError + if res != nil && res.StatusCode != 0 { + code = res.StatusCode + } + + httperror.WriteError(w, code, "Unable to proxy the request via the Docker socket", err) + return + } + defer res.Body.Close() + + for k, vv := range res.Header { + for _, v := range vv { + w.Header().Add(k, v) + } + } + + w.WriteHeader(res.StatusCode) + + if _, err := io.Copy(w, res.Body); err != nil { + log.Printf("proxy error: %s\n", err) + } +} diff --git a/api/http/proxy/factory/docker/access_control.go b/api/http/proxy/factory/docker/access_control.go new file mode 100644 index 000000000..83050f79e --- /dev/null +++ b/api/http/proxy/factory/docker/access_control.go @@ -0,0 +1,306 @@ +package docker + +import ( + "log" + "net/http" + "strings" + + "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + + "github.com/portainer/portainer/api" +) + +const ( + resourceLabelForPortainerTeamResourceControl = "io.portainer.accesscontrol.teams" + resourceLabelForPortainerUserResourceControl = "io.portainer.accesscontrol.users" + resourceLabelForPortainerPublicResourceControl = "io.portainer.accesscontrol.public" + resourceLabelForDockerSwarmStackName = "com.docker.stack.namespace" + resourceLabelForDockerServiceID = "com.docker.swarm.service.id" + resourceLabelForDockerComposeStackName = "com.docker.compose.project" +) + +type ( + resourceLabelsObjectSelector func(map[string]interface{}) map[string]interface{} + resourceOperationParameters struct { + resourceIdentifierAttribute string + resourceType portainer.ResourceControlType + labelsObjectSelector resourceLabelsObjectSelector + } +) + +func (transport *Transport) newResourceControlFromPortainerLabels(labelsObject map[string]interface{}, resourceID string, resourceType portainer.ResourceControlType) (*portainer.ResourceControl, error) { + if labelsObject[resourceLabelForPortainerPublicResourceControl] != nil { + resourceControl := portainer.NewPublicResourceControl(resourceID, resourceType) + + err := transport.resourceControlService.CreateResourceControl(resourceControl) + if err != nil { + return nil, err + } + + return resourceControl, nil + } + + teamNames := make([]string, 0) + userNames := make([]string, 0) + if labelsObject[resourceLabelForPortainerTeamResourceControl] != nil { + concatenatedTeamNames := labelsObject[resourceLabelForPortainerTeamResourceControl].(string) + teamNames = strings.Split(concatenatedTeamNames, ",") + } + + if labelsObject[resourceLabelForPortainerUserResourceControl] != nil { + concatenatedUserNames := labelsObject[resourceLabelForPortainerUserResourceControl].(string) + userNames = strings.Split(concatenatedUserNames, ",") + } + + if len(teamNames) > 0 || len(userNames) > 0 { + teamIDs := make([]portainer.TeamID, 0) + userIDs := make([]portainer.UserID, 0) + + for _, name := range teamNames { + team, err := transport.teamService.TeamByName(name) + if err != nil { + log.Printf("[WARN] [http,proxy,docker] [message: unknown team name in access control label, ignoring access control rule for this team] [name: %s] [resource_id: %s]", name, resourceID) + continue + } + + teamIDs = append(teamIDs, team.ID) + } + + for _, name := range userNames { + user, err := transport.userService.UserByUsername(name) + if err != nil { + log.Printf("[WARN] [http,proxy,docker] [message: unknown user name in access control label, ignoring access control rule for this user] [name: %s] [resource_id: %s]", name, resourceID) + continue + } + + userIDs = append(userIDs, user.ID) + } + + resourceControl := portainer.NewRestrictedResourceControl(resourceID, resourceType, userIDs, teamIDs) + + err := transport.resourceControlService.CreateResourceControl(resourceControl) + if err != nil { + return nil, err + } + + return resourceControl, nil + } + + return nil, nil +} + +func (transport *Transport) createPrivateResourceControl(resourceIdentifier string, resourceType portainer.ResourceControlType, userID portainer.UserID) (*portainer.ResourceControl, error) { + resourceControl := portainer.NewPrivateResourceControl(resourceIdentifier, resourceType, userID) + + err := transport.resourceControlService.CreateResourceControl(resourceControl) + if err != nil { + log.Printf("[ERROR] [http,proxy,docker,transport] [message: unable to persist resource control] [resource: %s] [err: %s]", resourceIdentifier, err) + return nil, err + } + + return resourceControl, nil +} + +func (transport *Transport) getInheritedResourceControlFromServiceOrStack(resourceIdentifier, nodeName string, resourceType portainer.ResourceControlType, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) { + client := transport.dockerClient + + if nodeName != "" { + dockerClient, err := transport.dockerClientFactory.CreateClient(transport.endpoint, nodeName) + if err != nil { + return nil, err + } + defer dockerClient.Close() + + client = dockerClient + } + + switch resourceType { + case portainer.ContainerResourceControl: + return getInheritedResourceControlFromContainerLabels(client, resourceIdentifier, resourceControls) + case portainer.NetworkResourceControl: + return getInheritedResourceControlFromNetworkLabels(client, resourceIdentifier, resourceControls) + case portainer.VolumeResourceControl: + return getInheritedResourceControlFromVolumeLabels(client, resourceIdentifier, resourceControls) + case portainer.ServiceResourceControl: + return getInheritedResourceControlFromServiceLabels(client, resourceIdentifier, resourceControls) + case portainer.ConfigResourceControl: + return getInheritedResourceControlFromConfigLabels(client, resourceIdentifier, resourceControls) + case portainer.SecretResourceControl: + return getInheritedResourceControlFromSecretLabels(client, resourceIdentifier, resourceControls) + } + + return nil, nil +} + +func (transport *Transport) applyAccessControlOnResource(parameters *resourceOperationParameters, responseObject map[string]interface{}, response *http.Response, executor *operationExecutor) error { + if responseObject[parameters.resourceIdentifierAttribute] == nil { + log.Printf("[WARN] [message: unable to find resource identifier property in resource object] [identifier_attribute: %s]", parameters.resourceIdentifierAttribute) + return nil + } + + if parameters.resourceType == portainer.NetworkResourceControl { + systemResourceControl := findSystemNetworkResourceControl(responseObject) + if systemResourceControl != nil { + responseObject = decorateObject(responseObject, systemResourceControl) + return responseutils.RewriteResponse(response, responseObject, http.StatusOK) + } + } + + resourceIdentifier := responseObject[parameters.resourceIdentifierAttribute].(string) + resourceLabelsObject := parameters.labelsObjectSelector(responseObject) + + resourceControl, err := transport.findResourceControl(resourceIdentifier, parameters.resourceType, resourceLabelsObject, executor.operationContext.resourceControls) + if err != nil { + return err + } + + if resourceControl == nil && (executor.operationContext.isAdmin || executor.operationContext.endpointResourceAccess) { + return responseutils.RewriteResponse(response, responseObject, http.StatusOK) + } + + if executor.operationContext.isAdmin || executor.operationContext.endpointResourceAccess || portainer.UserCanAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl) { + responseObject = decorateObject(responseObject, resourceControl) + return responseutils.RewriteResponse(response, responseObject, http.StatusOK) + } + + return responseutils.RewriteAccessDeniedResponse(response) +} + +func (transport *Transport) applyAccessControlOnResourceList(parameters *resourceOperationParameters, resourceData []interface{}, executor *operationExecutor) ([]interface{}, error) { + if executor.operationContext.isAdmin || executor.operationContext.endpointResourceAccess { + return transport.decorateResourceList(parameters, resourceData, executor.operationContext.resourceControls) + } + + return transport.filterResourceList(parameters, resourceData, executor.operationContext) +} + +func (transport *Transport) decorateResourceList(parameters *resourceOperationParameters, resourceData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { + decoratedResourceData := make([]interface{}, 0) + + for _, resource := range resourceData { + resourceObject := resource.(map[string]interface{}) + + if resourceObject[parameters.resourceIdentifierAttribute] == nil { + log.Printf("[WARN] [http,proxy,docker,decorate] [message: unable to find resource identifier property in resource list element] [identifier_attribute: %s]", parameters.resourceIdentifierAttribute) + continue + } + + if parameters.resourceType == portainer.NetworkResourceControl { + systemResourceControl := findSystemNetworkResourceControl(resourceObject) + if systemResourceControl != nil { + resourceObject = decorateObject(resourceObject, systemResourceControl) + decoratedResourceData = append(decoratedResourceData, resourceObject) + continue + } + } + + resourceIdentifier := resourceObject[parameters.resourceIdentifierAttribute].(string) + resourceLabelsObject := parameters.labelsObjectSelector(resourceObject) + + resourceControl, err := transport.findResourceControl(resourceIdentifier, parameters.resourceType, resourceLabelsObject, resourceControls) + if err != nil { + return nil, err + } + + if resourceControl != nil { + resourceObject = decorateObject(resourceObject, resourceControl) + } + + decoratedResourceData = append(decoratedResourceData, resourceObject) + } + + return decoratedResourceData, nil +} + +func (transport *Transport) filterResourceList(parameters *resourceOperationParameters, resourceData []interface{}, context *restrictedDockerOperationContext) ([]interface{}, error) { + filteredResourceData := make([]interface{}, 0) + + for _, resource := range resourceData { + resourceObject := resource.(map[string]interface{}) + if resourceObject[parameters.resourceIdentifierAttribute] == nil { + log.Printf("[WARN] [http,proxy,docker,filter] [message: unable to find resource identifier property in resource list element] [identifier_attribute: %s]", parameters.resourceIdentifierAttribute) + continue + } + + resourceIdentifier := resourceObject[parameters.resourceIdentifierAttribute].(string) + resourceLabelsObject := parameters.labelsObjectSelector(resourceObject) + + if parameters.resourceType == portainer.NetworkResourceControl { + systemResourceControl := findSystemNetworkResourceControl(resourceObject) + if systemResourceControl != nil { + resourceObject = decorateObject(resourceObject, systemResourceControl) + filteredResourceData = append(filteredResourceData, resourceObject) + continue + } + } + + resourceControl, err := transport.findResourceControl(resourceIdentifier, parameters.resourceType, resourceLabelsObject, context.resourceControls) + if err != nil { + return nil, err + } + + if resourceControl == nil { + if context.isAdmin || context.endpointResourceAccess { + filteredResourceData = append(filteredResourceData, resourceObject) + } + continue + } + + if context.isAdmin || context.endpointResourceAccess || portainer.UserCanAccessResource(context.userID, context.userTeamIDs, resourceControl) { + resourceObject = decorateObject(resourceObject, resourceControl) + filteredResourceData = append(filteredResourceData, resourceObject) + } + } + + return filteredResourceData, nil +} + +func (transport *Transport) findResourceControl(resourceIdentifier string, resourceType portainer.ResourceControlType, resourceLabelsObject map[string]interface{}, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) { + resourceControl := portainer.GetResourceControlByResourceIDAndType(resourceIdentifier, resourceType, resourceControls) + if resourceControl != nil { + return resourceControl, nil + } + + if resourceLabelsObject != nil { + if resourceLabelsObject[resourceLabelForDockerServiceID] != nil { + inheritedServiceIdentifier := resourceLabelsObject[resourceLabelForDockerServiceID].(string) + resourceControl = portainer.GetResourceControlByResourceIDAndType(inheritedServiceIdentifier, portainer.ServiceResourceControl, resourceControls) + + if resourceControl != nil { + return resourceControl, nil + } + } + + if resourceLabelsObject[resourceLabelForDockerSwarmStackName] != nil { + inheritedSwarmStackIdentifier := resourceLabelsObject[resourceLabelForDockerSwarmStackName].(string) + resourceControl = portainer.GetResourceControlByResourceIDAndType(inheritedSwarmStackIdentifier, portainer.StackResourceControl, resourceControls) + + if resourceControl != nil { + return resourceControl, nil + } + } + + if resourceLabelsObject[resourceLabelForDockerComposeStackName] != nil { + inheritedComposeStackIdentifier := resourceLabelsObject[resourceLabelForDockerComposeStackName].(string) + resourceControl = portainer.GetResourceControlByResourceIDAndType(inheritedComposeStackIdentifier, portainer.StackResourceControl, resourceControls) + + if resourceControl != nil { + return resourceControl, nil + } + } + + return transport.newResourceControlFromPortainerLabels(resourceLabelsObject, resourceIdentifier, resourceType) + } + + return nil, nil +} + +func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} { + if object["Portainer"] == nil { + object["Portainer"] = make(map[string]interface{}) + } + + portainerMetadata := object["Portainer"].(map[string]interface{}) + portainerMetadata["ResourceControl"] = resourceControl + return object +} diff --git a/api/http/proxy/build.go b/api/http/proxy/factory/docker/build.go similarity index 99% rename from api/http/proxy/build.go rename to api/http/proxy/factory/docker/build.go index 93f0d4a68..4bec82ec0 100644 --- a/api/http/proxy/build.go +++ b/api/http/proxy/factory/docker/build.go @@ -1,4 +1,4 @@ -package proxy +package docker import ( "bytes" diff --git a/api/http/proxy/factory/docker/configs.go b/api/http/proxy/factory/docker/configs.go new file mode 100644 index 000000000..e9092dc3f --- /dev/null +++ b/api/http/proxy/factory/docker/configs.go @@ -0,0 +1,86 @@ +package docker + +import ( + "context" + "net/http" + + "github.com/docker/docker/client" + + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy/factory/responseutils" +) + +const ( + configObjectIdentifier = "ID" +) + +func getInheritedResourceControlFromConfigLabels(dockerClient *client.Client, configID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) { + config, _, err := dockerClient.ConfigInspectWithRaw(context.Background(), configID) + if err != nil { + return nil, err + } + + swarmStackName := config.Spec.Labels[resourceLabelForDockerSwarmStackName] + if swarmStackName != "" { + return portainer.GetResourceControlByResourceIDAndType(swarmStackName, portainer.StackResourceControl, resourceControls), nil + } + + return nil, nil +} + +// configListOperation extracts the response as a JSON object, loop through the configs array +// decorate and/or filter the configs based on resource controls before rewriting the response. +func (transport *Transport) configListOperation(response *http.Response, executor *operationExecutor) error { + // ConfigList response is a JSON array + // https://docs.docker.com/engine/api/v1.30/#operation/ConfigList + responseArray, err := responseutils.GetResponseAsJSONArray(response) + if err != nil { + return err + } + + resourceOperationParameters := &resourceOperationParameters{ + resourceIdentifierAttribute: configObjectIdentifier, + resourceType: portainer.ConfigResourceControl, + labelsObjectSelector: selectorConfigLabels, + } + + responseArray, err = transport.applyAccessControlOnResourceList(resourceOperationParameters, responseArray, executor) + if err != nil { + return err + } + + return responseutils.RewriteResponse(response, responseArray, http.StatusOK) +} + +// configInspectOperation extracts the response as a JSON object, verify that the user +// has access to the config based on resource control and either rewrite an access denied response or a decorated config. +func (transport *Transport) configInspectOperation(response *http.Response, executor *operationExecutor) error { + // ConfigInspect response is a JSON object + // https://docs.docker.com/engine/api/v1.30/#operation/ConfigInspect + responseObject, err := responseutils.GetResponseAsJSONOBject(response) + if err != nil { + return err + } + + resourceOperationParameters := &resourceOperationParameters{ + resourceIdentifierAttribute: configObjectIdentifier, + resourceType: portainer.ConfigResourceControl, + labelsObjectSelector: selectorConfigLabels, + } + + return transport.applyAccessControlOnResource(resourceOperationParameters, responseObject, response, executor) +} + +// selectorConfigLabels retrieve the labels object associated to the config object. +// Labels are available under the "Spec.Labels" property. +// API schema references: +// https://docs.docker.com/engine/api/v1.40/#operation/ConfigList +// https://docs.docker.com/engine/api/v1.40/#operation/ConfigInspect +func selectorConfigLabels(responseObject map[string]interface{}) map[string]interface{} { + secretSpec := responseutils.GetJSONObject(responseObject, "Spec") + if secretSpec != nil { + secretLabelsObject := responseutils.GetJSONObject(secretSpec, "Labels") + return secretLabelsObject + } + return nil +} diff --git a/api/http/proxy/factory/docker/containers.go b/api/http/proxy/factory/docker/containers.go new file mode 100644 index 000000000..8952b1bf6 --- /dev/null +++ b/api/http/proxy/factory/docker/containers.go @@ -0,0 +1,146 @@ +package docker + +import ( + "context" + "net/http" + + "github.com/docker/docker/client" + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy/factory/responseutils" +) + +const ( + containerObjectIdentifier = "Id" +) + +func getInheritedResourceControlFromContainerLabels(dockerClient *client.Client, containerID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) { + container, err := dockerClient.ContainerInspect(context.Background(), containerID) + if err != nil { + return nil, err + } + + swarmStackName := container.Config.Labels[resourceLabelForDockerSwarmStackName] + if swarmStackName != "" { + return portainer.GetResourceControlByResourceIDAndType(swarmStackName, portainer.StackResourceControl, resourceControls), nil + } + + serviceName := container.Config.Labels[resourceLabelForDockerServiceID] + if serviceName != "" { + return portainer.GetResourceControlByResourceIDAndType(serviceName, portainer.ServiceResourceControl, resourceControls), nil + } + + composeStackName := container.Config.Labels[resourceLabelForDockerComposeStackName] + if composeStackName != "" { + return portainer.GetResourceControlByResourceIDAndType(composeStackName, portainer.StackResourceControl, resourceControls), nil + } + + return nil, nil +} + +// containerListOperation extracts the response as a JSON array, loop through the containers array +// decorate and/or filter the containers based on resource controls before rewriting the response. +func (transport *Transport) containerListOperation(response *http.Response, executor *operationExecutor) error { + // ContainerList response is a JSON array + // https://docs.docker.com/engine/api/v1.28/#operation/ContainerList + responseArray, err := responseutils.GetResponseAsJSONArray(response) + if err != nil { + return err + } + + resourceOperationParameters := &resourceOperationParameters{ + resourceIdentifierAttribute: containerObjectIdentifier, + resourceType: portainer.ContainerResourceControl, + labelsObjectSelector: selectorContainerLabelsFromContainerListOperation, + } + + responseArray, err = transport.applyAccessControlOnResourceList(resourceOperationParameters, responseArray, executor) + if err != nil { + return err + } + + if executor.labelBlackList != nil { + responseArray, err = filterContainersWithBlackListedLabels(responseArray, executor.labelBlackList) + if err != nil { + return err + } + } + + return responseutils.RewriteResponse(response, responseArray, http.StatusOK) +} + +// containerInspectOperation extracts the response as a JSON object, verify that the user +// has access to the container based on resource control and either rewrite an access denied response or a decorated container. +func (transport *Transport) containerInspectOperation(response *http.Response, executor *operationExecutor) error { + //ContainerInspect response is a JSON object + // https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect + responseObject, err := responseutils.GetResponseAsJSONOBject(response) + if err != nil { + return err + } + + resourceOperationParameters := &resourceOperationParameters{ + resourceIdentifierAttribute: containerObjectIdentifier, + resourceType: portainer.ContainerResourceControl, + labelsObjectSelector: selectorContainerLabelsFromContainerInspectOperation, + } + + return transport.applyAccessControlOnResource(resourceOperationParameters, responseObject, response, executor) +} + +// selectorContainerLabelsFromContainerInspectOperation retrieve the labels object associated to the container object. +// This selector is specific to the containerInspect Docker operation. +// Labels are available under the "Config.Labels" property. +// API schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect +func selectorContainerLabelsFromContainerInspectOperation(responseObject map[string]interface{}) map[string]interface{} { + containerConfigObject := responseutils.GetJSONObject(responseObject, "Config") + if containerConfigObject != nil { + containerLabelsObject := responseutils.GetJSONObject(containerConfigObject, "Labels") + return containerLabelsObject + } + return nil +} + +// selectorContainerLabelsFromContainerListOperation retrieve the labels object associated to the container object. +// This selector is specific to the containerList Docker operation. +// Labels are available under the "Labels" property. +// API schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList +func selectorContainerLabelsFromContainerListOperation(responseObject map[string]interface{}) map[string]interface{} { + containerLabelsObject := responseutils.GetJSONObject(responseObject, "Labels") + return containerLabelsObject +} + +// filterContainersWithLabels loops through a list of containers, and filters containers that do not contains +// any labels in the labels black list. +func filterContainersWithBlackListedLabels(containerData []interface{}, labelBlackList []portainer.Pair) ([]interface{}, error) { + filteredContainerData := make([]interface{}, 0) + + for _, container := range containerData { + containerObject := container.(map[string]interface{}) + + containerLabels := selectorContainerLabelsFromContainerListOperation(containerObject) + if containerLabels != nil { + if !containerHasBlackListedLabel(containerLabels, labelBlackList) { + filteredContainerData = append(filteredContainerData, containerObject) + } + } else { + filteredContainerData = append(filteredContainerData, containerObject) + } + } + + return filteredContainerData, nil +} + +func containerHasBlackListedLabel(containerLabels map[string]interface{}, labelBlackList []portainer.Pair) bool { + for key, value := range containerLabels { + labelName := key + labelValue := value.(string) + + for _, blackListedLabel := range labelBlackList { + if blackListedLabel.Name == labelName && blackListedLabel.Value == labelValue { + return true + } + } + } + + return false +} diff --git a/api/http/proxy/factory/docker/networks.go b/api/http/proxy/factory/docker/networks.go new file mode 100644 index 000000000..c50b716e2 --- /dev/null +++ b/api/http/proxy/factory/docker/networks.go @@ -0,0 +1,101 @@ +package docker + +import ( + "context" + "net/http" + + "github.com/docker/docker/api/types" + + "github.com/docker/docker/client" + + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy/factory/responseutils" +) + +const ( + networkObjectIdentifier = "Id" + networkObjectName = "Name" +) + +func getInheritedResourceControlFromNetworkLabels(dockerClient *client.Client, networkID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) { + network, err := dockerClient.NetworkInspect(context.Background(), networkID, types.NetworkInspectOptions{}) + if err != nil { + return nil, err + } + + swarmStackName := network.Labels[resourceLabelForDockerSwarmStackName] + if swarmStackName != "" { + return portainer.GetResourceControlByResourceIDAndType(swarmStackName, portainer.StackResourceControl, resourceControls), nil + } + + return nil, nil +} + +// networkListOperation extracts the response as a JSON object, loop through the networks array +// decorate and/or filter the networks based on resource controls before rewriting the response. +func (transport *Transport) networkListOperation(response *http.Response, executor *operationExecutor) error { + // NetworkList response is a JSON array + // https://docs.docker.com/engine/api/v1.28/#operation/NetworkList + responseArray, err := responseutils.GetResponseAsJSONArray(response) + if err != nil { + return err + } + + resourceOperationParameters := &resourceOperationParameters{ + resourceIdentifierAttribute: networkObjectIdentifier, + resourceType: portainer.NetworkResourceControl, + labelsObjectSelector: selectorNetworkLabels, + } + + responseArray, err = transport.applyAccessControlOnResourceList(resourceOperationParameters, responseArray, executor) + if err != nil { + return err + } + + return responseutils.RewriteResponse(response, responseArray, http.StatusOK) +} + +// networkInspectOperation extracts the response as a JSON object, verify that the user +// has access to the network based on resource control and either rewrite an access denied response or a decorated network. +func (transport *Transport) networkInspectOperation(response *http.Response, executor *operationExecutor) error { + // NetworkInspect response is a JSON object + // https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect + responseObject, err := responseutils.GetResponseAsJSONOBject(response) + if err != nil { + return err + } + + resourceOperationParameters := &resourceOperationParameters{ + resourceIdentifierAttribute: networkObjectIdentifier, + resourceType: portainer.NetworkResourceControl, + labelsObjectSelector: selectorNetworkLabels, + } + + return transport.applyAccessControlOnResource(resourceOperationParameters, responseObject, response, executor) +} + +// findSystemNetworkResourceControl will check if the network object is a system network +// and will return a system resource control if that's the case. +func findSystemNetworkResourceControl(networkObject map[string]interface{}) *portainer.ResourceControl { + if networkObject[networkObjectName] == nil { + return nil + } + + networkID := networkObject[networkObjectIdentifier].(string) + networkName := networkObject[networkObjectName].(string) + + if networkName == "bridge" || networkName == "host" || networkName == "none" { + return portainer.NewSystemResourceControl(networkID, portainer.NetworkResourceControl) + } + + return nil +} + +// selectorNetworkLabels retrieve the labels object associated to the network object. +// Labels are available under the "Labels" property. +// API schema references: +// https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect +// https://docs.docker.com/engine/api/v1.28/#operation/NetworkList +func selectorNetworkLabels(responseObject map[string]interface{}) map[string]interface{} { + return responseutils.GetJSONObject(responseObject, "Labels") +} diff --git a/api/http/proxy/registry.go b/api/http/proxy/factory/docker/registry.go similarity index 73% rename from api/http/proxy/registry.go rename to api/http/proxy/factory/docker/registry.go index 5f614f29d..c07ebae3d 100644 --- a/api/http/proxy/registry.go +++ b/api/http/proxy/factory/docker/registry.go @@ -1,10 +1,25 @@ -package proxy +package docker import ( "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" ) +type ( + registryAccessContext struct { + isAdmin bool + userID portainer.UserID + teamMemberships []portainer.TeamMembership + registries []portainer.Registry + dockerHub *portainer.DockerHub + } + registryAuthenticationHeader struct { + Username string `json:"username"` + Password string `json:"password"` + Serveraddress string `json:"serveraddress"` + } +) + func createRegistryAuthenticationHeader(serverAddress string, accessContext *registryAccessContext) *registryAuthenticationHeader { var authenticationHeader *registryAuthenticationHeader diff --git a/api/http/proxy/factory/docker/secrets.go b/api/http/proxy/factory/docker/secrets.go new file mode 100644 index 000000000..08a4045cf --- /dev/null +++ b/api/http/proxy/factory/docker/secrets.go @@ -0,0 +1,87 @@ +package docker + +import ( + "context" + "net/http" + + "github.com/docker/docker/client" + + "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + + "github.com/portainer/portainer/api" +) + +const ( + secretObjectIdentifier = "ID" +) + +func getInheritedResourceControlFromSecretLabels(dockerClient *client.Client, secretID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) { + secret, _, err := dockerClient.SecretInspectWithRaw(context.Background(), secretID) + if err != nil { + return nil, err + } + + swarmStackName := secret.Spec.Labels[resourceLabelForDockerSwarmStackName] + if swarmStackName != "" { + return portainer.GetResourceControlByResourceIDAndType(swarmStackName, portainer.StackResourceControl, resourceControls), nil + } + + return nil, nil +} + +// secretListOperation extracts the response as a JSON object, loop through the secrets array +// decorate and/or filter the secrets based on resource controls before rewriting the response. +func (transport *Transport) secretListOperation(response *http.Response, executor *operationExecutor) error { + // SecretList response is a JSON array + // https://docs.docker.com/engine/api/v1.28/#operation/SecretList + responseArray, err := responseutils.GetResponseAsJSONArray(response) + if err != nil { + return err + } + + resourceOperationParameters := &resourceOperationParameters{ + resourceIdentifierAttribute: secretObjectIdentifier, + resourceType: portainer.SecretResourceControl, + labelsObjectSelector: selectorSecretLabels, + } + + responseArray, err = transport.applyAccessControlOnResourceList(resourceOperationParameters, responseArray, executor) + if err != nil { + return err + } + + return responseutils.RewriteResponse(response, responseArray, http.StatusOK) +} + +// secretInspectOperation extracts the response as a JSON object, verify that the user +// has access to the secret based on resource control and either rewrite an access denied response or a decorated secret. +func (transport *Transport) secretInspectOperation(response *http.Response, executor *operationExecutor) error { + // SecretInspect response is a JSON object + // https://docs.docker.com/engine/api/v1.28/#operation/SecretInspect + responseObject, err := responseutils.GetResponseAsJSONOBject(response) + if err != nil { + return err + } + + resourceOperationParameters := &resourceOperationParameters{ + resourceIdentifierAttribute: secretObjectIdentifier, + resourceType: portainer.SecretResourceControl, + labelsObjectSelector: selectorSecretLabels, + } + + return transport.applyAccessControlOnResource(resourceOperationParameters, responseObject, response, executor) +} + +// selectorSecretLabels retrieve the labels object associated to the secret object. +// Labels are available under the "Spec.Labels" property. +// API schema references: +// https://docs.docker.com/engine/api/v1.40/#operation/SecretList +// https://docs.docker.com/engine/api/v1.40/#operation/SecretInspect +func selectorSecretLabels(responseObject map[string]interface{}) map[string]interface{} { + secretSpec := responseutils.GetJSONObject(responseObject, "Spec") + if secretSpec != nil { + secretLabelsObject := responseutils.GetJSONObject(secretSpec, "Labels") + return secretLabelsObject + } + return nil +} diff --git a/api/http/proxy/factory/docker/services.go b/api/http/proxy/factory/docker/services.go new file mode 100644 index 000000000..eb1aa6e80 --- /dev/null +++ b/api/http/proxy/factory/docker/services.go @@ -0,0 +1,86 @@ +package docker + +import ( + "context" + "net/http" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/client" + + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy/factory/responseutils" +) + +const ( + serviceObjectIdentifier = "ID" +) + +func getInheritedResourceControlFromServiceLabels(dockerClient *client.Client, serviceID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) { + service, _, err := dockerClient.ServiceInspectWithRaw(context.Background(), serviceID, types.ServiceInspectOptions{}) + if err != nil { + return nil, err + } + + swarmStackName := service.Spec.Labels[resourceLabelForDockerSwarmStackName] + if swarmStackName != "" { + return portainer.GetResourceControlByResourceIDAndType(swarmStackName, portainer.StackResourceControl, resourceControls), nil + } + + return nil, nil +} + +// serviceListOperation extracts the response as a JSON array, loop through the service array +// decorate and/or filter the services based on resource controls before rewriting the response. +func (transport *Transport) serviceListOperation(response *http.Response, executor *operationExecutor) error { + // ServiceList response is a JSON array + // https://docs.docker.com/engine/api/v1.28/#operation/ServiceList + responseArray, err := responseutils.GetResponseAsJSONArray(response) + if err != nil { + return err + } + + resourceOperationParameters := &resourceOperationParameters{ + resourceIdentifierAttribute: serviceObjectIdentifier, + resourceType: portainer.ServiceResourceControl, + labelsObjectSelector: selectorServiceLabels, + } + + responseArray, err = transport.applyAccessControlOnResourceList(resourceOperationParameters, responseArray, executor) + if err != nil { + return err + } + + return responseutils.RewriteResponse(response, responseArray, http.StatusOK) +} + +// serviceInspectOperation extracts the response as a JSON object, verify that the user +// has access to the service based on resource control and either rewrite an access denied response or a decorated service. +func (transport *Transport) serviceInspectOperation(response *http.Response, executor *operationExecutor) error { + //ServiceInspect response is a JSON object + //https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect + responseObject, err := responseutils.GetResponseAsJSONOBject(response) + if err != nil { + return err + } + + resourceOperationParameters := &resourceOperationParameters{ + resourceIdentifierAttribute: serviceObjectIdentifier, + resourceType: portainer.ServiceResourceControl, + labelsObjectSelector: selectorServiceLabels, + } + + return transport.applyAccessControlOnResource(resourceOperationParameters, responseObject, response, executor) +} + +// selectorServiceLabels retrieve the labels object associated to the service object. +// Labels are available under the "Spec.Labels" property. +// API schema references: +// https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect +// https://docs.docker.com/engine/api/v1.28/#operation/ServiceList +func selectorServiceLabels(responseObject map[string]interface{}) map[string]interface{} { + serviceSpecObject := responseutils.GetJSONObject(responseObject, "Spec") + if serviceSpecObject != nil { + return responseutils.GetJSONObject(serviceSpecObject, "Labels") + } + return nil +} diff --git a/api/http/proxy/swarm.go b/api/http/proxy/factory/docker/swarm.go similarity index 71% rename from api/http/proxy/swarm.go rename to api/http/proxy/factory/docker/swarm.go index 0048b3a38..57d27f8cc 100644 --- a/api/http/proxy/swarm.go +++ b/api/http/proxy/factory/docker/swarm.go @@ -1,7 +1,9 @@ -package proxy +package docker import ( "net/http" + + "github.com/portainer/portainer/api/http/proxy/factory/responseutils" ) // swarmInspectOperation extracts the response as a JSON object and rewrites the response based @@ -9,7 +11,7 @@ import ( func swarmInspectOperation(response *http.Response, executor *operationExecutor) error { // SwarmInspect response is a JSON object // https://docs.docker.com/engine/api/v1.30/#operation/SwarmInspect - responseObject, err := getResponseAsJSONOBject(response) + responseObject, err := responseutils.GetResponseAsJSONOBject(response) if err != nil { return err } @@ -19,5 +21,5 @@ func swarmInspectOperation(response *http.Response, executor *operationExecutor) delete(responseObject, "TLSInfo") } - return rewriteResponse(response, responseObject, http.StatusOK) + return responseutils.RewriteResponse(response, responseObject, http.StatusOK) } diff --git a/api/http/proxy/factory/docker/tasks.go b/api/http/proxy/factory/docker/tasks.go new file mode 100644 index 000000000..ad13398fd --- /dev/null +++ b/api/http/proxy/factory/docker/tasks.go @@ -0,0 +1,50 @@ +package docker + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy/factory/responseutils" +) + +const ( + taskServiceObjectIdentifier = "ServiceID" +) + +// taskListOperation extracts the response as a JSON array, loop through the tasks array +// and filter the containers based on resource controls before rewriting the response. +func (transport *Transport) taskListOperation(response *http.Response, executor *operationExecutor) error { + // TaskList response is a JSON array + // https://docs.docker.com/engine/api/v1.28/#operation/TaskList + responseArray, err := responseutils.GetResponseAsJSONArray(response) + if err != nil { + return err + } + + resourceOperationParameters := &resourceOperationParameters{ + resourceIdentifierAttribute: taskServiceObjectIdentifier, + resourceType: portainer.ServiceResourceControl, + labelsObjectSelector: selectorTaskLabels, + } + + responseArray, err = transport.applyAccessControlOnResourceList(resourceOperationParameters, responseArray, executor) + if err != nil { + return err + } + + return responseutils.RewriteResponse(response, responseArray, http.StatusOK) +} + +// selectorServiceLabels retrieve the labels object associated to the task object. +// Labels are available under the "Spec.ContainerSpec.Labels" property. +// API schema reference: https://docs.docker.com/engine/api/v1.28/#operation/TaskList +func selectorTaskLabels(responseObject map[string]interface{}) map[string]interface{} { + taskSpecObject := responseutils.GetJSONObject(responseObject, "Spec") + if taskSpecObject != nil { + containerSpecObject := responseutils.GetJSONObject(taskSpecObject, "ContainerSpec") + if containerSpecObject != nil { + return responseutils.GetJSONObject(containerSpecObject, "Labels") + } + } + return nil +} diff --git a/api/http/proxy/factory/docker/transport.go b/api/http/proxy/factory/docker/transport.go new file mode 100644 index 000000000..469e3f67d --- /dev/null +++ b/api/http/proxy/factory/docker/transport.go @@ -0,0 +1,727 @@ +package docker + +import ( + "encoding/base64" + "encoding/json" + "errors" + "log" + "net/http" + "path" + "regexp" + "strings" + + "github.com/portainer/portainer/api/docker" + + "github.com/docker/docker/client" + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/http/security" +) + +var apiVersionRe = regexp.MustCompile(`(/v[0-9]\.[0-9]*)?`) + +type ( + // Transport is a custom transport for Docker API reverse proxy. It allows + // interception of requests and rewriting of responses. + Transport struct { + HTTPTransport *http.Transport + endpoint *portainer.Endpoint + resourceControlService portainer.ResourceControlService + userService portainer.UserService + teamService portainer.TeamService + teamMembershipService portainer.TeamMembershipService + registryService portainer.RegistryService + dockerHubService portainer.DockerHubService + settingsService portainer.SettingsService + signatureService portainer.DigitalSignatureService + reverseTunnelService portainer.ReverseTunnelService + extensionService portainer.ExtensionService + dockerClient *client.Client + dockerClientFactory *docker.ClientFactory + } + + // TransportParameters is used to create a new Transport + TransportParameters struct { + Endpoint *portainer.Endpoint + ResourceControlService portainer.ResourceControlService + UserService portainer.UserService + TeamService portainer.TeamService + TeamMembershipService portainer.TeamMembershipService + RegistryService portainer.RegistryService + DockerHubService portainer.DockerHubService + SettingsService portainer.SettingsService + SignatureService portainer.DigitalSignatureService + ReverseTunnelService portainer.ReverseTunnelService + ExtensionService portainer.ExtensionService + DockerClientFactory *docker.ClientFactory + } + + restrictedDockerOperationContext struct { + isAdmin bool + endpointResourceAccess bool + userID portainer.UserID + userTeamIDs []portainer.TeamID + resourceControls []portainer.ResourceControl + } + + operationExecutor struct { + operationContext *restrictedDockerOperationContext + labelBlackList []portainer.Pair + } + restrictedOperationRequest func(*http.Response, *operationExecutor) error + operationRequest func(*http.Request) error +) + +// NewTransport returns a pointer to a new Transport instance. +func NewTransport(parameters *TransportParameters, httpTransport *http.Transport) (*Transport, error) { + dockerClient, err := parameters.DockerClientFactory.CreateClient(parameters.Endpoint, "") + if err != nil { + return nil, err + } + + transport := &Transport{ + endpoint: parameters.Endpoint, + resourceControlService: parameters.ResourceControlService, + userService: parameters.UserService, + teamService: parameters.TeamService, + teamMembershipService: parameters.TeamMembershipService, + registryService: parameters.RegistryService, + dockerHubService: parameters.DockerHubService, + settingsService: parameters.SettingsService, + signatureService: parameters.SignatureService, + reverseTunnelService: parameters.ReverseTunnelService, + extensionService: parameters.ExtensionService, + dockerClientFactory: parameters.DockerClientFactory, + HTTPTransport: httpTransport, + dockerClient: dockerClient, + } + + return transport, nil +} + +// RoundTrip is the implementation of the the http.RoundTripper interface +func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) { + return transport.ProxyDockerRequest(request) +} + +// ProxyDockerRequest intercepts a Docker API request and apply logic based +// on the requested operation. +func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Response, error) { + requestPath := apiVersionRe.ReplaceAllString(request.URL.Path, "") + request.URL.Path = requestPath + + if transport.endpoint.Type == portainer.AgentOnDockerEnvironment { + signature, err := transport.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage) + if err != nil { + return nil, err + } + + request.Header.Set(portainer.PortainerAgentPublicKeyHeader, transport.signatureService.EncodedPublicKey()) + request.Header.Set(portainer.PortainerAgentSignatureHeader, signature) + } + + switch { + case strings.HasPrefix(requestPath, "/configs"): + return transport.proxyConfigRequest(request) + case strings.HasPrefix(requestPath, "/containers"): + return transport.proxyContainerRequest(request) + case strings.HasPrefix(requestPath, "/services"): + return transport.proxyServiceRequest(request) + case strings.HasPrefix(requestPath, "/volumes"): + return transport.proxyVolumeRequest(request) + case strings.HasPrefix(requestPath, "/networks"): + return transport.proxyNetworkRequest(request) + case strings.HasPrefix(requestPath, "/secrets"): + return transport.proxySecretRequest(request) + case strings.HasPrefix(requestPath, "/swarm"): + return transport.proxySwarmRequest(request) + case strings.HasPrefix(requestPath, "/nodes"): + return transport.proxyNodeRequest(request) + case strings.HasPrefix(requestPath, "/tasks"): + return transport.proxyTaskRequest(request) + case strings.HasPrefix(requestPath, "/build"): + return transport.proxyBuildRequest(request) + case strings.HasPrefix(requestPath, "/images"): + return transport.proxyImageRequest(request) + case strings.HasPrefix(requestPath, "/v2"): + return transport.proxyAgentRequest(request) + default: + return transport.executeDockerRequest(request) + } +} + +func (transport *Transport) executeDockerRequest(request *http.Request) (*http.Response, error) { + response, err := transport.HTTPTransport.RoundTrip(request) + + if transport.endpoint.Type != portainer.EdgeAgentEnvironment { + return response, err + } + + if err == nil { + transport.reverseTunnelService.SetTunnelStatusToActive(transport.endpoint.ID) + } else { + transport.reverseTunnelService.SetTunnelStatusToIdle(transport.endpoint.ID) + } + + return response, err +} + +func (transport *Transport) 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 transport.administratorOperation(r) + } + + return transport.restrictedResourceOperation(r, volumeIDParameter[0], portainer.VolumeResourceControl, true) + } + + return transport.executeDockerRequest(r) +} + +func (transport *Transport) proxyConfigRequest(request *http.Request) (*http.Response, error) { + switch requestPath := request.URL.Path; requestPath { + case "/configs/create": + return transport.decorateGenericResourceCreationOperation(request, configObjectIdentifier, portainer.ConfigResourceControl) + + case "/configs": + return transport.rewriteOperation(request, transport.configListOperation) + + default: + // assume /configs/{id} + configID := path.Base(requestPath) + + if request.Method == http.MethodGet { + return transport.rewriteOperation(request, transport.configInspectOperation) + } else if request.Method == http.MethodDelete { + return transport.executeGenericResourceDeletionOperation(request, configID, portainer.ConfigResourceControl) + } + + return transport.restrictedResourceOperation(request, configID, portainer.ConfigResourceControl, false) + } +} + +func (transport *Transport) proxyContainerRequest(request *http.Request) (*http.Response, error) { + switch requestPath := request.URL.Path; requestPath { + case "/containers/create": + return transport.decorateGenericResourceCreationOperation(request, containerObjectIdentifier, portainer.ContainerResourceControl) + + case "/containers/prune": + return transport.administratorOperation(request) + + case "/containers/json": + return transport.rewriteOperationWithLabelFiltering(request, transport.containerListOperation) + + default: + // This section assumes /containers/** + if match, _ := path.Match("/containers/*/*", requestPath); match { + // Handle /containers/{id}/{action} requests + containerID := path.Base(path.Dir(requestPath)) + action := path.Base(requestPath) + + if action == "json" { + return transport.rewriteOperation(request, transport.containerInspectOperation) + } + return transport.restrictedResourceOperation(request, containerID, portainer.ContainerResourceControl, false) + } else if match, _ := path.Match("/containers/*", requestPath); match { + // Handle /containers/{id} requests + containerID := path.Base(requestPath) + + if request.Method == http.MethodDelete { + return transport.executeGenericResourceDeletionOperation(request, containerID, portainer.ContainerResourceControl) + } + + return transport.restrictedResourceOperation(request, containerID, portainer.ContainerResourceControl, false) + } + return transport.executeDockerRequest(request) + } +} + +func (transport *Transport) proxyServiceRequest(request *http.Request) (*http.Response, error) { + switch requestPath := request.URL.Path; requestPath { + case "/services/create": + return transport.replaceRegistryAuthenticationHeader(request) + + case "/services": + return transport.rewriteOperation(request, transport.serviceListOperation) + + default: + // This section assumes /services/** + if match, _ := path.Match("/services/*/*", requestPath); match { + // Handle /services/{id}/{action} requests + serviceID := path.Base(path.Dir(requestPath)) + return transport.restrictedResourceOperation(request, serviceID, portainer.ServiceResourceControl, false) + } else if match, _ := path.Match("/services/*", requestPath); match { + // Handle /services/{id} requests + serviceID := path.Base(requestPath) + + switch request.Method { + case http.MethodGet: + return transport.rewriteOperation(request, transport.serviceInspectOperation) + case http.MethodDelete: + return transport.executeGenericResourceDeletionOperation(request, serviceID, portainer.ServiceResourceControl) + } + return transport.restrictedResourceOperation(request, serviceID, portainer.ServiceResourceControl, false) + } + return transport.executeDockerRequest(request) + } +} + +func (transport *Transport) proxyVolumeRequest(request *http.Request) (*http.Response, error) { + switch requestPath := request.URL.Path; requestPath { + case "/volumes/create": + return transport.decorateGenericResourceCreationOperation(request, volumeObjectIdentifier, portainer.VolumeResourceControl) + + case "/volumes/prune": + return transport.administratorOperation(request) + + case "/volumes": + return transport.rewriteOperation(request, transport.volumeListOperation) + + default: + // assume /volumes/{name} + volumeID := path.Base(requestPath) + + if request.Method == http.MethodGet { + return transport.rewriteOperation(request, transport.volumeInspectOperation) + } else if request.Method == http.MethodDelete { + return transport.executeGenericResourceDeletionOperation(request, volumeID, portainer.VolumeResourceControl) + } + return transport.restrictedResourceOperation(request, volumeID, portainer.VolumeResourceControl, false) + } +} + +func (transport *Transport) proxyNetworkRequest(request *http.Request) (*http.Response, error) { + switch requestPath := request.URL.Path; requestPath { + case "/networks/create": + return transport.decorateGenericResourceCreationOperation(request, networkObjectIdentifier, portainer.NetworkResourceControl) + + case "/networks": + return transport.rewriteOperation(request, transport.networkListOperation) + + default: + // assume /networks/{id} + networkID := path.Base(requestPath) + + if request.Method == http.MethodGet { + return transport.rewriteOperation(request, transport.networkInspectOperation) + } else if request.Method == http.MethodDelete { + return transport.executeGenericResourceDeletionOperation(request, networkID, portainer.NetworkResourceControl) + } + return transport.restrictedResourceOperation(request, networkID, portainer.NetworkResourceControl, false) + } +} + +func (transport *Transport) proxySecretRequest(request *http.Request) (*http.Response, error) { + switch requestPath := request.URL.Path; requestPath { + case "/secrets/create": + return transport.decorateGenericResourceCreationOperation(request, secretObjectIdentifier, portainer.SecretResourceControl) + + case "/secrets": + return transport.rewriteOperation(request, transport.secretListOperation) + + default: + // assume /secrets/{id} + secretID := path.Base(requestPath) + + if request.Method == http.MethodGet { + return transport.rewriteOperation(request, transport.secretInspectOperation) + } else if request.Method == http.MethodDelete { + return transport.executeGenericResourceDeletionOperation(request, secretID, portainer.SecretResourceControl) + } + return transport.restrictedResourceOperation(request, secretID, portainer.SecretResourceControl, false) + } +} + +func (transport *Transport) proxyNodeRequest(request *http.Request) (*http.Response, error) { + requestPath := request.URL.Path + + // assume /nodes/{id} + if path.Base(requestPath) != "nodes" { + return transport.administratorOperation(request) + } + + return transport.executeDockerRequest(request) +} + +func (transport *Transport) proxySwarmRequest(request *http.Request) (*http.Response, error) { + switch requestPath := request.URL.Path; requestPath { + case "/swarm": + return transport.rewriteOperation(request, swarmInspectOperation) + default: + // assume /swarm/{action} + return transport.administratorOperation(request) + } +} + +func (transport *Transport) proxyTaskRequest(request *http.Request) (*http.Response, error) { + switch requestPath := request.URL.Path; requestPath { + case "/tasks": + return transport.rewriteOperation(request, transport.taskListOperation) + default: + // assume /tasks/{id} + return transport.executeDockerRequest(request) + } +} + +func (transport *Transport) proxyBuildRequest(request *http.Request) (*http.Response, error) { + return transport.interceptAndRewriteRequest(request, buildOperation) +} + +func (transport *Transport) proxyImageRequest(request *http.Request) (*http.Response, error) { + switch requestPath := request.URL.Path; requestPath { + case "/images/create": + return transport.replaceRegistryAuthenticationHeader(request) + default: + if path.Base(requestPath) == "push" && request.Method == http.MethodPost { + return transport.replaceRegistryAuthenticationHeader(request) + } + return transport.executeDockerRequest(request) + } +} + +func (transport *Transport) replaceRegistryAuthenticationHeader(request *http.Request) (*http.Response, error) { + accessContext, err := transport.createRegistryAccessContext(request) + if err != nil { + return nil, err + } + + originalHeader := request.Header.Get("X-Registry-Auth") + + if originalHeader != "" { + + decodedHeaderData, err := base64.StdEncoding.DecodeString(originalHeader) + if err != nil { + return nil, err + } + + var originalHeaderData registryAuthenticationHeader + err = json.Unmarshal(decodedHeaderData, &originalHeaderData) + if err != nil { + return nil, err + } + + authenticationHeader := createRegistryAuthenticationHeader(originalHeaderData.Serveraddress, accessContext) + + headerData, err := json.Marshal(authenticationHeader) + if err != nil { + return nil, err + } + + header := base64.StdEncoding.EncodeToString(headerData) + + request.Header.Set("X-Registry-Auth", header) + } + + return transport.decorateGenericResourceCreationOperation(request, serviceObjectIdentifier, portainer.ServiceResourceControl) +} + +func (transport *Transport) restrictedResourceOperation(request *http.Request, resourceID string, resourceType portainer.ResourceControlType, volumeBrowseRestrictionCheck bool) (*http.Response, error) { + var err error + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, err + } + + if tokenData.Role != portainer.AdministratorRole { + rbacExtension, err := transport.extensionService.Extension(portainer.RBACExtension) + if err != nil && err != portainer.ErrObjectNotFound { + return nil, err + } + + user, err := transport.userService.User(tokenData.ID) + if err != nil { + return nil, err + } + + if volumeBrowseRestrictionCheck { + settings, err := transport.settingsService.Settings() + if err != nil { + return nil, err + } + + // Return access denied for all roles except endpoint-administrator + _, userCanBrowse := user.EndpointAuthorizations[transport.endpoint.ID][portainer.OperationDockerAgentBrowseList] + if rbacExtension != nil && !settings.AllowVolumeBrowserForRegularUsers && !userCanBrowse { + return responseutils.WriteAccessDeniedResponse() + } + } + + endpointResourceAccess := false + _, ok := user.EndpointAuthorizations[transport.endpoint.ID][portainer.EndpointResourcesAccess] + if ok { + endpointResourceAccess = true + } + + if rbacExtension != nil && endpointResourceAccess { + return transport.executeDockerRequest(request) + } + + teamMemberships, err := transport.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 := transport.resourceControlService.ResourceControls() + if err != nil { + return nil, err + } + + resourceControl := portainer.GetResourceControlByResourceIDAndType(resourceID, resourceType, resourceControls) + if resourceControl == nil { + agentTargetHeader := request.Header.Get(portainer.PortainerAgentTargetHeader) + + // This resource was created outside of portainer, + // is part of a Docker service or part of a Docker Swarm/Compose stack. + inheritedResourceControl, err := transport.getInheritedResourceControlFromServiceOrStack(resourceID, agentTargetHeader, resourceType, resourceControls) + if err != nil { + return nil, err + } + + if inheritedResourceControl == nil || !portainer.UserCanAccessResource(tokenData.ID, userTeamIDs, inheritedResourceControl) { + return responseutils.WriteAccessDeniedResponse() + } + } + + if resourceControl != nil && !portainer.UserCanAccessResource(tokenData.ID, userTeamIDs, resourceControl) { + return responseutils.WriteAccessDeniedResponse() + } + } + + return transport.executeDockerRequest(request) +} + +// rewriteOperationWithLabelFiltering will create a new operation context with data that will be used +// to decorate the original request's response as well as retrieve all the black listed labels +// to filter the resources. +func (transport *Transport) rewriteOperationWithLabelFiltering(request *http.Request, operation restrictedOperationRequest) (*http.Response, error) { + operationContext, err := transport.createOperationContext(request) + if err != nil { + return nil, err + } + + settings, err := transport.settingsService.Settings() + if err != nil { + return nil, err + } + + executor := &operationExecutor{ + operationContext: operationContext, + labelBlackList: settings.BlackListedLabels, + } + + return transport.executeRequestAndRewriteResponse(request, operation, executor) +} + +// rewriteOperation will create a new operation context with data that will be used +// to decorate the original request's response. +func (transport *Transport) rewriteOperation(request *http.Request, operation restrictedOperationRequest) (*http.Response, error) { + operationContext, err := transport.createOperationContext(request) + if err != nil { + return nil, err + } + + executor := &operationExecutor{ + operationContext: operationContext, + } + + return transport.executeRequestAndRewriteResponse(request, operation, executor) +} + +func (transport *Transport) interceptAndRewriteRequest(request *http.Request, operation operationRequest) (*http.Response, error) { + err := operation(request) + if err != nil { + return nil, err + } + + return transport.executeDockerRequest(request) +} + +// decorateGenericResourceCreationResponse extracts the response as a JSON object, extracts the resource identifier from that object based +// on the resourceIdentifierAttribute parameter then generate a new resource control associated to that resource +// with a random token and rewrites the response by decorating the original response with a ResourceControl object. +// The generic Docker API response format is JSON object: +// https://docs.docker.com/engine/api/v1.40/#operation/ContainerCreate +// https://docs.docker.com/engine/api/v1.40/#operation/NetworkCreate +// https://docs.docker.com/engine/api/v1.40/#operation/VolumeCreate +// https://docs.docker.com/engine/api/v1.40/#operation/ServiceCreate +// https://docs.docker.com/engine/api/v1.40/#operation/SecretCreate +// https://docs.docker.com/engine/api/v1.40/#operation/ConfigCreate +func (transport *Transport) decorateGenericResourceCreationResponse(response *http.Response, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType, userID portainer.UserID) error { + responseObject, err := responseutils.GetResponseAsJSONOBject(response) + if err != nil { + return err + } + + if responseObject[resourceIdentifierAttribute] == nil { + log.Printf("[ERROR] [proxy,docker]") + return errors.New("missing identifier in Docker resource creation response") + } + + resourceID := responseObject[resourceIdentifierAttribute].(string) + + resourceControl, err := transport.createPrivateResourceControl(resourceID, resourceType, userID) + if err != nil { + return err + } + + responseObject = decorateObject(responseObject, resourceControl) + + return responseutils.RewriteResponse(response, responseObject, http.StatusOK) +} + +func (transport *Transport) decorateGenericResourceCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) { + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, err + } + + response, err := transport.executeDockerRequest(request) + if err != nil { + return response, err + } + + if response.StatusCode == http.StatusCreated { + err = transport.decorateGenericResourceCreationResponse(response, resourceIdentifierAttribute, resourceType, tokenData.ID) + } + + return response, err +} + +func (transport *Transport) executeGenericResourceDeletionOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) { + response, err := transport.restrictedResourceOperation(request, resourceIdentifierAttribute, resourceType, false) + if err != nil { + return response, err + } + + resourceControl, err := transport.resourceControlService.ResourceControlByResourceIDAndType(resourceIdentifierAttribute, resourceType) + if err != nil { + return response, err + } + + if resourceControl != nil { + err = transport.resourceControlService.DeleteResourceControl(resourceControl.ID) + if err != nil { + return response, err + } + } + + return response, err +} + +func (transport *Transport) executeRequestAndRewriteResponse(request *http.Request, operation restrictedOperationRequest, executor *operationExecutor) (*http.Response, error) { + response, err := transport.executeDockerRequest(request) + if err != nil { + return response, err + } + + err = operation(response, executor) + return response, err +} + +// administratorOperation ensures that the user has administrator privileges +// before executing the original request. +func (transport *Transport) administratorOperation(request *http.Request) (*http.Response, error) { + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, err + } + + if tokenData.Role != portainer.AdministratorRole { + return responseutils.WriteAccessDeniedResponse() + } + + return transport.executeDockerRequest(request) +} + +func (transport *Transport) createRegistryAccessContext(request *http.Request) (*registryAccessContext, error) { + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, err + } + + accessContext := ®istryAccessContext{ + isAdmin: true, + userID: tokenData.ID, + } + + hub, err := transport.dockerHubService.DockerHub() + if err != nil { + return nil, err + } + accessContext.dockerHub = hub + + registries, err := transport.registryService.Registries() + if err != nil { + return nil, err + } + accessContext.registries = registries + + if tokenData.Role != portainer.AdministratorRole { + accessContext.isAdmin = false + + teamMemberships, err := transport.teamMembershipService.TeamMembershipsByUserID(tokenData.ID) + if err != nil { + return nil, err + } + + accessContext.teamMemberships = teamMemberships + } + + return accessContext, nil +} + +func (transport *Transport) createOperationContext(request *http.Request) (*restrictedDockerOperationContext, error) { + var err error + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, err + } + + resourceControls, err := transport.resourceControlService.ResourceControls() + if err != nil { + return nil, err + } + + operationContext := &restrictedDockerOperationContext{ + isAdmin: true, + userID: tokenData.ID, + resourceControls: resourceControls, + endpointResourceAccess: false, + } + + if tokenData.Role != portainer.AdministratorRole { + operationContext.isAdmin = false + + user, err := transport.userService.User(operationContext.userID) + if err != nil { + return nil, err + } + + _, ok := user.EndpointAuthorizations[transport.endpoint.ID][portainer.EndpointResourcesAccess] + if ok { + operationContext.endpointResourceAccess = true + } + + teamMemberships, err := transport.teamMembershipService.TeamMembershipsByUserID(tokenData.ID) + if err != nil { + return nil, err + } + + userTeamIDs := make([]portainer.TeamID, 0) + for _, membership := range teamMemberships { + userTeamIDs = append(userTeamIDs, membership.TeamID) + } + operationContext.userTeamIDs = userTeamIDs + } + + return operationContext, nil +} diff --git a/api/http/proxy/factory/docker/volumes.go b/api/http/proxy/factory/docker/volumes.go new file mode 100644 index 000000000..49512852e --- /dev/null +++ b/api/http/proxy/factory/docker/volumes.go @@ -0,0 +1,89 @@ +package docker + +import ( + "context" + "net/http" + + "github.com/docker/docker/client" + + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy/factory/responseutils" +) + +const ( + volumeObjectIdentifier = "Name" +) + +func getInheritedResourceControlFromVolumeLabels(dockerClient *client.Client, volumeID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) { + volume, err := dockerClient.VolumeInspect(context.Background(), volumeID) + if err != nil { + return nil, err + } + + swarmStackName := volume.Labels[resourceLabelForDockerSwarmStackName] + if swarmStackName != "" { + return portainer.GetResourceControlByResourceIDAndType(swarmStackName, portainer.StackResourceControl, resourceControls), nil + } + + return nil, nil +} + +// volumeListOperation extracts the response as a JSON object, loop through the volume array +// decorate and/or filter the volumes based on resource controls before rewriting the response. +func (transport *Transport) volumeListOperation(response *http.Response, executor *operationExecutor) error { + // VolumeList response is a JSON object + // https://docs.docker.com/engine/api/v1.28/#operation/VolumeList + responseObject, err := responseutils.GetResponseAsJSONOBject(response) + if err != nil { + return err + } + + // The "Volumes" field contains the list of volumes as an array of JSON objects + if responseObject["Volumes"] != nil { + volumeData := responseObject["Volumes"].([]interface{}) + + resourceOperationParameters := &resourceOperationParameters{ + resourceIdentifierAttribute: volumeObjectIdentifier, + resourceType: portainer.VolumeResourceControl, + labelsObjectSelector: selectorVolumeLabels, + } + + volumeData, err = transport.applyAccessControlOnResourceList(resourceOperationParameters, volumeData, executor) + if err != nil { + return err + } + + // Overwrite the original volume list + responseObject["Volumes"] = volumeData + } + + return responseutils.RewriteResponse(response, responseObject, http.StatusOK) +} + +// volumeInspectOperation extracts the response as a JSON object, verify that the user +// has access to the volume based on any existing resource control and either rewrite an access denied response or a decorated volume. +func (transport *Transport) volumeInspectOperation(response *http.Response, executor *operationExecutor) error { + // VolumeInspect response is a JSON object + // https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect + responseObject, err := responseutils.GetResponseAsJSONOBject(response) + if err != nil { + return err + } + + resourceOperationParameters := &resourceOperationParameters{ + resourceIdentifierAttribute: volumeObjectIdentifier, + resourceType: portainer.VolumeResourceControl, + labelsObjectSelector: selectorVolumeLabels, + } + + return transport.applyAccessControlOnResource(resourceOperationParameters, responseObject, response, executor) +} + +// selectorVolumeLabels retrieve the labels object associated to the volume object. +// Labels are available under the "Labels" property. +// API schema references: +// https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect +// https://docs.docker.com/engine/api/v1.28/#operation/VolumeList +func selectorVolumeLabels(responseObject map[string]interface{}) map[string]interface{} { + return responseutils.GetJSONObject(responseObject, "Labels") +} diff --git a/api/http/proxy/factory/docker_unix.go b/api/http/proxy/factory/docker_unix.go new file mode 100644 index 000000000..214b50747 --- /dev/null +++ b/api/http/proxy/factory/docker_unix.go @@ -0,0 +1,46 @@ +// +build aix darwin dragonfly freebsd linux netbsd openbsd solaris + +package factory + +import ( + "net" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy/factory/docker" +) + +func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portainer.Endpoint) (http.Handler, error) { + transportParameters := &docker.TransportParameters{ + Endpoint: endpoint, + ResourceControlService: factory.resourceControlService, + UserService: factory.userService, + TeamService: factory.teamService, + TeamMembershipService: factory.teamMembershipService, + RegistryService: factory.registryService, + DockerHubService: factory.dockerHubService, + SettingsService: factory.settingsService, + ReverseTunnelService: factory.reverseTunnelService, + ExtensionService: factory.extensionService, + SignatureService: factory.signatureService, + DockerClientFactory: factory.dockerClientFactory, + } + + proxy := &dockerLocalProxy{} + + dockerTransport, err := docker.NewTransport(transportParameters, newSocketTransport(path)) + if err != nil { + return nil, err + } + + proxy.transport = dockerTransport + return proxy, nil +} + +func newSocketTransport(socketPath string) *http.Transport { + return &http.Transport{ + Dial: func(proto, addr string) (conn net.Conn, err error) { + return net.Dial("unix", socketPath) + }, + } +} diff --git a/api/http/proxy/factory/docker_windows.go b/api/http/proxy/factory/docker_windows.go new file mode 100644 index 000000000..50ba768f7 --- /dev/null +++ b/api/http/proxy/factory/docker_windows.go @@ -0,0 +1,47 @@ +// +build windows + +package factory + +import ( + "net" + "net/http" + + "github.com/Microsoft/go-winio" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy/factory/docker" +) + +func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portainer.Endpoint) (http.Handler, error) { + transportParameters := &docker.TransportParameters{ + Endpoint: endpoint, + ResourceControlService: factory.resourceControlService, + UserService: factory.userService, + TeamService: factory.teamService, + TeamMembershipService: factory.teamMembershipService, + RegistryService: factory.registryService, + DockerHubService: factory.dockerHubService, + SettingsService: factory.settingsService, + ReverseTunnelService: factory.reverseTunnelService, + ExtensionService: factory.extensionService, + SignatureService: factory.signatureService, + DockerClientFactory: factory.dockerClientFactory, + } + + proxy := &dockerLocalProxy{} + + dockerTransport, err := docker.NewTransport(transportParameters, newNamedPipeTransport(path)) + if err != nil { + return nil, err + } + + proxy.transport = dockerTransport + return proxy, nil +} + +func newNamedPipeTransport(namedPipePath string) *http.Transport { + return &http.Transport{ + Dial: func(proto, addr string) (conn net.Conn, err error) { + return winio.DialPipe(namedPipePath, nil) + }, + } +} diff --git a/api/http/proxy/factory/factory.go b/api/http/proxy/factory/factory.go new file mode 100644 index 000000000..207977f21 --- /dev/null +++ b/api/http/proxy/factory/factory.go @@ -0,0 +1,114 @@ +package factory + +import ( + "fmt" + "net/http" + "net/http/httputil" + "net/url" + + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/docker" +) + +const azureAPIBaseURL = "https://management.azure.com" + +var extensionPorts = map[portainer.ExtensionID]string{ + portainer.RegistryManagementExtension: "7001", + portainer.OAuthAuthenticationExtension: "7002", + portainer.RBACExtension: "7003", +} + +type ( + // ProxyFactory is a factory to create reverse proxies to Docker endpoints and extensions + ProxyFactory struct { + resourceControlService portainer.ResourceControlService + userService portainer.UserService + teamService portainer.TeamService + teamMembershipService portainer.TeamMembershipService + settingsService portainer.SettingsService + registryService portainer.RegistryService + dockerHubService portainer.DockerHubService + signatureService portainer.DigitalSignatureService + reverseTunnelService portainer.ReverseTunnelService + extensionService portainer.ExtensionService + dockerClientFactory *docker.ClientFactory + } + + // ProxyFactoryParameters is used to create a new ProxyFactory + ProxyFactoryParameters struct { + ResourceControlService portainer.ResourceControlService + UserService portainer.UserService + TeamService portainer.TeamService + TeamMembershipService portainer.TeamMembershipService + SettingsService portainer.SettingsService + RegistryService portainer.RegistryService + DockerHubService portainer.DockerHubService + SignatureService portainer.DigitalSignatureService + ReverseTunnelService portainer.ReverseTunnelService + ExtensionService portainer.ExtensionService + DockerClientFactory *docker.ClientFactory + } +) + +// NewProxyFactory returns a pointer to a new instance of a ProxyFactory +func NewProxyFactory(parameters *ProxyFactoryParameters) *ProxyFactory { + return &ProxyFactory{ + resourceControlService: parameters.ResourceControlService, + userService: parameters.UserService, + teamService: parameters.TeamService, + teamMembershipService: parameters.TeamMembershipService, + settingsService: parameters.SettingsService, + registryService: parameters.RegistryService, + dockerHubService: parameters.DockerHubService, + signatureService: parameters.SignatureService, + reverseTunnelService: parameters.ReverseTunnelService, + extensionService: parameters.ExtensionService, + dockerClientFactory: parameters.DockerClientFactory, + } +} + +// BuildExtensionURL returns the URL to an extension server +func BuildExtensionURL(extensionID portainer.ExtensionID) string { + return fmt.Sprintf("http://%s:%s", portainer.ExtensionServer, extensionPorts[extensionID]) +} + +// NewExtensionProxy returns a new HTTP proxy to an extension server +func (factory *ProxyFactory) NewExtensionProxy(extensionID portainer.ExtensionID) (http.Handler, error) { + address := "http://" + portainer.ExtensionServer + ":" + extensionPorts[extensionID] + + extensionURL, err := url.Parse(address) + if err != nil { + return nil, err + } + + extensionURL.Scheme = "http" + proxy := httputil.NewSingleHostReverseProxy(extensionURL) + return proxy, nil +} + +// NewLegacyExtensionProxy returns a new HTTP proxy to a legacy extension server (Storidge) +func (factory *ProxyFactory) NewLegacyExtensionProxy(extensionAPIURL string) (http.Handler, error) { + extensionURL, err := url.Parse(extensionAPIURL) + if err != nil { + return nil, err + } + + extensionURL.Scheme = "http" + proxy := httputil.NewSingleHostReverseProxy(extensionURL) + return proxy, nil +} + +// NewEndpointProxy returns a new reverse proxy (filesystem based or HTTP) to an endpoint API server +func (factory *ProxyFactory) NewEndpointProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + switch endpoint.Type { + case portainer.AzureEnvironment: + return newAzureProxy(endpoint) + } + + return factory.newDockerProxy(endpoint) +} + +// NewGitlabProxy returns a new HTTP proxy to a Gitlab API server +func (factory *ProxyFactory) NewGitlabProxy(gitlabAPIUri string) (http.Handler, error) { + return newGitlabProxy(gitlabAPIUri) +} diff --git a/api/http/proxy/factory/gitlab.go b/api/http/proxy/factory/gitlab.go new file mode 100644 index 000000000..cbe28411c --- /dev/null +++ b/api/http/proxy/factory/gitlab.go @@ -0,0 +1,19 @@ +package factory + +import ( + "net/http" + "net/url" + + "github.com/portainer/portainer/api/http/proxy/factory/gitlab" +) + +func newGitlabProxy(uri string) (http.Handler, error) { + url, err := url.Parse(uri) + if err != nil { + return nil, err + } + + proxy := newSingleHostReverseProxyWithHostHeader(url) + proxy.Transport = gitlab.NewTransport() + return proxy, nil +} diff --git a/api/http/proxy/factory/gitlab/transport.go b/api/http/proxy/factory/gitlab/transport.go new file mode 100644 index 000000000..4857ed710 --- /dev/null +++ b/api/http/proxy/factory/gitlab/transport.go @@ -0,0 +1,34 @@ +package gitlab + +import ( + "errors" + "net/http" +) + +type Transport struct { + httpTransport *http.Transport +} + +// NewTransport returns a pointer to a new instance of Transport that implements the HTTP Transport +// interface for proxying requests to the Gitlab API. +func NewTransport() *Transport { + return &Transport{ + httpTransport: &http.Transport{}, + } +} + +// RoundTrip is the implementation of the the http.RoundTripper interface +func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) { + token := request.Header.Get("Private-Token") + if token == "" { + return nil, errors.New("no gitlab token provided") + } + + r, err := http.NewRequest(request.Method, request.URL.String(), nil) + if err != nil { + return nil, err + } + + r.Header.Set("Private-Token", token) + return transport.httpTransport.RoundTrip(r) +} diff --git a/api/http/proxy/factory/responseutils/json.go b/api/http/proxy/factory/responseutils/json.go new file mode 100644 index 000000000..15af94c60 --- /dev/null +++ b/api/http/proxy/factory/responseutils/json.go @@ -0,0 +1,11 @@ +package responseutils + +// GetJSONObject will extract an object from a specific property of another JSON object. +// Returns nil if nothing is associated to the specified key. +func GetJSONObject(jsonObject map[string]interface{}, property string) map[string]interface{} { + object := jsonObject[property] + if object != nil { + return object.(map[string]interface{}) + } + return nil +} diff --git a/api/http/proxy/factory/responseutils/response.go b/api/http/proxy/factory/responseutils/response.go new file mode 100644 index 000000000..1ce1d39e9 --- /dev/null +++ b/api/http/proxy/factory/responseutils/response.go @@ -0,0 +1,106 @@ +package responseutils + +import ( + "bytes" + "encoding/json" + "errors" + "io/ioutil" + "log" + "net/http" + "strconv" +) + +// GetResponseAsJSONOBject returns the response content as a generic JSON object +func GetResponseAsJSONOBject(response *http.Response) (map[string]interface{}, error) { + responseData, err := getResponseBodyAsGenericJSON(response) + if err != nil { + return nil, err + } + + responseObject := responseData.(map[string]interface{}) + return responseObject, nil +} + +// GetResponseAsJSONArray returns the response content as an array of generic JSON object +func GetResponseAsJSONArray(response *http.Response) ([]interface{}, error) { + responseData, err := getResponseBodyAsGenericJSON(response) + if err != nil { + return nil, err + } + + switch responseObject := responseData.(type) { + case []interface{}: + return responseObject, nil + case map[string]interface{}: + if responseObject["message"] != nil { + return nil, errors.New(responseObject["message"].(string)) + } + log.Printf("[ERROR] [http,proxy,response] [message: invalid response format, expecting JSON array] [response: %+v]", responseObject) + return nil, errors.New("unable to parse response: expected JSON array, got JSON object") + default: + log.Printf("[ERROR] [http,proxy,response] [message: invalid response format, expecting JSON array] [response: %+v]", responseObject) + return nil, errors.New("unable to parse response: expected JSON array") + } +} + +func getResponseBodyAsGenericJSON(response *http.Response) (interface{}, error) { + if response.Body == nil { + return nil, errors.New("unable to parse response: empty response body") + } + + var data interface{} + body, err := ioutil.ReadAll(response.Body) + if err != nil { + return nil, err + } + + err = response.Body.Close() + if err != nil { + return nil, err + } + + err = json.Unmarshal(body, &data) + if err != nil { + return nil, err + } + + return data, nil +} + +type dockerErrorResponse struct { + Message string `json:"message,omitempty"` +} + +// WriteAccessDeniedResponse will create a new access denied response +func WriteAccessDeniedResponse() (*http.Response, error) { + response := &http.Response{} + err := RewriteResponse(response, dockerErrorResponse{Message: "access denied to resource"}, http.StatusForbidden) + return response, err +} + +// RewriteAccessDeniedResponse will overwrite the existing response with an access denied response +func RewriteAccessDeniedResponse(response *http.Response) error { + return RewriteResponse(response, dockerErrorResponse{Message: "access denied to resource"}, http.StatusForbidden) +} + +// RewriteResponse will replace the existing response body and status code with the one specified +// in parameters +func RewriteResponse(response *http.Response, newResponseData interface{}, statusCode int) error { + jsonData, err := json.Marshal(newResponseData) + if err != nil { + return err + } + + body := ioutil.NopCloser(bytes.NewReader(jsonData)) + + response.StatusCode = statusCode + response.Body = body + response.ContentLength = int64(len(jsonData)) + + if response.Header == nil { + response.Header = make(http.Header) + } + response.Header.Set("Content-Length", strconv.Itoa(len(jsonData))) + + return nil +} diff --git a/api/http/proxy/reverse_proxy.go b/api/http/proxy/factory/reverse_proxy.go similarity index 98% rename from api/http/proxy/reverse_proxy.go rename to api/http/proxy/factory/reverse_proxy.go index 47e71b63e..93d22f94d 100644 --- a/api/http/proxy/reverse_proxy.go +++ b/api/http/proxy/factory/reverse_proxy.go @@ -1,4 +1,4 @@ -package proxy +package factory import ( "net/http" diff --git a/api/http/proxy/factory_local.go b/api/http/proxy/factory_local.go deleted file mode 100644 index c9ab44b81..000000000 --- a/api/http/proxy/factory_local.go +++ /dev/null @@ -1,29 +0,0 @@ -// +build !windows - -package proxy - -import ( - "net/http" - - portainer "github.com/portainer/portainer/api" -) - -func (factory *proxyFactory) newLocalProxy(path string, endpoint *portainer.Endpoint) http.Handler { - proxy := &localProxy{} - 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, - endpointType: endpoint.Type, - } - proxy.Transport = transport - return proxy -} diff --git a/api/http/proxy/factory_local_windows.go b/api/http/proxy/factory_local_windows.go deleted file mode 100644 index 3f1d860d7..000000000 --- a/api/http/proxy/factory_local_windows.go +++ /dev/null @@ -1,40 +0,0 @@ -// +build windows - -package proxy - -import ( - "net" - "net/http" - - "github.com/Microsoft/go-winio" - - portainer "github.com/portainer/portainer/api" -) - -func (factory *proxyFactory) newLocalProxy(path string, endpoint *portainer.Endpoint) http.Handler { - proxy := &localProxy{} - 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, - } - proxy.Transport = transport - return proxy -} - -func newNamedPipeTransport(namedPipePath string) *http.Transport { - return &http.Transport{ - Dial: func(proto, addr string) (conn net.Conn, err error) { - return winio.DialPipe(namedPipePath, nil) - }, - } -} diff --git a/api/http/proxy/local.go b/api/http/proxy/local.go deleted file mode 100644 index 7686768ad..000000000 --- a/api/http/proxy/local.go +++ /dev/null @@ -1,43 +0,0 @@ -package proxy - -import ( - "io" - "log" - "net/http" - - httperror "github.com/portainer/libhttp/error" -) - -type localProxy struct { - Transport *proxyTransport -} - -func (proxy *localProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // Force URL/domain to http/unixsocket to be able to - // use http.Transport RoundTrip to do the requests via the socket - r.URL.Scheme = "http" - r.URL.Host = "unixsocket" - - res, err := proxy.Transport.proxyDockerRequest(r) - if err != nil { - code := http.StatusInternalServerError - if res != nil && res.StatusCode != 0 { - code = res.StatusCode - } - httperror.WriteError(w, code, "Unable to proxy the request via the Docker socket", err) - return - } - defer res.Body.Close() - - for k, vv := range res.Header { - for _, v := range vv { - w.Header().Add(k, v) - } - } - - w.WriteHeader(res.StatusCode) - - if _, err := io.Copy(w, res.Body); err != nil { - log.Printf("proxy error: %s\n", err) - } -} diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go index 7a1f38580..fa27f6399 100644 --- a/api/http/proxy/manager.go +++ b/api/http/proxy/manager.go @@ -1,29 +1,22 @@ package proxy import ( - "fmt" "net/http" - "net/url" "strconv" "github.com/orcaman/concurrent-map" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/docker" + "github.com/portainer/portainer/api/http/proxy/factory" ) // TODO: contain code related to legacy extension management -var extensionPorts = map[portainer.ExtensionID]string{ - portainer.RegistryManagementExtension: "7001", - portainer.OAuthAuthenticationExtension: "7002", - portainer.RBACExtension: "7003", -} - type ( - // Manager represents a service used to manage Docker proxies. + // Manager represents a service used to manage proxies to endpoints and extensions. Manager struct { - proxyFactory *proxyFactory - reverseTunnelService portainer.ReverseTunnelService - proxies cmap.ConcurrentMap + proxyFactory *factory.ProxyFactory + endpointProxies cmap.ConcurrentMap extensionProxies cmap.ConcurrentMap legacyExtensionProxies cmap.ConcurrentMap } @@ -32,6 +25,7 @@ type ( ManagerParams struct { ResourceControlService portainer.ResourceControlService UserService portainer.UserService + TeamService portainer.TeamService TeamMembershipService portainer.TeamMembershipService SettingsService portainer.SettingsService RegistryService portainer.RegistryService @@ -39,54 +33,71 @@ type ( SignatureService portainer.DigitalSignatureService ReverseTunnelService portainer.ReverseTunnelService ExtensionService portainer.ExtensionService + DockerClientFactory *docker.ClientFactory } ) // NewManager initializes a new proxy Service func NewManager(parameters *ManagerParams) *Manager { + proxyFactoryParameters := &factory.ProxyFactoryParameters{ + ResourceControlService: parameters.ResourceControlService, + UserService: parameters.UserService, + TeamService: parameters.TeamService, + TeamMembershipService: parameters.TeamMembershipService, + SettingsService: parameters.SettingsService, + RegistryService: parameters.RegistryService, + DockerHubService: parameters.DockerHubService, + SignatureService: parameters.SignatureService, + ReverseTunnelService: parameters.ReverseTunnelService, + ExtensionService: parameters.ExtensionService, + DockerClientFactory: parameters.DockerClientFactory, + } + return &Manager{ - proxies: cmap.New(), + endpointProxies: cmap.New(), extensionProxies: cmap.New(), 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, + proxyFactory: factory.NewProxyFactory(proxyFactoryParameters), } } -// GetProxy returns the proxy associated to a key -func (manager *Manager) GetProxy(endpoint *portainer.Endpoint) http.Handler { - proxy, ok := manager.proxies.Get(string(endpoint.ID)) - if !ok { - return nil - } - return proxy.(http.Handler) -} - -// CreateAndRegisterProxy creates a new HTTP reverse proxy based on endpoint properties and and adds it to the registered proxies. +// CreateAndRegisterEndpointProxy creates a new HTTP reverse proxy based on endpoint properties and and adds it to the registered proxies. // It can also be used to create a new HTTP reverse proxy and replace an already registered proxy. -func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (http.Handler, error) { - proxy, err := manager.createProxy(endpoint) +func (manager *Manager) CreateAndRegisterEndpointProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + proxy, err := manager.proxyFactory.NewEndpointProxy(endpoint) if err != nil { return nil, err } - manager.proxies.Set(string(endpoint.ID), proxy) + manager.endpointProxies.Set(string(endpoint.ID), proxy) return proxy, nil } -// DeleteProxy deletes the proxy associated to a key -func (manager *Manager) DeleteProxy(endpoint *portainer.Endpoint) { - manager.proxies.Remove(string(endpoint.ID)) +// GetEndpointProxy returns the proxy associated to a key +func (manager *Manager) GetEndpointProxy(endpoint *portainer.Endpoint) http.Handler { + proxy, ok := manager.endpointProxies.Get(string(endpoint.ID)) + if !ok { + return nil + } + + return proxy.(http.Handler) +} + +// DeleteEndpointProxy deletes the proxy associated to a key +func (manager *Manager) DeleteEndpointProxy(endpoint *portainer.Endpoint) { + manager.endpointProxies.Remove(string(endpoint.ID)) +} + +// CreateExtensionProxy creates a new HTTP reverse proxy for an extension and +// registers it in the extension map associated to the specified extension identifier +func (manager *Manager) CreateExtensionProxy(extensionID portainer.ExtensionID) (http.Handler, error) { + proxy, err := manager.proxyFactory.NewExtensionProxy(extensionID) + if err != nil { + return nil, err + } + + manager.extensionProxies.Set(strconv.Itoa(int(extensionID)), proxy) + return proxy, nil } // GetExtensionProxy returns an extension proxy associated to an extension identifier @@ -95,28 +106,13 @@ func (manager *Manager) GetExtensionProxy(extensionID portainer.ExtensionID) htt if !ok { return nil } + return proxy.(http.Handler) } -// CreateExtensionProxy creates a new HTTP reverse proxy for an extension and -// registers it in the extension map associated to the specified extension identifier -func (manager *Manager) CreateExtensionProxy(extensionID portainer.ExtensionID) (http.Handler, error) { - address := "http://" + portainer.ExtensionServer + ":" + extensionPorts[extensionID] - - extensionURL, err := url.Parse(address) - if err != nil { - return nil, err - } - - proxy := manager.proxyFactory.newHTTPProxy(extensionURL) - manager.extensionProxies.Set(strconv.Itoa(int(extensionID)), proxy) - - return proxy, nil -} - // GetExtensionURL retrieves the URL of an extension running locally based on the extension port table func (manager *Manager) GetExtensionURL(extensionID portainer.ExtensionID) string { - return "http://localhost:" + extensionPorts[extensionID] + return factory.BuildExtensionURL(extensionID) } // DeleteExtensionProxy deletes the extension proxy associated to an extension identifier @@ -124,6 +120,17 @@ func (manager *Manager) DeleteExtensionProxy(extensionID portainer.ExtensionID) manager.extensionProxies.Remove(strconv.Itoa(int(extensionID))) } +// CreateLegacyExtensionProxy creates a new HTTP reverse proxy for a legacy extension and adds it to the registered proxies +func (manager *Manager) CreateLegacyExtensionProxy(key, extensionAPIURL string) (http.Handler, error) { + proxy, err := manager.proxyFactory.NewLegacyExtensionProxy(extensionAPIURL) + if err != nil { + return nil, err + } + + manager.legacyExtensionProxies.Set(key, proxy) + return proxy, nil +} + // GetLegacyExtensionProxy returns a legacy extension proxy associated to a key func (manager *Manager) GetLegacyExtensionProxy(key string) http.Handler { proxy, ok := manager.legacyExtensionProxies.Get(key) @@ -133,52 +140,7 @@ func (manager *Manager) GetLegacyExtensionProxy(key string) http.Handler { return proxy.(http.Handler) } -// CreateLegacyExtensionProxy creates a new HTTP reverse proxy for a legacy extension and adds it to the registered proxies. -func (manager *Manager) CreateLegacyExtensionProxy(key, extensionAPIURL string) (http.Handler, error) { - extensionURL, err := url.Parse(extensionAPIURL) - if err != nil { - return nil, err - } - - proxy := manager.proxyFactory.newHTTPProxy(extensionURL) - manager.legacyExtensionProxies.Set(key, proxy) - return proxy, nil -} - -func (manager *Manager) createDockerProxy(endpoint *portainer.Endpoint) (http.Handler, error) { - baseURL := endpoint.URL - if endpoint.Type == portainer.EdgeAgentEnvironment { - tunnel := manager.reverseTunnelService.GetTunnelDetails(endpoint.ID) - baseURL = fmt.Sprintf("http://localhost:%d", tunnel.Port) - } - - endpointURL, err := url.Parse(baseURL) - if err != nil { - return nil, err - } - - switch endpoint.Type { - case portainer.AgentOnDockerEnvironment: - return manager.proxyFactory.newDockerHTTPSProxy(endpointURL, &endpoint.TLSConfig, endpoint) - case portainer.EdgeAgentEnvironment: - return manager.proxyFactory.newDockerHTTPProxy(endpointURL, endpoint), nil - } - - if endpointURL.Scheme == "tcp" { - if endpoint.TLSConfig.TLS || endpoint.TLSConfig.TLSSkipVerify { - return manager.proxyFactory.newDockerHTTPSProxy(endpointURL, &endpoint.TLSConfig, endpoint) - } - - return manager.proxyFactory.newDockerHTTPProxy(endpointURL, endpoint), nil - } - - return manager.proxyFactory.newLocalProxy(endpointURL.Path, endpoint), nil -} - -func (manager *Manager) createProxy(endpoint *portainer.Endpoint) (http.Handler, error) { - if endpoint.Type == portainer.AzureEnvironment { - return newAzureProxy(&endpoint.AzureCredentials) - } - - return manager.createDockerProxy(endpoint) +// CreateGitlabProxy creates a new HTTP reverse proxy that can be used to send requests to the Gitlab API +func (manager *Manager) CreateGitlabProxy(url string) (http.Handler, error) { + return manager.proxyFactory.NewGitlabProxy(url) } diff --git a/api/http/proxy/networks.go b/api/http/proxy/networks.go deleted file mode 100644 index 084f8604a..000000000 --- a/api/http/proxy/networks.go +++ /dev/null @@ -1,135 +0,0 @@ -package proxy - -import ( - "net/http" - - "github.com/portainer/portainer/api" -) - -const ( - // ErrDockerNetworkIdentifierNotFound defines an error raised when Portainer is unable to find a network identifier - ErrDockerNetworkIdentifierNotFound = portainer.Error("Docker network identifier not found") - networkIdentifier = "Id" - networkLabelForStackIdentifier = "com.docker.stack.namespace" -) - -// networkListOperation extracts the response as a JSON object, loop through the networks array -// decorate and/or filter the networks based on resource controls before rewriting the response -func networkListOperation(response *http.Response, executor *operationExecutor) error { - var err error - // NetworkList response is a JSON array - // https://docs.docker.com/engine/api/v1.28/#operation/NetworkList - responseArray, err := getResponseAsJSONArray(response) - if err != nil { - return err - } - - if executor.operationContext.isAdmin || executor.operationContext.endpointResourceAccess { - responseArray, err = decorateNetworkList(responseArray, executor.operationContext.resourceControls) - } else { - responseArray, err = filterNetworkList(responseArray, executor.operationContext) - } - if err != nil { - return err - } - - return rewriteResponse(response, responseArray, http.StatusOK) -} - -// networkInspectOperation extracts the response as a JSON object, verify that the user -// has access to the network based on resource control and either rewrite an access denied response -// or a decorated network. -func networkInspectOperation(response *http.Response, executor *operationExecutor) error { - // NetworkInspect response is a JSON object - // https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect - responseObject, err := getResponseAsJSONOBject(response) - if err != nil { - return err - } - - if responseObject[networkIdentifier] == nil { - return ErrDockerNetworkIdentifierNotFound - } - - networkID := responseObject[networkIdentifier].(string) - responseObject, access := applyResourceAccessControl(responseObject, networkID, executor.operationContext) - if access { - return rewriteResponse(response, responseObject, http.StatusOK) - } - - networkLabels := extractNetworkLabelsFromNetworkInspectObject(responseObject) - responseObject, access = applyResourceAccessControlFromLabel(networkLabels, responseObject, networkLabelForStackIdentifier, executor.operationContext) - if access { - return rewriteResponse(response, responseObject, http.StatusOK) - } - - return rewriteAccessDeniedResponse(response) -} - -// extractNetworkLabelsFromNetworkInspectObject retrieve the Labels of the network if present. -// Container schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect -func extractNetworkLabelsFromNetworkInspectObject(responseObject map[string]interface{}) map[string]interface{} { - // Labels are stored under Labels - return extractJSONField(responseObject, "Labels") -} - -// extractNetworkLabelsFromNetworkListObject retrieve the Labels of the network if present. -// Network schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkList -func extractNetworkLabelsFromNetworkListObject(responseObject map[string]interface{}) map[string]interface{} { - // Labels are stored under Labels - return extractJSONField(responseObject, "Labels") -} - -// decorateNetworkList loops through all networks and decorates any network with an existing resource control. -// Resource controls checks are based on: resource identifier, stack identifier (from label). -// Network object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkList -func decorateNetworkList(networkData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { - decoratedNetworkData := make([]interface{}, 0) - - for _, network := range networkData { - - networkObject := network.(map[string]interface{}) - if networkObject[networkIdentifier] == nil { - return nil, ErrDockerNetworkIdentifierNotFound - } - - networkID := networkObject[networkIdentifier].(string) - networkObject = decorateResourceWithAccessControl(networkObject, networkID, resourceControls) - - networkLabels := extractNetworkLabelsFromNetworkListObject(networkObject) - networkObject = decorateResourceWithAccessControlFromLabel(networkLabels, networkObject, networkLabelForStackIdentifier, resourceControls) - - decoratedNetworkData = append(decoratedNetworkData, networkObject) - } - - return decoratedNetworkData, nil -} - -// filterNetworkList loops through all networks and filters public networks (no associated resource control) -// as well as authorized networks (access granted to the user based on existing resource control). -// Authorized networks are decorated during the process. -// Resource controls checks are based on: resource identifier, stack identifier (from label). -// Network object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkList -func filterNetworkList(networkData []interface{}, context *restrictedDockerOperationContext) ([]interface{}, error) { - filteredNetworkData := make([]interface{}, 0) - - for _, network := range networkData { - networkObject := network.(map[string]interface{}) - if networkObject[networkIdentifier] == nil { - return nil, ErrDockerNetworkIdentifierNotFound - } - - networkID := networkObject[networkIdentifier].(string) - networkObject, access := applyResourceAccessControl(networkObject, networkID, context) - if !access { - networkLabels := extractNetworkLabelsFromNetworkListObject(networkObject) - networkObject, access = applyResourceAccessControlFromLabel(networkLabels, networkObject, networkLabelForStackIdentifier, context) - } - - if access { - filteredNetworkData = append(filteredNetworkData, networkObject) - } - } - - return filteredNetworkData, nil -} diff --git a/api/http/proxy/response.go b/api/http/proxy/response.go deleted file mode 100644 index a974d8f25..000000000 --- a/api/http/proxy/response.go +++ /dev/null @@ -1,109 +0,0 @@ -package proxy - -import ( - "bytes" - "encoding/json" - "io/ioutil" - "log" - "net/http" - "strconv" - - "github.com/portainer/portainer/api" -) - -const ( - // ErrEmptyResponseBody defines an error raised when portainer excepts to parse the body of a HTTP response and there is nothing to parse - ErrEmptyResponseBody = portainer.Error("Empty response body") - // ErrInvalidResponseContent defines an error raised when Portainer excepts a JSON array and get something else. - ErrInvalidResponseContent = portainer.Error("Invalid Docker response") -) - -func extractJSONField(jsonObject map[string]interface{}, key string) map[string]interface{} { - object := jsonObject[key] - if object != nil { - return object.(map[string]interface{}) - } - return nil -} - -func getResponseAsJSONOBject(response *http.Response) (map[string]interface{}, error) { - responseData, err := getResponseBodyAsGenericJSON(response) - if err != nil { - return nil, err - } - - responseObject := responseData.(map[string]interface{}) - return responseObject, nil -} - -func getResponseAsJSONArray(response *http.Response) ([]interface{}, error) { - responseData, err := getResponseBodyAsGenericJSON(response) - if err != nil { - return nil, err - } - - switch responseObject := responseData.(type) { - case []interface{}: - return responseObject, nil - case map[string]interface{}: - if responseObject["message"] != nil { - return nil, portainer.Error(responseObject["message"].(string)) - } - log.Printf("Response: %+v\n", responseObject) - return nil, ErrInvalidResponseContent - default: - log.Printf("Response: %+v\n", responseObject) - return nil, ErrInvalidResponseContent - } -} - -func getResponseBodyAsGenericJSON(response *http.Response) (interface{}, error) { - var data interface{} - if response.Body != nil { - body, err := ioutil.ReadAll(response.Body) - if err != nil { - return nil, err - } - - err = response.Body.Close() - if err != nil { - return nil, err - } - - err = json.Unmarshal(body, &data) - if err != nil { - return nil, err - } - - return data, nil - } - return nil, ErrEmptyResponseBody -} - -func writeAccessDeniedResponse() (*http.Response, error) { - response := &http.Response{} - err := rewriteResponse(response, portainer.ErrResourceAccessDenied, http.StatusForbidden) - return response, err -} - -func rewriteAccessDeniedResponse(response *http.Response) error { - return rewriteResponse(response, portainer.ErrResourceAccessDenied, http.StatusForbidden) -} - -func rewriteResponse(response *http.Response, newResponseData interface{}, statusCode int) error { - jsonData, err := json.Marshal(newResponseData) - if err != nil { - return err - } - body := ioutil.NopCloser(bytes.NewReader(jsonData)) - response.StatusCode = statusCode - response.Body = body - response.ContentLength = int64(len(jsonData)) - - if response.Header == nil { - response.Header = make(http.Header) - } - response.Header.Set("Content-Length", strconv.Itoa(len(jsonData))) - - return nil -} diff --git a/api/http/proxy/secrets.go b/api/http/proxy/secrets.go deleted file mode 100644 index 67af61936..000000000 --- a/api/http/proxy/secrets.go +++ /dev/null @@ -1,107 +0,0 @@ -package proxy - -import ( - "net/http" - - "github.com/portainer/portainer/api" -) - -const ( - // ErrDockerSecretIdentifierNotFound defines an error raised when Portainer is unable to find a secret identifier - ErrDockerSecretIdentifierNotFound = portainer.Error("Docker secret identifier not found") - secretIdentifier = "ID" -) - -// secretListOperation extracts the response as a JSON object, loop through the secrets array -// decorate and/or filter the secrets based on resource controls before rewriting the response -func secretListOperation(response *http.Response, executor *operationExecutor) error { - var err error - - // SecretList response is a JSON array - // https://docs.docker.com/engine/api/v1.28/#operation/SecretList - responseArray, err := getResponseAsJSONArray(response) - if err != nil { - return err - } - - if executor.operationContext.isAdmin || executor.operationContext.endpointResourceAccess { - responseArray, err = decorateSecretList(responseArray, executor.operationContext.resourceControls) - } else { - responseArray, err = filterSecretList(responseArray, executor.operationContext) - } - if err != nil { - return err - } - - return rewriteResponse(response, responseArray, http.StatusOK) -} - -// secretInspectOperation extracts the response as a JSON object, verify that the user -// has access to the secret based on resource control (check are done based on the secretID and optional Swarm service ID) -// and either rewrite an access denied response or a decorated secret. -func secretInspectOperation(response *http.Response, executor *operationExecutor) error { - // SecretInspect response is a JSON object - // https://docs.docker.com/engine/api/v1.28/#operation/SecretInspect - responseObject, err := getResponseAsJSONOBject(response) - if err != nil { - return err - } - - if responseObject[secretIdentifier] == nil { - return ErrDockerSecretIdentifierNotFound - } - - secretID := responseObject[secretIdentifier].(string) - responseObject, access := applyResourceAccessControl(responseObject, secretID, executor.operationContext) - if !access { - return rewriteAccessDeniedResponse(response) - } - - return rewriteResponse(response, responseObject, http.StatusOK) -} - -// decorateSecretList loops through all secrets and decorates any secret with an existing resource control. -// Resource controls checks are based on: resource identifier. -// Secret object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/SecretList -func decorateSecretList(secretData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { - decoratedSecretData := make([]interface{}, 0) - - for _, secret := range secretData { - - secretObject := secret.(map[string]interface{}) - if secretObject[secretIdentifier] == nil { - return nil, ErrDockerSecretIdentifierNotFound - } - - secretID := secretObject[secretIdentifier].(string) - secretObject = decorateResourceWithAccessControl(secretObject, secretID, resourceControls) - - decoratedSecretData = append(decoratedSecretData, secretObject) - } - - return decoratedSecretData, nil -} - -// filterSecretList loops through all secrets and filters public secrets (no associated resource control) -// as well as authorized secrets (access granted to the user based on existing resource control). -// Authorized secrets are decorated during the process. -// Resource controls checks are based on: resource identifier. -// Secret object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/SecretList -func filterSecretList(secretData []interface{}, context *restrictedDockerOperationContext) ([]interface{}, error) { - filteredSecretData := make([]interface{}, 0) - - for _, secret := range secretData { - secretObject := secret.(map[string]interface{}) - if secretObject[secretIdentifier] == nil { - return nil, ErrDockerSecretIdentifierNotFound - } - - secretID := secretObject[secretIdentifier].(string) - secretObject, access := applyResourceAccessControl(secretObject, secretID, context) - if access { - filteredSecretData = append(filteredSecretData, secretObject) - } - } - - return filteredSecretData, nil -} diff --git a/api/http/proxy/services.go b/api/http/proxy/services.go deleted file mode 100644 index b14b50a47..000000000 --- a/api/http/proxy/services.go +++ /dev/null @@ -1,143 +0,0 @@ -package proxy - -import ( - "net/http" - - "github.com/portainer/portainer/api" -) - -const ( - // ErrDockerServiceIdentifierNotFound defines an error raised when Portainer is unable to find a service identifier - ErrDockerServiceIdentifierNotFound = portainer.Error("Docker service identifier not found") - serviceIdentifier = "ID" - serviceLabelForStackIdentifier = "com.docker.stack.namespace" -) - -// serviceListOperation extracts the response as a JSON array, loop through the service array -// decorate and/or filter the services based on resource controls before rewriting the response -func serviceListOperation(response *http.Response, executor *operationExecutor) error { - var err error - // ServiceList response is a JSON array - // https://docs.docker.com/engine/api/v1.28/#operation/ServiceList - responseArray, err := getResponseAsJSONArray(response) - if err != nil { - return err - } - - if executor.operationContext.isAdmin || executor.operationContext.endpointResourceAccess { - responseArray, err = decorateServiceList(responseArray, executor.operationContext.resourceControls) - } else { - responseArray, err = filterServiceList(responseArray, executor.operationContext) - } - if err != nil { - return err - } - - return rewriteResponse(response, responseArray, http.StatusOK) -} - -// serviceInspectOperation extracts the response as a JSON object, verify that the user -// has access to the service based on resource control and either rewrite an access denied response -// or a decorated service. -func serviceInspectOperation(response *http.Response, executor *operationExecutor) error { - // ServiceInspect response is a JSON object - // https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect - responseObject, err := getResponseAsJSONOBject(response) - if err != nil { - return err - } - - if responseObject[serviceIdentifier] == nil { - return ErrDockerServiceIdentifierNotFound - } - - serviceID := responseObject[serviceIdentifier].(string) - responseObject, access := applyResourceAccessControl(responseObject, serviceID, executor.operationContext) - if access { - return rewriteResponse(response, responseObject, http.StatusOK) - } - - serviceLabels := extractServiceLabelsFromServiceInspectObject(responseObject) - responseObject, access = applyResourceAccessControlFromLabel(serviceLabels, responseObject, serviceLabelForStackIdentifier, executor.operationContext) - if access { - return rewriteResponse(response, responseObject, http.StatusOK) - } - - return rewriteAccessDeniedResponse(response) -} - -// extractServiceLabelsFromServiceInspectObject retrieve the Labels of the service if present. -// Service schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect -func extractServiceLabelsFromServiceInspectObject(responseObject map[string]interface{}) map[string]interface{} { - // Labels are stored under Spec.Labels - serviceSpecObject := extractJSONField(responseObject, "Spec") - if serviceSpecObject != nil { - return extractJSONField(serviceSpecObject, "Labels") - } - return nil -} - -// extractServiceLabelsFromServiceListObject retrieve the Labels of the service if present. -// Service schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList -func extractServiceLabelsFromServiceListObject(responseObject map[string]interface{}) map[string]interface{} { - // Labels are stored under Spec.Labels - serviceSpecObject := extractJSONField(responseObject, "Spec") - if serviceSpecObject != nil { - return extractJSONField(serviceSpecObject, "Labels") - } - return nil -} - -// decorateServiceList loops through all services and decorates any service with an existing resource control. -// Resource controls checks are based on: resource identifier, stack identifier (from label). -// Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList -func decorateServiceList(serviceData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { - decoratedServiceData := make([]interface{}, 0) - - for _, service := range serviceData { - - serviceObject := service.(map[string]interface{}) - if serviceObject[serviceIdentifier] == nil { - return nil, ErrDockerServiceIdentifierNotFound - } - - serviceID := serviceObject[serviceIdentifier].(string) - serviceObject = decorateResourceWithAccessControl(serviceObject, serviceID, resourceControls) - - serviceLabels := extractServiceLabelsFromServiceListObject(serviceObject) - serviceObject = decorateResourceWithAccessControlFromLabel(serviceLabels, serviceObject, serviceLabelForStackIdentifier, resourceControls) - - decoratedServiceData = append(decoratedServiceData, serviceObject) - } - - return decoratedServiceData, nil -} - -// filterServiceList loops through all services and filters public services (no associated resource control) -// as well as authorized services (access granted to the user based on existing resource control). -// Authorized services are decorated during the process. -// Resource controls checks are based on: resource identifier, stack identifier (from label). -// Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList -func filterServiceList(serviceData []interface{}, context *restrictedDockerOperationContext) ([]interface{}, error) { - filteredServiceData := make([]interface{}, 0) - - for _, service := range serviceData { - serviceObject := service.(map[string]interface{}) - if serviceObject[serviceIdentifier] == nil { - return nil, ErrDockerServiceIdentifierNotFound - } - - serviceID := serviceObject[serviceIdentifier].(string) - serviceObject, access := applyResourceAccessControl(serviceObject, serviceID, context) - if !access { - serviceLabels := extractServiceLabelsFromServiceListObject(serviceObject) - serviceObject, access = applyResourceAccessControlFromLabel(serviceLabels, serviceObject, serviceLabelForStackIdentifier, context) - } - - if access { - filteredServiceData = append(filteredServiceData, serviceObject) - } - } - - return filteredServiceData, nil -} diff --git a/api/http/proxy/tasks.go b/api/http/proxy/tasks.go deleted file mode 100644 index 1da630ff7..000000000 --- a/api/http/proxy/tasks.go +++ /dev/null @@ -1,79 +0,0 @@ -package proxy - -import ( - "net/http" - - "github.com/portainer/portainer/api" -) - -const ( - // ErrDockerTaskServiceIdentifierNotFound defines an error raised when Portainer is unable to find the service identifier associated to a task - ErrDockerTaskServiceIdentifierNotFound = portainer.Error("Docker task service identifier not found") - taskServiceIdentifier = "ServiceID" - taskLabelForStackIdentifier = "com.docker.stack.namespace" -) - -// taskListOperation extracts the response as a JSON object, loop through the tasks array -// and filter the tasks based on resource controls before rewriting the response -func taskListOperation(response *http.Response, executor *operationExecutor) error { - var err error - - // TaskList response is a JSON array - // https://docs.docker.com/engine/api/v1.28/#operation/TaskList - responseArray, err := getResponseAsJSONArray(response) - if err != nil { - return err - } - - if !executor.operationContext.isAdmin && !executor.operationContext.endpointResourceAccess { - responseArray, err = filterTaskList(responseArray, executor.operationContext) - if err != nil { - return err - } - } - - return rewriteResponse(response, responseArray, http.StatusOK) -} - -// extractTaskLabelsFromTaskListObject retrieve the Labels of the task if present. -// Task schema reference: https://docs.docker.com/engine/api/v1.28/#operation/TaskList -func extractTaskLabelsFromTaskListObject(responseObject map[string]interface{}) map[string]interface{} { - // Labels are stored under Spec.ContainerSpec.Labels - taskSpecObject := extractJSONField(responseObject, "Spec") - if taskSpecObject != nil { - containerSpecObject := extractJSONField(taskSpecObject, "ContainerSpec") - if containerSpecObject != nil { - return extractJSONField(containerSpecObject, "Labels") - } - } - return nil -} - -// filterTaskList loops through all tasks and filters public tasks (no associated resource control) -// as well as authorized tasks (access granted to the user based on existing resource control). -// Resource controls checks are based on: service identifier, stack identifier (from label). -// Task object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/TaskList -// any resource control giving access to the user based on the associated service identifier. -func filterTaskList(taskData []interface{}, context *restrictedDockerOperationContext) ([]interface{}, error) { - filteredTaskData := make([]interface{}, 0) - - for _, task := range taskData { - taskObject := task.(map[string]interface{}) - if taskObject[taskServiceIdentifier] == nil { - return nil, ErrDockerTaskServiceIdentifierNotFound - } - - serviceID := taskObject[taskServiceIdentifier].(string) - taskObject, access := applyResourceAccessControl(taskObject, serviceID, context) - if !access { - taskLabels := extractTaskLabelsFromTaskListObject(taskObject) - taskObject, access = applyResourceAccessControlFromLabel(taskLabels, taskObject, taskLabelForStackIdentifier, context) - } - - if access { - filteredTaskData = append(filteredTaskData, taskObject) - } - } - - return filteredTaskData, nil -} diff --git a/api/http/proxy/volumes.go b/api/http/proxy/volumes.go deleted file mode 100644 index 59099d9ed..000000000 --- a/api/http/proxy/volumes.go +++ /dev/null @@ -1,144 +0,0 @@ -package proxy - -import ( - "net/http" - - "github.com/portainer/portainer/api" -) - -const ( - // ErrDockerVolumeIdentifierNotFound defines an error raised when Portainer is unable to find a volume identifier - ErrDockerVolumeIdentifierNotFound = portainer.Error("Docker volume identifier not found") - volumeIdentifier = "Name" - volumeLabelForStackIdentifier = "com.docker.stack.namespace" -) - -// volumeListOperation extracts the response as a JSON object, loop through the volume array -// decorate and/or filter the volumes based on resource controls before rewriting the response -func volumeListOperation(response *http.Response, executor *operationExecutor) error { - var err error - // VolumeList response is a JSON object - // https://docs.docker.com/engine/api/v1.28/#operation/VolumeList - responseObject, err := getResponseAsJSONOBject(response) - if err != nil { - return err - } - - // The "Volumes" field contains the list of volumes as an array of JSON objects - // Response schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList - if responseObject["Volumes"] != nil { - volumeData := responseObject["Volumes"].([]interface{}) - - if executor.operationContext.isAdmin || executor.operationContext.endpointResourceAccess { - volumeData, err = decorateVolumeList(volumeData, executor.operationContext.resourceControls) - } else { - volumeData, err = filterVolumeList(volumeData, executor.operationContext) - } - if err != nil { - return err - } - - // Overwrite the original volume list - responseObject["Volumes"] = volumeData - } - - return rewriteResponse(response, responseObject, http.StatusOK) -} - -// volumeInspectOperation extracts the response as a JSON object, verify that the user -// has access to the volume based on any existing resource control and either rewrite an access denied response -// or a decorated volume. -func volumeInspectOperation(response *http.Response, executor *operationExecutor) error { - // VolumeInspect response is a JSON object - // https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect - responseObject, err := getResponseAsJSONOBject(response) - if err != nil { - return err - } - - if responseObject[volumeIdentifier] == nil { - return ErrDockerVolumeIdentifierNotFound - } - - volumeID := responseObject[volumeIdentifier].(string) - responseObject, access := applyResourceAccessControl(responseObject, volumeID, executor.operationContext) - if access { - return rewriteResponse(response, responseObject, http.StatusOK) - } - - volumeLabels := extractVolumeLabelsFromVolumeInspectObject(responseObject) - responseObject, access = applyResourceAccessControlFromLabel(volumeLabels, responseObject, volumeLabelForStackIdentifier, executor.operationContext) - if access { - return rewriteResponse(response, responseObject, http.StatusOK) - } - - return rewriteAccessDeniedResponse(response) -} - -// extractVolumeLabelsFromVolumeInspectObject retrieve the Labels of the volume if present. -// Volume schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect -func extractVolumeLabelsFromVolumeInspectObject(responseObject map[string]interface{}) map[string]interface{} { - // Labels are stored under Labels - return extractJSONField(responseObject, "Labels") -} - -// extractVolumeLabelsFromVolumeListObject retrieve the Labels of the volume if present. -// Volume schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList -func extractVolumeLabelsFromVolumeListObject(responseObject map[string]interface{}) map[string]interface{} { - // Labels are stored under Labels - return extractJSONField(responseObject, "Labels") -} - -// decorateVolumeList loops through all volumes and decorates any volume with an existing resource control. -// Resource controls checks are based on: resource identifier, stack identifier (from label). -// Volume object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList -func decorateVolumeList(volumeData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { - decoratedVolumeData := make([]interface{}, 0) - - for _, volume := range volumeData { - - volumeObject := volume.(map[string]interface{}) - if volumeObject[volumeIdentifier] == nil { - return nil, ErrDockerVolumeIdentifierNotFound - } - - volumeID := volumeObject[volumeIdentifier].(string) - volumeObject = decorateResourceWithAccessControl(volumeObject, volumeID, resourceControls) - - volumeLabels := extractVolumeLabelsFromVolumeListObject(volumeObject) - volumeObject = decorateResourceWithAccessControlFromLabel(volumeLabels, volumeObject, volumeLabelForStackIdentifier, resourceControls) - - decoratedVolumeData = append(decoratedVolumeData, volumeObject) - } - - return decoratedVolumeData, nil -} - -// filterVolumeList loops through all volumes and filters public volumes (no associated resource control) -// as well as authorized volumes (access granted to the user based on existing resource control). -// Authorized volumes are decorated during the process. -// Resource controls checks are based on: resource identifier, stack identifier (from label). -// Volume object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList -func filterVolumeList(volumeData []interface{}, context *restrictedDockerOperationContext) ([]interface{}, error) { - filteredVolumeData := make([]interface{}, 0) - - for _, volume := range volumeData { - volumeObject := volume.(map[string]interface{}) - if volumeObject[volumeIdentifier] == nil { - return nil, ErrDockerVolumeIdentifierNotFound - } - - volumeID := volumeObject[volumeIdentifier].(string) - volumeObject, access := applyResourceAccessControl(volumeObject, volumeID, context) - if !access { - volumeLabels := extractVolumeLabelsFromVolumeListObject(volumeObject) - volumeObject, access = applyResourceAccessControlFromLabel(volumeLabels, volumeObject, volumeLabelForStackIdentifier, context) - } - - if access { - filteredVolumeData = append(filteredVolumeData, volumeObject) - } - } - - return filteredVolumeData, nil -} diff --git a/api/http/security/authorization.go b/api/http/security/authorization.go index d2a0550c6..2ba3e8743 100644 --- a/api/http/security/authorization.go +++ b/api/http/security/authorization.go @@ -4,40 +4,6 @@ import ( "github.com/portainer/portainer/api" ) -// AuthorizedResourceControlDeletion ensure that the user can delete a resource control object. -// A non-administrator user cannot delete a resource control where: -// * the Public flag is false -// * he is not one of the users in the user accesses -// * he is not a member of any team within the team accesses -func AuthorizedResourceControlDeletion(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool { - if context.IsAdmin || resourceControl.Public { - return true - } - - userAccessesCount := len(resourceControl.UserAccesses) - teamAccessesCount := len(resourceControl.TeamAccesses) - - if teamAccessesCount > 0 { - for _, access := range resourceControl.TeamAccesses { - for _, membership := range context.UserMemberships { - if membership.TeamID == access.TeamID { - return true - } - } - } - } - - if userAccessesCount > 0 { - for _, access := range resourceControl.UserAccesses { - if access.UserID == context.UserID { - return true - } - } - } - - return false -} - // AuthorizedResourceControlAccess checks whether the user can alter an existing resource control. func AuthorizedResourceControlAccess(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool { if context.IsAdmin || resourceControl.Public { @@ -62,30 +28,22 @@ func AuthorizedResourceControlAccess(resourceControl *portainer.ResourceControl, } // AuthorizedResourceControlUpdate ensure that the user can update a resource control object. -// It reuses the creation restrictions and adds extra checks. -// A non-administrator user cannot update a resource control where: -// * he wants to put one or more user in the user accesses -func AuthorizedResourceControlUpdate(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool { - userAccessesCount := len(resourceControl.UserAccesses) - if !context.IsAdmin && userAccessesCount > 0 { - return false - } - - return AuthorizedResourceControlCreation(resourceControl, context) -} - -// AuthorizedResourceControlCreation ensure that the user can create a resource control object. // A non-administrator user cannot create a resource control where: // * the Public flag is set false +// * the AdministatorsOnly flag is set to true // * he wants to create a resource control without any user/team accesses // * he wants to add more than one user in the user accesses // * he wants to add a user in the user accesses that is not corresponding to its id // * he wants to add a team he is not a member of -func AuthorizedResourceControlCreation(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool { +func AuthorizedResourceControlUpdate(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool { if context.IsAdmin || resourceControl.Public { return true } + if resourceControl.AdministratorsOnly { + return false + } + userAccessesCount := len(resourceControl.UserAccesses) teamAccessesCount := len(resourceControl.TeamAccesses) @@ -133,15 +91,6 @@ func AuthorizedTeamManagement(teamID portainer.TeamID, context *RestrictedReques return false } -// AuthorizedUserManagement ensure that access to the management of the specified user is granted. -// It will check if the user is either administrator or the owner of the user account. -func AuthorizedUserManagement(userID portainer.UserID, context *RestrictedRequestContext) bool { - if context.IsAdmin || context.UserID == userID { - return true - } - return false -} - // authorizedEndpointAccess ensure that the user can access the specified endpoint. // It will check if the user is part of the authorized users or part of a team that is // listed in the authorized teams of the endpoint and the associated group. diff --git a/api/http/server.go b/api/http/server.go index 40c11ac2e..066bc7bef 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -87,6 +87,7 @@ func (server *Server) Start() error { proxyManagerParameters := &proxy.ManagerParams{ ResourceControlService: server.ResourceControlService, UserService: server.UserService, + TeamService: server.TeamService, TeamMembershipService: server.TeamMembershipService, SettingsService: server.SettingsService, RegistryService: server.RegistryService, @@ -94,6 +95,7 @@ func (server *Server) Start() error { SignatureService: server.SignatureService, ReverseTunnelService: server.ReverseTunnelService, ExtensionService: server.ExtensionService, + DockerClientFactory: server.DockerClientFactory, } proxyManager := proxy.NewManager(proxyManagerParameters) @@ -134,6 +136,7 @@ func (server *Server) Start() error { authHandler.EndpointGroupService = server.EndpointGroupService authHandler.RoleService = server.RoleService authHandler.ProxyManager = proxyManager + authHandler.AuthorizationService = authorizationService var roleHandler = roles.NewHandler(requestBouncer) roleHandler.RoleService = server.RoleService @@ -214,6 +217,8 @@ func (server *Server) Start() error { stackHandler.RegistryService = server.RegistryService stackHandler.DockerHubService = server.DockerHubService stackHandler.SettingsService = server.SettingsService + stackHandler.UserService = server.UserService + stackHandler.ExtensionService = server.ExtensionService var tagHandler = tags.NewHandler(requestBouncer) tagHandler.TagService = server.TagService diff --git a/api/libcompose/compose_stack.go b/api/libcompose/compose_stack.go index 01de1cd20..30322d7d8 100644 --- a/api/libcompose/compose_stack.go +++ b/api/libcompose/compose_stack.go @@ -16,6 +16,10 @@ import ( "github.com/portainer/portainer/api" ) +const ( + dockerClientVersion = "1.24" +) + // ComposeStackManager represents a service for managing compose stacks. type ComposeStackManager struct { dataPath string @@ -40,7 +44,7 @@ func (manager *ComposeStackManager) createClient(endpoint *portainer.Endpoint) ( clientOpts := client.Options{ Host: endpointURL, - APIVersion: portainer.SupportedDockerAPIVersion, + APIVersion: dockerClientVersion, } if endpoint.TLSConfig.TLS { diff --git a/api/portainer.go b/api/portainer.go index d7997b2d7..237ceca1d 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -175,14 +175,15 @@ type ( // Stack represents a Docker stack created via docker stack deploy Stack struct { - ID StackID `json:"Id"` - Name string `json:"Name"` - Type StackType `json:"Type"` - EndpointID EndpointID `json:"EndpointId"` - SwarmID string `json:"SwarmId"` - EntryPoint string `json:"EntryPoint"` - Env []Pair `json:"Env"` - ProjectPath string + ID StackID `json:"Id"` + Name string `json:"Name"` + Type StackType `json:"Type"` + EndpointID EndpointID `json:"EndpointId"` + SwarmID string `json:"SwarmId"` + EntryPoint string `json:"EntryPoint"` + Env []Pair `json:"Env"` + ResourceControl *ResourceControl `json:"ResourceControl"` + ProjectPath string } // RegistryID represents a registry identifier @@ -191,6 +192,13 @@ type ( // RegistryType represents a type of registry RegistryType int + // GitlabRegistryData represents data required for gitlab registry to work + GitlabRegistryData struct { + ProjectID int `json:"ProjectId"` + InstanceURL string `json:"InstanceURL"` + ProjectPath string `json:"ProjectPath"` + } + // Registry represents a Docker registry with all the info required // to connect to it Registry struct { @@ -202,6 +210,7 @@ type ( Username string `json:"Username"` Password string `json:"Password,omitempty"` ManagementConfiguration *RegistryManagementConfiguration `json:"ManagementConfiguration"` + Gitlab GitlabRegistryData `json:"Gitlab"` UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"` TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"` @@ -295,6 +304,7 @@ type ( Name string `json:"Name"` Description string `json:"Description"` Authorizations Authorizations `json:"Authorizations"` + Priority int `json:"Priority"` } // AccessPolicy represent a policy that can be associated to a user or team @@ -439,21 +449,20 @@ type ( // ResourceControl represent a reference to a Docker resource with specific access controls ResourceControl struct { - ID ResourceControlID `json:"Id"` - ResourceID string `json:"ResourceId"` - SubResourceIDs []string `json:"SubResourceIds"` - Type ResourceControlType `json:"Type"` - UserAccesses []UserResourceAccess `json:"UserAccesses"` - TeamAccesses []TeamResourceAccess `json:"TeamAccesses"` - Public bool `json:"Public"` + ID ResourceControlID `json:"Id"` + ResourceID string `json:"ResourceId"` + SubResourceIDs []string `json:"SubResourceIds"` + Type ResourceControlType `json:"Type"` + UserAccesses []UserResourceAccess `json:"UserAccesses"` + TeamAccesses []TeamResourceAccess `json:"TeamAccesses"` + Public bool `json:"Public"` + AdministratorsOnly bool `json:"AdministratorsOnly"` + System bool `json:"System"` // Deprecated fields // Deprecated in DBVersion == 2 OwnerID UserID `json:"OwnerId,omitempty"` AccessLevel ResourceAccessLevel `json:"AccessLevel,omitempty"` - - // Deprecated in DBVersion == 14 - AdministratorsOnly bool `json:"AdministratorsOnly,omitempty"` } // ResourceControlType represents the type of resource associated to the resource control (volume, container, service...) @@ -742,7 +751,7 @@ type ( // ResourceControlService represents a service for managing resource control data ResourceControlService interface { ResourceControl(ID ResourceControlID) (*ResourceControl, error) - ResourceControlByResourceID(resourceID string) (*ResourceControl, error) + ResourceControlByResourceIDAndType(resourceID string, resourceType ResourceControlType) (*ResourceControl, error) ResourceControls() ([]ResourceControl, error) CreateResourceControl(rc *ResourceControl) error UpdateResourceControl(ID ResourceControlID, resourceControl *ResourceControl) error @@ -883,9 +892,11 @@ type ( // ExtensionManager represents a service used to manage extensions ExtensionManager interface { FetchExtensionDefinitions() ([]Extension, error) + InstallExtension(extension *Extension, licenseKey string, archiveFileName string, extensionArchive []byte) error EnableExtension(extension *Extension, licenseKey string) error DisableExtension(extension *Extension) error UpdateExtension(extension *Extension, version string) error + StartExtensions() error } // ReverseTunnelService represensts a service used to manage reverse tunnel connections. @@ -903,9 +914,9 @@ type ( const ( // APIVersion is the version number of the Portainer API - APIVersion = "1.22.1" + APIVersion = "1.23.0" // DBVersion is the version number of the Portainer database - DBVersion = 20 + DBVersion = 22 // 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 @@ -913,7 +924,7 @@ const ( // 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.1.json" + ExtensionDefinitionsURL = AssetsServerURL + "/extensions-" + APIVersion + ".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 @@ -929,12 +940,12 @@ const ( // PortainerAgentSignatureMessage represents the message used to create a digital signature // to be used when communicating with an agent PortainerAgentSignatureMessage = "Portainer-App" - // SupportedDockerAPIVersion is the minimum Docker API version supported by Portainer - SupportedDockerAPIVersion = "1.24" // ExtensionServer represents the server used by Portainer to communicate with extensions ExtensionServer = "localhost" // DefaultEdgeAgentCheckinIntervalInSeconds represents the default interval (in seconds) used by Edge agents to checkin with the Portainer instance DefaultEdgeAgentCheckinIntervalInSeconds = 5 + // LocalExtensionManifestFile represents the name of the local manifest file for extensions + LocalExtensionManifestFile = "/extensions.json" ) const ( @@ -1076,6 +1087,8 @@ const ( AzureRegistry // CustomRegistry represents a custom registry CustomRegistry + // GitlabRegistry represents a gitlab registry + GitlabRegistry ) const ( diff --git a/api/swagger.yaml b/api/swagger.yaml index 54f437420..56ed496f4 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.1" + version: "1.23.0" title: "Portainer API" contact: email: "info@portainer.io" @@ -1235,7 +1235,7 @@ paths: summary: "Create a new resource control" description: | Create a new resource control to restrict access to a Docker resource. - **Access policy**: restricted + **Access policy**: administrator operationId: "ResourceControlCreate" consumes: - "application/json" @@ -1343,7 +1343,7 @@ paths: summary: "Remove a resource control" description: | Remove a resource control. - **Access policy**: restricted + **Access policy**: administrator operationId: "ResourceControlDelete" security: - jwt: [] @@ -3174,7 +3174,7 @@ definitions: description: "Is analytics enabled" Version: type: "string" - example: "1.22.1" + example: "1.23.0" description: "Portainer API version" PublicSettingsInspectResponse: type: "object" diff --git a/api/swagger_config.json b/api/swagger_config.json index 1f65abf52..c37562555 100644 --- a/api/swagger_config.json +++ b/api/swagger_config.json @@ -1,5 +1,5 @@ { "packageName": "portainer", - "packageVersion": "1.22.1", + "packageVersion": "1.23.0", "projectName": "portainer" } diff --git a/app/__module.js b/app/__module.js index 9eaa6248d..881453c9a 100644 --- a/app/__module.js +++ b/app/__module.js @@ -1,5 +1,4 @@ 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/app.js b/app/app.js index acdee2add..95cf796bf 100644 --- a/app/app.js +++ b/app/app.js @@ -1,25 +1,13 @@ -import _ from 'lodash-es'; +import $ from 'jquery'; +import '@babel/polyfill' angular.module('portainer') -.run(['$rootScope', '$state', '$interval', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', 'SystemService', 'cfpLoadingBar', '$transitions', 'HttpRequestHelper', -function ($rootScope, $state, $interval, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics, SystemService, cfpLoadingBar, $transitions, HttpRequestHelper) { +.run(['$rootScope', '$state', '$interval', 'LocalStorage', 'EndpointProvider', 'SystemService', 'cfpLoadingBar', '$transitions', 'HttpRequestHelper', +function ($rootScope, $state, $interval, LocalStorage, EndpointProvider, SystemService, cfpLoadingBar, $transitions, HttpRequestHelper) { 'use strict'; EndpointProvider.initialize(); - StateManager.initialize() - .then(function success(state) { - if (state.application.authentication) { - initAuthentication(authManager, Authentication, $rootScope, $state); - } - if (state.application.analytics) { - initAnalytics(Analytics, $rootScope); - } - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve application settings'); - }); - $rootScope.$state = $state; // Workaround to prevent the loading bar from going backward @@ -35,11 +23,23 @@ function ($rootScope, $state, $interval, Authentication, authManager, StateManag HttpRequestHelper.resetAgentHeaders(); }); + $state.defaultErrorHandler(function() { + // Do not log transitionTo errors + }); + // Keep-alive Edge endpoints by sending a ping request every minute $interval(function() { ping(EndpointProvider, SystemService); }, 60 * 1000) + $(document).ajaxSend(function (event, jqXhr, jqOpts) { + const type = jqOpts.type === 'POST' || jqOpts.type === 'PUT' || jqOpts.type === 'PATCH'; + const hasNoContentType = jqOpts.contentType !== 'application/json' && jqOpts.headers && !jqOpts.headers['Content-Type']; + if (type && hasNoContentType) { + jqXhr.setRequestHeader('Content-Type', 'application/json'); + } + jqXhr.setRequestHeader('Authorization', 'Bearer ' + LocalStorage.getJWT()); + }); }]); function ping(EndpointProvider, SystemService) { @@ -48,28 +48,3 @@ function ping(EndpointProvider, SystemService) { SystemService.ping(endpoint.Id); } } - -function initAuthentication(authManager, Authentication, $rootScope, $state) { - authManager.checkAuthOnRefresh(); - Authentication.init(); - - // The unauthenticated event is broadcasted by the jwtInterceptor when - // hitting a 401. We're using this instead of the usual combination of - // authManager.redirectWhenUnauthenticated() + unauthenticatedRedirector - // to have more controls on which URL should trigger the unauthenticated state. - $rootScope.$on('unauthenticated', function (event, data) { - if (!_.includes(data.config.url, '/v2/')) { - $state.go('portainer.auth', { error: 'Your session has expired' }); - } - }); -} - -function initAnalytics(Analytics, $rootScope) { - Analytics.offline(false); - Analytics.registerScriptTags(); - Analytics.registerTrackers(); - $rootScope.$on('$stateChangeSuccess', function (event, toState) { - Analytics.trackPage(toState.url); - Analytics.pageView(); - }); -} diff --git a/app/docker/components/datatables/configs-datatable/configsDatatable.html b/app/docker/components/datatables/configs-datatable/configsDatatable.html index 31fd0cc67..d6a1e386d 100644 --- a/app/docker/components/datatables/configs-datatable/configsDatatable.html +++ b/app/docker/components/datatables/configs-datatable/configsDatatable.html @@ -101,7 +101,7 @@ - {{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = 'administrators' }} + {{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = $ctrl.RCO.ADMINISTRATORS }} diff --git a/app/docker/components/datatables/containers-datatable/containersDatatable.html b/app/docker/components/datatables/containers-datatable/containersDatatable.html index fb2a24cd8..b8bdc3a4b 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatable.html +++ b/app/docker/components/datatables/containers-datatable/containersDatatable.html @@ -272,7 +272,7 @@ - {{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = 'administrators' }} + {{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = $ctrl.RCO.ADMINISTRATORS }} 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 index 691569467..7d6e4ef65 100644 --- a/app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.html +++ b/app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.html @@ -9,6 +9,7 @@ {{ item.Name | truncate:40 }} {{ item.Name | truncate:40 }} + System {{ item.StackName ? item.StackName : '-' }} {{ item.Scope }} @@ -22,6 +23,6 @@ - {{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = 'administrators' }} + {{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = RCO.ADMINISTRATORS }} \ 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 index 90dc60c57..41cabc224 100644 --- a/app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.js +++ b/app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.js @@ -1,3 +1,5 @@ +import { ResourceControlOwnership as RCO } from 'Portainer/models/resourceControl/resourceControlOwnership'; + angular.module('portainer.docker') .directive('networkRowContent', [function networkRowContent() { var directive = { @@ -9,6 +11,9 @@ angular.module('portainer.docker') parentCtrl: '<', allowCheckbox: '<', allowExpand: '<' + }, + controller: ($scope) => { + $scope.RCO = RCO; } }; return directive; diff --git a/app/docker/components/datatables/networks-datatable/networksDatatableController.js b/app/docker/components/datatables/networks-datatable/networksDatatableController.js index 7550111b4..eefb9d153 100644 --- a/app/docker/components/datatables/networks-datatable/networksDatatableController.js +++ b/app/docker/components/datatables/networks-datatable/networksDatatableController.js @@ -1,13 +1,13 @@ import _ from 'lodash-es'; angular.module('portainer.docker') - .controller('NetworksDatatableController', ['$scope', '$controller', 'PREDEFINED_NETWORKS', 'DatatableService', - function ($scope, $controller, PREDEFINED_NETWORKS, DatatableService) { + .controller('NetworksDatatableController', ['$scope', '$controller', 'NetworkHelper', 'DatatableService', + function ($scope, $controller, NetworkHelper, DatatableService) { angular.extend(this, $controller('GenericDatatableController', {$scope: $scope})); this.disableRemove = function(item) { - return PREDEFINED_NETWORKS.includes(item.Name); + return NetworkHelper.isSystemNetwork(item); }; this.state = Object.assign(this.state, { @@ -15,7 +15,7 @@ angular.module('portainer.docker') }) /** - * Do not allow PREDEFINED_NETWORKS to be selected + * Do not allow system networks to be selected */ this.allowSelection = function(item) { return !this.disableRemove(item); diff --git a/app/docker/components/datatables/secrets-datatable/secretsDatatable.html b/app/docker/components/datatables/secrets-datatable/secretsDatatable.html index 4f9916f53..28e560a36 100644 --- a/app/docker/components/datatables/secrets-datatable/secretsDatatable.html +++ b/app/docker/components/datatables/secrets-datatable/secretsDatatable.html @@ -101,7 +101,7 @@ - {{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = 'administrators' }} + {{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = $ctrl.RCO.ADMINISTRATORS }} diff --git a/app/docker/components/datatables/services-datatable/servicesDatatable.html b/app/docker/components/datatables/services-datatable/servicesDatatable.html index 0952d091f..ce551f6c0 100644 --- a/app/docker/components/datatables/services-datatable/servicesDatatable.html +++ b/app/docker/components/datatables/services-datatable/servicesDatatable.html @@ -156,7 +156,7 @@ - {{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = 'administrators' }} + {{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = $ctrl.RCO.ADMINISTRATORS }} diff --git a/app/docker/components/datatables/volumes-datatable/volumesDatatable.html b/app/docker/components/datatables/volumes-datatable/volumesDatatable.html index 1266ea5be..1c8f66f09 100644 --- a/app/docker/components/datatables/volumes-datatable/volumesDatatable.html +++ b/app/docker/components/datatables/volumes-datatable/volumesDatatable.html @@ -164,7 +164,7 @@ - {{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = 'administrators' }} + {{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = $ctrl.RCO.ADMINISTRATORS }} diff --git a/app/docker/components/imageRegistry/por-image-registry.js b/app/docker/components/imageRegistry/por-image-registry.js index 568b12ce5..39d6a5919 100644 --- a/app/docker/components/imageRegistry/por-image-registry.js +++ b/app/docker/components/imageRegistry/por-image-registry.js @@ -2,8 +2,8 @@ angular.module('portainer.docker').component('porImageRegistry', { templateUrl: './porImageRegistry.html', controller: 'porImageRegistryController', bindings: { - 'image': '=', - 'registry': '=', + 'model': '=', // must be of type PorImageRegistryModel + 'pullWarning': '<', 'autoComplete': '<', 'labelClass': '@', 'inputClass': '@' diff --git a/app/docker/components/imageRegistry/porImageRegistry.html b/app/docker/components/imageRegistry/porImageRegistry.html index aae64be66..05bc7ea07 100644 --- a/app/docker/components/imageRegistry/porImageRegistry.html +++ b/app/docker/components/imageRegistry/porImageRegistry.html @@ -1,21 +1,54 @@ -
- -
- -
- -
- -
-
-
-
-
-

Image name is required.

+ +
+
+ +
+ +
+ +
+
+ {{$ctrl.displayedRegistryURL()}} + +
+ + +
+
+ +
+ +
+
+
+ + +
+
+
+

Image name is required. Tag must be specified otherwise Portainer will pull all tags associated to the image.

+
+
+
+ + diff --git a/app/docker/components/imageRegistry/porImageRegistryController.js b/app/docker/components/imageRegistry/porImageRegistryController.js index 6c47fb80a..7f4e9cfbc 100644 --- a/app/docker/components/imageRegistry/porImageRegistryController.js +++ b/app/docker/components/imageRegistry/porImageRegistryController.js @@ -1,31 +1,89 @@ +import angular from 'angular'; import _ from 'lodash-es'; +import { DockerHubViewModel } from 'Portainer/models/dockerhub'; +import { RegistryTypes } from 'Extensions/registry-management/models/registryTypes'; -angular.module('portainer.docker') -.controller('porImageRegistryController', ['$q', 'RegistryService', 'DockerHubService', 'ImageService', 'Notifications', -function ($q, RegistryService, DockerHubService, ImageService, Notifications) { - var ctrl = this; +class porImageRegistryController { + /* @ngInject */ + constructor($async, $scope, ImageHelper, RegistryService, DockerHubService, ImageService, Notifications) { + this.$async = $async; + this.$scope = $scope; + this.ImageHelper = ImageHelper; + this.RegistryService = RegistryService; + this.DockerHubService = DockerHubService; + this.ImageService = ImageService; + this.Notifications = Notifications; - function initComponent() { - $q.all({ - registries: RegistryService.registries(), - dockerhub: DockerHubService.dockerhub(), - availableImages: ctrl.autoComplete ? ImageService.images() : [] - }) - .then(function success(data) { - var dockerhub = data.dockerhub; - var registries = data.registries; - ctrl.availableImages = ImageService.getUniqueTagListFromImages(data.availableImages); - ctrl.availableRegistries = [dockerhub].concat(registries); - if (!ctrl.registry.Id) { - ctrl.registry = dockerhub; - } else { - ctrl.registry = _.find(ctrl.availableRegistries, { 'Id': ctrl.registry.Id }); - } - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve registries'); - }); + this.onInit = this.onInit.bind(this); + this.onRegistryChange = this.onRegistryChange.bind(this); + + this.$scope.$watch(() => this.model.Registry, this.onRegistryChange); } - initComponent(); -}]); + isKnownRegistry(registry) { + return !(registry instanceof DockerHubViewModel) && registry.URL; + } + + getRegistryURL(registry) { + let url = registry.URL; + if (registry.Type === RegistryTypes.GITLAB) { + url = registry.URL + '/' + registry.Gitlab.ProjectPath; + } + return url; + } + + prepareAutocomplete() { + let images = []; + const registry = this.model.Registry; + if (this.isKnownRegistry(registry)) { + const url = this.getRegistryURL(registry); + const registryImages = _.filter(this.images, (image) => _.includes(image, url)); + images = _.map(registryImages, (image) => _.replace(image, new RegExp(url + '\/?'), '')); + } else { + const registries = _.filter(this.availableRegistries, (reg) => this.isKnownRegistry(reg)); + const registryImages = _.flatMap(registries, (registry) => _.filter(this.images, (image) => _.includes(image, registry.URL))); + const imagesWithoutKnown = _.difference(this.images, registryImages); + images = _.filter(imagesWithoutKnown, (image) => !this.ImageHelper.imageContainsURL(image)); + } + this.availableImages = images; + } + + onRegistryChange() { + this.prepareAutocomplete(); + if (this.model.Registry.Type === RegistryTypes.GITLAB && this.model.Image) { + this.model.Image = _.replace(this.model.Image, this.model.Registry.Gitlab.ProjectPath, ''); + } + } + + displayedRegistryURL() { + return this.getRegistryURL(this.model.Registry) || 'docker.io'; + } + + async onInit() { + try { + const [registries, dockerhub, images] = await Promise.all([ + this.RegistryService.registries(), + this.DockerHubService.dockerhub(), + this.autoComplete ? this.ImageService.images() : [] + ]); + this.images = this.ImageService.getUniqueTagListFromImages(images); + this.availableRegistries = _.concat(dockerhub, registries); + + const id = this.model.Registry.Id; + if (!id) { + this.model.Registry = dockerhub; + } else { + this.model.Registry = _.find(this.availableRegistries, { 'Id': id }); + } + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve registries'); + } + } + + $onInit() { + return this.$async(this.onInit); + } +} + +export default porImageRegistryController; +angular.module('portainer.docker').controller('porImageRegistryController', porImageRegistryController); diff --git a/app/docker/filters/filters.js b/app/docker/filters/filters.js index 3ba22ef50..3fd65f22a 100644 --- a/app/docker/filters/filters.js +++ b/app/docker/filters/filters.js @@ -278,6 +278,9 @@ angular.module('portainer.docker') .filter('trimshasum', function () { 'use strict'; return function (imageName) { + if (!imageName) { + return; + } if (imageName.indexOf('sha256:') === 0) { return imageName.substring(7, 19); } diff --git a/app/docker/helpers/containerHelper.js b/app/docker/helpers/containerHelper.js index 759162b72..ab169a4c5 100644 --- a/app/docker/helpers/containerHelper.js +++ b/app/docker/helpers/containerHelper.js @@ -1,5 +1,66 @@ +import _ from 'lodash-es'; import splitargs from 'splitargs/src/splitargs' +const portPattern = /^([1-9]|[1-5]?[0-9]{2,4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$/m; + +function parsePort(port) { + if (portPattern.test(port)) { + return parseInt(port); + } else { + return 0; + } +} + +function parsePortRange(portRange) { + if (typeof portRange !== 'string') { + portRange = portRange.toString(); + } + + // Split the range and convert to integers + const stringPorts = _.split(portRange, '-', 2); + const intPorts = _.map(stringPorts, parsePort); + + // If it's not a range, we still make sure that we return two ports (start & end) + if (intPorts.length == 1) { + intPorts.push(intPorts[0]); + } + + return intPorts; +} + +function isValidPortRange(portRange) { + if (typeof portRange === 'string') { + portRange = parsePortRange(); + } + + return Array.isArray(portRange) && portRange.length === 2 && + portRange[0] > 0 && portRange[1] >= portRange[0]; +} + +function createPortRange(portRangeText, port) { + if (typeof portRangeText !== 'string') { + portRangeText = portRangeText.toString(); + } + + let hostIp = null; + const colonIndex = portRangeText.indexOf(':'); + if (colonIndex >= 0) { + hostIp = portRangeText.substr(0, colonIndex); + portRangeText = portRangeText.substr(colonIndex + 1); + } + + port = (typeof port === 'number' ? port : parsePort(port)); + const portRange = parsePortRange(portRangeText); + const startPort = Math.min(portRange[0], port); + const endPort = Math.max(portRange[1], port); + + if (hostIp) { + return hostIp + ':' + startPort + '-' + endPort; + } else { + return startPort + '-' + endPort; + } +} + angular.module('portainer.docker') .factory('ContainerHelper', [function ContainerHelperFactory() { 'use strict'; @@ -54,5 +115,124 @@ angular.module('portainer.docker') return config; }; + helper.preparePortBindings = function(portBindings) { + const bindings = {}; + _.forEach(portBindings, (portBinding) => { + if (!portBinding.containerPort) { + return; + } + + let hostPort = portBinding.hostPort; + const containerPortRange = parsePortRange(portBinding.containerPort); + if (!isValidPortRange(containerPortRange)) { + throw new Error('Invalid port specification: ' + portBinding.containerPort); + } + + const startPort = containerPortRange[0]; + const endPort = containerPortRange[1]; + let hostIp = undefined; + let startHostPort = 0; + let endHostPort = 0; + if (hostPort) { + if (hostPort.indexOf(':') > -1) { + const hostAndPort = _.split(hostPort, ':'); + hostIp = hostAndPort[0]; + hostPort = hostAndPort[1]; + } + + const hostPortRange = parsePortRange(hostPort); + if (!isValidPortRange(hostPortRange)) { + throw new Error('Invalid port specification: ' + hostPort); + } + + startHostPort = hostPortRange[0]; + endHostPort = hostPortRange[1]; + if (endPort !== startPort && (endPort - startPort) !== (endHostPort - startHostPort)) { + throw new Error('Invalid port specification: ' + hostPort); + } + } + + for (let i = 0; i <= (endPort - startPort); i++) { + const containerPort = (startPort + i).toString(); + if (startHostPort > 0) { + hostPort = (startHostPort + i).toString(); + } + if (startPort === endPort && startHostPort !== endHostPort) { + hostPort += '-' + endHostPort.toString(); + } + + const bindKey = containerPort + '/' + portBinding.protocol; + bindings[bindKey] = [{ HostIp: hostIp, HostPort: hostPort }]; + } + }); + return bindings; + }; + + helper.sortAndCombinePorts = function(portBindings) { + const bindings = []; + const portBindingKeys = _.keys(portBindings); + + // Group the port bindings by protocol + const portBindingKeysByProtocol = _.groupBy(portBindingKeys, (portKey) => { + return _.split(portKey, '/')[1]; + }); + + _.forEach(portBindingKeysByProtocol, (portBindingKeys, protocol) => { + // Group the port bindings by host IP + const portBindingKeysByHostIp = _.groupBy(portBindingKeys, (portKey) => { + const portBinding = portBindings[portKey][0]; + return portBinding.HostIp || ''; + }); + + _.forEach(portBindingKeysByHostIp, (portBindingKeys) => { + // Sort by host port + const sortedPortBindingKeys = _.orderBy(portBindingKeys, (portKey) => { + return parseInt(_.split(portKey, '/')[0]); + }); + + let previousHostPort = -1; + let previousContainerPort = -1; + _.forEach(sortedPortBindingKeys, (portKey) => { + const portKeySplit = _.split(portKey, '/'); + const containerPort = parseInt(portKeySplit[0]); + const portBinding = portBindings[portKey][0]; + const hostPort = parsePort(portBinding.HostPort); + + // We only combine single ports, and skip the host port ranges on one container port + if (hostPort > 0) { + // If we detect consecutive ports, we create a range of them + if (bindings.length > 0 && previousHostPort === (hostPort - 1) && previousContainerPort === (containerPort - 1)) { + bindings[bindings.length-1].hostPort = createPortRange(bindings[bindings.length-1].hostPort, hostPort); + bindings[bindings.length-1].containerPort = createPortRange(bindings[bindings.length-1].containerPort, containerPort); + previousHostPort = hostPort; + previousContainerPort = containerPort; + return; + } + + previousHostPort = hostPort; + previousContainerPort = containerPort; + } else { + previousHostPort = -1; + previousContainerPort = -1; + } + + let bindingHostPort = portBinding.HostPort.toString(); + if (portBinding.HostIp) { + bindingHostPort = portBinding.HostIp + ':' + bindingHostPort; + } + + const binding = { + hostPort: bindingHostPort, + containerPort: containerPort, + protocol: protocol + }; + bindings.push(binding); + }); + }); + }); + + return bindings; + }; + return helper; }]); diff --git a/app/docker/helpers/imageHelper.js b/app/docker/helpers/imageHelper.js index b46951c83..b1bd8d78d 100644 --- a/app/docker/helpers/imageHelper.js +++ b/app/docker/helpers/imageHelper.js @@ -1,4 +1,5 @@ import _ from 'lodash-es'; +import { RegistryTypes } from 'Extensions/registry-management/models/registryTypes'; angular.module('portainer.docker') .factory('ImageHelper', [function ImageHelperFactory() { @@ -6,69 +7,66 @@ angular.module('portainer.docker') var helper = {}; - helper.extractImageAndRegistryFromRepository = function(repository) { - var slashCount = _.countBy(repository)['/']; - var registry = null; - var image = repository; - if (slashCount >= 1) { - // assume something/something[/...] - registry = repository.substr(0, repository.indexOf('/')); - // assume valid DNS name or IP (contains at least one '.') - if (_.countBy(registry)['.'] > 0) { - image = repository.substr(repository.indexOf('/') + 1); - } else { - registry = null; - } - } + helper.isValidTag = isValidTag; + helper.createImageConfigForContainer = createImageConfigForContainer; + helper.getImagesNamesForDownload = getImagesNamesForDownload; + helper.removeDigestFromRepository = removeDigestFromRepository; + helper.imageContainsURL = imageContainsURL; - return { - registry: registry, - image: image - }; - }; + function isValidTag(tag) { + return tag.match(/^(?![\.\-])([a-zA-Z0-9\_\.\-])+$/g); + } - helper.getImagesNamesForDownload = function(images) { + function getImagesNamesForDownload(images) { var names = images.map(function(image) { return image.RepoTags[0] !== ':' ? image.RepoTags[0] : image.Id; }); return { names: names }; - }; - - function extractNameAndTag(imageName, registry) { - var imageNameAndTag = imageName.split(':'); - var image = imageNameAndTag[0]; - var tag = imageNameAndTag[1] ? imageNameAndTag[1] : 'latest'; - if (registry) { - image = registry + '/' + imageNameAndTag[0]; - } - - return { - image: image, - tag: tag - }; } - helper.createImageConfigForCommit = function(imageName, registry) { - var imageAndTag = extractNameAndTag(imageName, registry); - return { - repo: imageAndTag.image, - tag: imageAndTag.tag + /** + * + * @param {PorImageRegistryModel} registry + */ + function createImageConfigForContainer(registry) { + const data = { + fromImage: '' }; - }; + let fullImageName = ''; - helper.createImageConfigForContainer = function (imageName, registry) { - var imageAndTag = extractNameAndTag(imageName, registry); - return { - fromImage: imageAndTag.image, - tag: imageAndTag.tag - }; - }; + if (registry.UseRegistry) { + if (registry.Registry.Type === RegistryTypes.GITLAB) { + const slash = _.startsWith(registry.Image, ':') ? '' : '/'; + fullImageName = registry.Registry.URL + '/' + registry.Registry.Gitlab.ProjectPath + slash + registry.Image; + } else { + const url = registry.Registry.URL ? registry.Registry.URL + '/' : ''; + fullImageName = url + registry.Image; + } + if (!_.includes(registry.Image, ':')) { + fullImageName += ':latest'; + } + } else { + fullImageName = registry.Image; + } - helper.removeDigestFromRepository = function(repository) { + data.fromImage = fullImageName; + return data; + } + + function imageContainsURL(image) { + const split = _.split(image, '/'); + const url = split[0]; + if (split.length > 1) { + return _.includes(url, '.') || _.includes(url, ':'); + } + return false; + } + + function removeDigestFromRepository(repository) { return repository.split('@sha')[0]; - }; + } return helper; }]); diff --git a/app/docker/models/config.js b/app/docker/models/config.js index 8f1d83873..56301040c 100644 --- a/app/docker/models/config.js +++ b/app/docker/models/config.js @@ -1,4 +1,4 @@ -import { ResourceControlViewModel } from '../../portainer/models/resourceControl'; +import { ResourceControlViewModel } from 'Portainer/models/resourceControl/resourceControl'; export function ConfigViewModel(data) { this.Id = data.ID; @@ -9,9 +9,7 @@ export function ConfigViewModel(data) { this.Labels = data.Spec.Labels; this.Data = atob(data.Spec.Data); - if (data.Portainer) { - if (data.Portainer.ResourceControl) { - this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); - } + if (data.Portainer && data.Portainer.ResourceControl) { + this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); } } diff --git a/app/docker/models/container.js b/app/docker/models/container.js index c23b7b891..c09ad7748 100644 --- a/app/docker/models/container.js +++ b/app/docker/models/container.js @@ -1,5 +1,5 @@ import _ from 'lodash-es'; -import { ResourceControlViewModel } from '../../portainer/models/resourceControl'; +import { ResourceControlViewModel } from 'Portainer/models/resourceControl/resourceControl'; export function createStatus(statusText) { var status = _.toLower(statusText); @@ -103,9 +103,7 @@ export function ContainerDetailsViewModel(data) { this.Config = data.Config; this.HostConfig = data.HostConfig; this.Mounts = data.Mounts; - if (data.Portainer) { - if (data.Portainer.ResourceControl) { - this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); - } + if (data.Portainer && data.Portainer.ResourceControl) { + this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); } } diff --git a/app/docker/models/network.js b/app/docker/models/network.js index 35e419850..a629eb0d3 100644 --- a/app/docker/models/network.js +++ b/app/docker/models/network.js @@ -1,4 +1,4 @@ -import { ResourceControlViewModel } from "../../portainer/models/resourceControl"; +import { ResourceControlViewModel } from 'Portainer/models/resourceControl/resourceControl'; export function NetworkViewModel(data) { this.Id = data.Id; diff --git a/app/docker/models/porImageRegistry.js b/app/docker/models/porImageRegistry.js new file mode 100644 index 000000000..38ea4ad94 --- /dev/null +++ b/app/docker/models/porImageRegistry.js @@ -0,0 +1,16 @@ +/** + * This model should be used with por-image-registry component + * And bound to the 'model' attribute + * + * // viewController.js + * + * this.imageModel = new PorImageRegistryModel(); + * + * // view.html + * + */ +export function PorImageRegistryModel() { + this.UseRegistry = true; + this.Registry = {}; + this.Image = ''; +} \ No newline at end of file diff --git a/app/docker/models/secret.js b/app/docker/models/secret.js index ccf098420..ecca5f077 100644 --- a/app/docker/models/secret.js +++ b/app/docker/models/secret.js @@ -1,4 +1,4 @@ -import { ResourceControlViewModel } from '../../portainer/models/resourceControl' +import { ResourceControlViewModel } from 'Portainer/models/resourceControl/resourceControl'; export function SecretViewModel(data) { this.Id = data.ID; diff --git a/app/docker/models/service.js b/app/docker/models/service.js index 7bfd68d6c..84f58eed0 100644 --- a/app/docker/models/service.js +++ b/app/docker/models/service.js @@ -1,4 +1,4 @@ -import { ResourceControlViewModel } from '../../portainer/models/resourceControl'; +import { ResourceControlViewModel } from 'Portainer/models/resourceControl/resourceControl'; export function ServiceViewModel(data, runningTasks, allTasks) { this.Model = data; diff --git a/app/docker/models/volume.js b/app/docker/models/volume.js index ae2ac1904..0de34bbc5 100644 --- a/app/docker/models/volume.js +++ b/app/docker/models/volume.js @@ -1,4 +1,4 @@ -import { ResourceControlViewModel } from "../../portainer/models/resourceControl"; +import { ResourceControlViewModel } from 'Portainer/models/resourceControl/resourceControl'; export function VolumeViewModel(data) { this.Id = data.Name; diff --git a/app/docker/rest/commit.js b/app/docker/rest/commit.js index f2cf6dc03..364610297 100644 --- a/app/docker/rest/commit.js +++ b/app/docker/rest/commit.js @@ -5,6 +5,6 @@ angular.module('portainer.docker') endpointId: EndpointProvider.endpointID }, { - commitContainer: {method: 'POST', params: {container: '@id', repo: '@repo', tag: '@tag'}, ignoreLoadingBar: true} + commitContainer: {method: 'POST', params: {container: '@id', repo: '@repo'}, ignoreLoadingBar: true} }); }]); diff --git a/app/docker/rest/image.js b/app/docker/rest/image.js index 928466802..a0cfd0cfc 100644 --- a/app/docker/rest/image.js +++ b/app/docker/rest/image.js @@ -15,16 +15,16 @@ function ImageFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, HttpR search: {method: 'GET', params: {action: 'search'}}, history: {method: 'GET', params: {action: 'history'}, isArray: true}, insert: {method: 'POST', params: {id: '@id', action: 'insert'}}, - tag: {method: 'POST', params: {id: '@id', action: 'tag', force: 0, repo: '@repo', tag: '@tag'}, ignoreLoadingBar: true}, + tag: {method: 'POST', params: {id: '@id', action: 'tag', force: 0, repo: '@repo'}, ignoreLoadingBar: true}, inspect: {method: 'GET', params: {id: '@id', action: 'json'}}, push: { - method: 'POST', params: {action: 'push', id: '@tag'}, + method: 'POST', params: {action: 'push', id: '@imageName'}, isArray: true, transformResponse: jsonObjectsToArrayHandler, headers: { 'X-Registry-Auth': HttpRequestHelper.registryAuthenticationHeader }, ignoreLoadingBar: true }, create: { - method: 'POST', params: {action: 'create', fromImage: '@fromImage', tag: '@tag'}, + method: 'POST', params: {action: 'create', fromImage: '@fromImage'}, isArray: true, transformResponse: jsonObjectsToArrayHandler, headers: { 'X-Registry-Auth': HttpRequestHelper.registryAuthenticationHeader }, ignoreLoadingBar: true diff --git a/app/docker/services/containerService.js b/app/docker/services/containerService.js index cc4cbea87..300a09f91 100644 --- a/app/docker/services/containerService.js +++ b/app/docker/services/containerService.js @@ -107,14 +107,14 @@ function ContainerServiceFactory($q, Container, ResourceControlService, LogHelpe service.createAndStartContainer = function(configuration) { var deferred = $q.defer(); - var containerID; + var container; service.createContainer(configuration) .then(function success(data) { - containerID = data.Id; - return service.startContainer(containerID); + container = data; + return service.startContainer(container.Id); }) .then(function success() { - deferred.resolve({ Id: containerID }); + deferred.resolve(container); }) .catch(function error(err) { deferred.reject(err); @@ -129,13 +129,9 @@ function ContainerServiceFactory($q, Container, ResourceControlService, LogHelpe .then(function success(data) { if (data.message) { deferred.reject({ msg: data.message, err: data.message }); + } else { + deferred.resolve(); } - if (container.ResourceControl && container.ResourceControl.Type === 1) { - return ResourceControlService.deleteResourceControl(container.ResourceControl.Id); - } - }) - .then(function success() { - deferred.resolve(); }) .catch(function error(err) { deferred.reject({ msg: 'Unable to remove container', err: err }); diff --git a/app/docker/services/imageService.js b/app/docker/services/imageService.js index 21bf6dd41..afd70aff3 100644 --- a/app/docker/services/imageService.js +++ b/app/docker/services/imageService.js @@ -77,12 +77,20 @@ angular.module('portainer.docker') return deferred.promise; }; - service.pushImage = function(tag, registry) { + service.pushImage = pushImage; + /** + * + * @param {PorImageRegistryModel} registryModel + */ + function pushImage(registryModel) { var deferred = $q.defer(); - var authenticationDetails = registry.Authentication ? RegistryService.encodedCredentials(registry) : ''; + var authenticationDetails = registryModel.Registry.Authentication ? RegistryService.encodedCredentials(registryModel.Registry) : ''; HttpRequestHelper.setRegistryAuthenticationHeader(authenticationDetails); - Image.push({tag: tag}).$promise + + const imageConfiguration = ImageHelper.createImageConfigForContainer(registryModel); + + Image.push({imageName: imageConfiguration.fromImage}).$promise .then(function success(data) { if (data[data.length - 1].error) { deferred.reject({ msg: data[data.length - 1].error }); @@ -94,7 +102,11 @@ angular.module('portainer.docker') deferred.reject({ msg: 'Unable to push image tag', err: err }); }); return deferred.promise; - }; + } + + /** + * PULL IMAGE + */ function pullImageAndIgnoreErrors(imageConfiguration) { var deferred = $q.defer(); @@ -127,21 +139,31 @@ angular.module('portainer.docker') return deferred.promise; } - service.pullImage = function(image, registry, ignoreErrors) { - var imageDetails = ImageHelper.extractImageAndRegistryFromRepository(image); - var imageConfiguration = ImageHelper.createImageConfigForContainer(imageDetails.image, registry.URL); - var authenticationDetails = registry.Authentication ? RegistryService.encodedCredentials(registry) : ''; + service.pullImage = pullImage; + + /** + * + * @param {PorImageRegistryModel} registry + * @param {bool} ignoreErrors + */ + function pullImage(registry, ignoreErrors) { + var authenticationDetails = registry.Registry.Authentication ? RegistryService.encodedCredentials(registry.Registry) : ''; HttpRequestHelper.setRegistryAuthenticationHeader(authenticationDetails); + var imageConfiguration = ImageHelper.createImageConfigForContainer(registry); + if (ignoreErrors) { return pullImageAndIgnoreErrors(imageConfiguration); } return pullImageAndAcknowledgeErrors(imageConfiguration); - }; + } - service.tagImage = function(id, image, registry) { - var imageConfig = ImageHelper.createImageConfigForCommit(image, registry); - return Image.tag({id: id, tag: imageConfig.tag, repo: imageConfig.repo}).$promise; + /** + * ! PULL IMAGE + */ + + service.tagImage = function(id, image) { + return Image.tag({id: id, repo: image}).$promise; }; service.downloadImages = function(images) { @@ -170,7 +192,8 @@ angular.module('portainer.docker') }; service.getUniqueTagListFromImages = function (availableImages) { - return _.flatten(_.map(availableImages, function (image) { + + return _.uniq(_.flatMap(availableImages, function (image) { _.remove(image.RepoTags, function (item) { return item.indexOf('') !== -1; }); diff --git a/app/docker/services/networkService.js b/app/docker/services/networkService.js index 4031a2d0c..f8400b693 100644 --- a/app/docker/services/networkService.js +++ b/app/docker/services/networkService.js @@ -71,8 +71,16 @@ angular.module('portainer.docker') return Network.disconnect({ id: networkId }, { Container: containerId, Force: force }).$promise; }; - service.connectContainer = function(networkId, containerId) { - return Network.connect({ id: networkId }, { Container: containerId }).$promise; + service.connectContainer = function(networkId, containerId, aliases) { + var payload = { + Container: containerId, + }; + if (aliases) { + payload.EndpointConfig = { + Aliases: aliases, + } + } + return Network.connect({ id: networkId }, payload).$promise; }; return service; diff --git a/app/docker/services/serviceService.js b/app/docker/services/serviceService.js index d3db5f048..95f16d214 100644 --- a/app/docker/services/serviceService.js +++ b/app/docker/services/serviceService.js @@ -43,14 +43,13 @@ function ServiceServiceFactory($q, Service, ServiceHelper, TaskService, Resource var deferred = $q.defer(); Service.remove({id: service.Id}).$promise - .then(function success() { - if (service.ResourceControl && service.ResourceControl.Type === 2) { - return ResourceControlService.deleteResourceControl(service.ResourceControl.Id); + .then(function success(data) { + if (data.message) { + deferred.reject({ msg: data.message, err: data.message }); + } else { + deferred.resolve(); } }) - .then(function success() { - deferred.resolve(); - }) .catch(function error(err) { deferred.reject({ msg: 'Unable to remove service', err: err }); }); diff --git a/app/docker/services/volumeService.js b/app/docker/services/volumeService.js index 5f3aaf31a..1ad5cbb9c 100644 --- a/app/docker/services/volumeService.js +++ b/app/docker/services/volumeService.js @@ -1,7 +1,7 @@ import { VolumeViewModel } from '../models/volume'; angular.module('portainer.docker') -.factory('VolumeService', ['$q', 'Volume', 'VolumeHelper', 'ResourceControlService', function VolumeServiceFactory($q, Volume, VolumeHelper, ResourceControlService) { +.factory('VolumeService', ['$q', 'Volume', 'VolumeHelper', function VolumeServiceFactory($q, Volume, VolumeHelper) { 'use strict'; var service = {}; @@ -45,13 +45,9 @@ angular.module('portainer.docker') .then(function success(data) { if (data.message) { deferred.reject({ msg: data.message, err: data.message }); + } else { + deferred.resolve(); } - if (volume.ResourceControl && volume.ResourceControl.Type === 3) { - return ResourceControlService.deleteResourceControl(volume.ResourceControl.Id); - } - }) - .then(function success() { - deferred.resolve(); }) .catch(function error(err) { deferred.reject({ msg: 'Unable to remove volume', err: err }); diff --git a/app/docker/views/configs/create/createConfigController.js b/app/docker/views/configs/create/createConfigController.js index 8baebf698..316250c47 100644 --- a/app/docker/views/configs/create/createConfigController.js +++ b/app/docker/views/configs/create/createConfigController.js @@ -104,9 +104,9 @@ class CreateConfigController { } async createAsync() { - let accessControlData = this.formValues.AccessControlData; - let userDetails = this.Authentication.getUserDetails(); - let isAdmin = this.Authentication.isAdmin(); + const accessControlData = this.formValues.AccessControlData; + const userDetails = this.Authentication.getUserDetails(); + const isAdmin = this.Authentication.isAdmin(); if (this.formValues.ConfigContent === "") { this.state.formValidationError = "Config content must not be empty"; @@ -117,19 +117,13 @@ class CreateConfigController { return; } - let config = this.prepareConfiguration(); + const config = this.prepareConfiguration(); try { - let data = await this.ConfigService.create(config); - let configIdentifier = data.ID; - let userId = userDetails.ID; - await this.ResourceControlService.applyResourceControl( - "config", - configIdentifier, - userId, - accessControlData, - [] - ); + const data = await this.ConfigService.create(config); + const resourceControl = data.Portainer.ResourceControl; + const userId = userDetails.ID; + await this.ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl); this.Notifications.success("Config successfully created"); this.$state.go("docker.configs", {}, { reload: true }); } catch (err) { diff --git a/app/docker/views/containers/create/createContainerController.js b/app/docker/views/containers/create/createContainerController.js index 73fb8e17b..7c331b7e3 100644 --- a/app/docker/views/containers/create/createContainerController.js +++ b/app/docker/views/containers/create/createContainerController.js @@ -2,11 +2,12 @@ import _ from 'lodash-es'; import { ContainerCapabilities, ContainerCapability } from '../../../models/containerCapabilities'; import { AccessControlFormData } from '../../../../portainer/components/accessControlForm/porAccessControlFormModel'; import { ContainerDetailsViewModel } from '../../../models/container'; +import { PorImageRegistryModel } from 'Docker/models/porImageRegistry'; angular.module('portainer.docker') -.controller('CreateContainerController', ['$q', '$scope', '$state', '$timeout', '$transition$', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'NetworkService', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator', 'ModalService', 'RegistryService', 'SystemService', 'SettingsService', 'PluginService', 'HttpRequestHelper', -function ($q, $scope, $state, $timeout, $transition$, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, NetworkService, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, FormValidator, ModalService, RegistryService, SystemService, SettingsService, PluginService, HttpRequestHelper) { +.controller('CreateContainerController', ['$q', '$scope', '$async', '$state', '$timeout', '$transition$', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'NetworkService', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator', 'ModalService', 'RegistryService', 'SystemService', 'SettingsService', 'PluginService', 'HttpRequestHelper', +function ($q, $scope, $async, $state, $timeout, $transition$, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, NetworkService, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, FormValidator, ModalService, RegistryService, SystemService, SettingsService, PluginService, HttpRequestHelper) { $scope.create = create; @@ -27,7 +28,8 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai NodeName: null, capabilities: [], LogDriverName: '', - LogDriverOpts: [] + LogDriverOpts: [], + RegistryModel: new PorImageRegistryModel() }; $scope.extraNetworks = {}; @@ -130,33 +132,13 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai $scope.fromContainerMultipleNetworks = false; function prepareImageConfig(config) { - var image = config.Image; - var registry = $scope.formValues.Registry; - var imageConfig = ImageHelper.createImageConfigForContainer(image, registry.URL); - config.Image = imageConfig.fromImage + ':' + imageConfig.tag; - $scope.imageConfig = imageConfig; + const imageConfig = ImageHelper.createImageConfigForContainer($scope.formValues.RegistryModel); + config.Image = imageConfig.fromImage; } function preparePortBindings(config) { - var bindings = {}; - if (config.ExposedPorts === undefined) { - config.ExposedPorts = {}; - } - config.HostConfig.PortBindings.forEach(function (portBinding) { - if (portBinding.containerPort) { - var key = portBinding.containerPort + '/' + portBinding.protocol; - var binding = {}; - if (portBinding.hostPort && portBinding.hostPort.indexOf(':') > -1) { - var hostAndPort = portBinding.hostPort.split(':'); - binding.HostIp = hostAndPort[0]; - binding.HostPort = hostAndPort[1]; - } else { - binding.HostPort = portBinding.hostPort; - } - bindings[key] = [binding]; - config.ExposedPorts[key] = {}; - } - }); + const bindings = ContainerHelper.preparePortBindings(config.HostConfig.PortBindings); + _.forEach(bindings, (_, key) => config.ExposedPorts[key] = {}); config.HostConfig.PortBindings = bindings; } @@ -220,13 +202,18 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai } config.HostConfig.NetworkMode = networkMode; config.MacAddress = $scope.formValues.MacAddress; - + config.NetworkingConfig.EndpointsConfig[networkMode] = { IPAMConfig: { IPv4Address: $scope.formValues.IPv4, IPv6Address: $scope.formValues.IPv6 } }; + + if (networkMode && _.get($scope.config.NetworkingConfig.EndpointsConfig[networkMode], 'Aliases')){ + var aliases = $scope.config.NetworkingConfig.EndpointsConfig[networkMode].Aliases; + config.NetworkingConfig.EndpointsConfig[networkMode].Aliases = _.filter(aliases, (o) => { return !_.startsWith($scope.fromContainer.Id,o)}); + } $scope.formValues.ExtraHosts.forEach(function (v) { if (v.value) { @@ -238,8 +225,13 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai function prepareLabels(config) { var labels = {}; $scope.formValues.Labels.forEach(function (label) { - if (label.name && label.value) { - labels[label.name] = label.value; + if (label.name) { + if (label.value) { + labels[label.name] = label.value; + } + else { + labels[label.name] = ''; + } } }); config.Labels = labels; @@ -330,22 +322,7 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai } function loadFromContainerPortBindings() { - var bindings = []; - for (var p in $scope.config.HostConfig.PortBindings) { - if ({}.hasOwnProperty.call($scope.config.HostConfig.PortBindings, p)) { - var hostPort = ''; - if ($scope.config.HostConfig.PortBindings[p][0].HostIp) { - hostPort = $scope.config.HostConfig.PortBindings[p][0].HostIp + ':'; - } - hostPort += $scope.config.HostConfig.PortBindings[p][0].HostPort; - var b = { - 'hostPort': hostPort, - 'containerPort': p.split('/')[0], - 'protocol': p.split('/')[1] - }; - bindings.push(b); - } - } + const bindings = ContainerHelper.sortAndCombinePorts($scope.config.HostConfig.PortBindings); $scope.config.HostConfig.PortBindings = bindings; } @@ -385,7 +362,7 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai var netContainer = $scope.config.HostConfig.NetworkMode.split(/^container:/)[1]; $scope.config.HostConfig.NetworkMode = 'container'; for (var c in $scope.runningContainers) { - if ($scope.runningContainers[c].Names && $scope.runningContainers[c].Names[0] === '/' + netContainer) { + if ($scope.runningContainers[c].Id == netContainer) { $scope.formValues.NetworkContainer = $scope.runningContainers[c]; } } @@ -425,7 +402,7 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai var envArr = []; for (var e in $scope.config.Env) { if ({}.hasOwnProperty.call($scope.config.Env, e)) { - var arr = $scope.config.Env[e].split(/\=(.+)/); + var arr = $scope.config.Env[e].split(/\=(.*)/); envArr.push({'name': arr[0], 'value': arr[1]}); } } @@ -464,13 +441,9 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai } function loadFromContainerImageConfig() { - var imageInfo = ImageHelper.extractImageAndRegistryFromRepository($scope.config.Image); - RegistryService.retrieveRegistryFromRepository($scope.config.Image) - .then(function success(data) { - if (data) { - $scope.config.Image = imageInfo.image; - $scope.formValues.Registry = data; - } + RegistryService.retrievePorRegistryModelFromRepository($scope.config.Image) + .then((model) => { + $scope.formValues.RegistryModel = model; }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrive registry'); @@ -595,7 +568,6 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai loadFromContainerSpec(); } else { $scope.fromContainer = {}; - $scope.formValues.Registry = {}; $scope.formValues.capabilities = new ContainerCapabilities(); } }, function(e) { @@ -780,25 +752,25 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai function pullImageIfNeeded() { return $q.when($scope.formValues.alwaysPull && - ImageService.pullImage($scope.config.Image, $scope.formValues.Registry, true)); + ImageService.pullImage($scope.formValues.RegistryModel, true)); } function createNewContainer() { - var config = prepareConfiguration(); - return ContainerService.createAndStartContainer(config); + return $async(async () => { + const config = prepareConfiguration(); + return await ContainerService.createAndStartContainer(config); + }); } function applyResourceControl(newContainer) { - var containerIdentifier = newContainer.Id; - var userId = Authentication.getUserDetails().ID; + const userId = Authentication.getUserDetails().ID; + const resourceControl = newContainer.Portainer.ResourceControl; + const containerId = newContainer.Id; + const accessControlData = $scope.formValues.AccessControlData; - return $q.when(ResourceControlService.applyResourceControl( - 'container', - containerIdentifier, - userId, - $scope.formValues.AccessControlData, [] - )).then(function onApplyResourceControlSuccess() { - return containerIdentifier; + return ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl) + .then(function onApplyResourceControlSuccess() { + return containerId; }); } @@ -807,9 +779,12 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai return $q.when(); } - var connectionPromises = Object.keys($scope.extraNetworks).map(function (networkName) { - return NetworkService.connectContainer(networkName, newContainerId); - }); + var connectionPromises = _.forOwn($scope.extraNetworks, function (network, networkName) { + if (_.has(network, 'Aliases')) { + var aliases = _.filter(network.Aliases, (o) => { return !_.startsWith($scope.fromContainer.Id,o)}) + } + return NetworkService.connectContainer(networkName, newContainerId, aliases); + }); return $q.all(connectionPromises); } diff --git a/app/docker/views/containers/create/createcontainer.html b/app/docker/views/containers/create/createcontainer.html index a2d688d84..266f6d0d7 100644 --- a/app/docker/views/containers/create/createcontainer.html +++ b/app/docker/views/containers/create/createcontainer.html @@ -21,18 +21,18 @@
Image configuration
-
+
The Docker registry for the {{ config.Image }} image is not registered inside Portainer, you will not be able to create a container. Please register that registry first.
-
+
@@ -68,7 +68,10 @@
- + publish a new network port @@ -79,7 +82,7 @@
host - +
@@ -88,7 +91,7 @@
container - +
@@ -139,7 +142,9 @@
- diff --git a/app/docker/views/containers/edit/container.html b/app/docker/views/containers/edit/container.html index ea1e5fde7..11a927aaa 100644 --- a/app/docker/views/containers/edit/container.html +++ b/app/docker/views/containers/edit/container.html @@ -14,13 +14,13 @@
- +
-
+
+
diff --git a/app/docker/views/containers/edit/containerController.js b/app/docker/views/containers/edit/containerController.js index 12f7ce7ca..f775fbc1e 100644 --- a/app/docker/views/containers/edit/containerController.js +++ b/app/docker/views/containers/edit/containerController.js @@ -1,14 +1,16 @@ import moment from 'moment'; +import { PorImageRegistryModel } from 'Docker/models/porImageRegistry'; angular.module('portainer.docker') -.controller('ContainerController', ['$q', '$scope', '$state','$transition$', '$filter', 'Commit', 'ContainerHelper', 'ContainerService', 'ImageHelper', 'NetworkService', 'Notifications', 'ModalService', 'ResourceControlService', 'RegistryService', 'ImageService', 'HttpRequestHelper', -function ($q, $scope, $state, $transition$, $filter, Commit, ContainerHelper, ContainerService, ImageHelper, NetworkService, Notifications, ModalService, ResourceControlService, RegistryService, ImageService, HttpRequestHelper) { +.controller('ContainerController', ['$q', '$scope', '$state','$transition$', '$filter', '$async', 'ExtensionService', 'Commit', 'ContainerHelper', 'ContainerService', 'ImageHelper', 'NetworkService', 'Notifications', 'ModalService', 'ResourceControlService', 'RegistryService', 'ImageService', 'HttpRequestHelper', 'Authentication', +function ($q, $scope, $state, $transition$, $filter, $async, ExtensionService, Commit, ContainerHelper, ContainerService, ImageHelper, NetworkService, Notifications, ModalService, ResourceControlService, RegistryService, ImageService, HttpRequestHelper, Authentication) { $scope.activityTime = 0; $scope.portBindings = []; + $scope.displayRecreateButton = false; $scope.config = { - Image: '', - Registry: '' + RegistryModel: new PorImageRegistryModel(), + commitInProgress: false }; $scope.state = { @@ -50,6 +52,14 @@ function ($q, $scope, $state, $transition$, $filter, Commit, ContainerHelper, Co } }); } + + const inSwarm = $scope.container.Config.Labels['com.docker.swarm.service.id']; + const autoRemove = $scope.container.HostConfig.AutoRemove; + const admin = Authentication.isAdmin(); + + ExtensionService.extensionEnabled(ExtensionService.EXTENSIONS.RBAC).then((rbacEnabled) => { + $scope.displayRecreateButton = !inSwarm && !autoRemove && (rbacEnabled ? admin : true) + }); }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve container info'); @@ -149,20 +159,23 @@ function ($q, $scope, $state, $transition$, $filter, Commit, ContainerHelper, Co }); }; - $scope.commit = function () { - var image = $scope.config.Image; - $scope.config.Image = ''; - var registry = $scope.config.Registry; - var imageConfig = ImageHelper.createImageConfigForCommit(image, registry.URL); - Commit.commitContainer({id: $transition$.params().id, tag: imageConfig.tag, repo: imageConfig.repo}, function () { - update(); + async function commitContainerAsync() { + $scope.config.commitInProgress = true; + const registryModel = $scope.config.RegistryModel; + const imageConfig = ImageHelper.createImageConfigForContainer(registryModel); + try { + await Commit.commitContainer({id: $transition$.params().id, repo: imageConfig.fromImage}).$promise; Notifications.success('Image created', $transition$.params().id); - }, function (e) { - update(); - Notifications.error('Failure', e, 'Unable to create image'); - }); - }; + $state.reload(); + } catch (err) { + Notifications.error('Failure', err, 'Unable to create image'); + $scope.config.commitInProgress = false; + } + } + $scope.commit = function () { + return $async(commitContainerAsync); + }; $scope.confirmRemove = function () { var title = 'You are about to remove a container.'; @@ -205,7 +218,7 @@ function ($q, $scope, $state, $transition$, $filter, Commit, ContainerHelper, Co .then(setMainNetworkAndCreateContainer) .then(connectContainerToOtherNetworks) .then(startContainerIfNeeded) - .then(createResourceControlIfNeeded) + .then(createResourceControl) .then(deleteOldContainer) .then(notifyAndChangeView) .catch(notifyOnError); @@ -225,15 +238,12 @@ function ($q, $scope, $state, $transition$, $filter, Commit, ContainerHelper, Co if (!pullImage) { return $q.when(); } - return getRegistry().then(function pullImage(containerRegistery) { - return ImageService.pullImage(container.Config.Image, containerRegistery, true); + return RegistryService.retrievePorRegistryModelFromRepository(container.Config.Image) + .then(function pullImage(registryModel) { + return ImageService.pullImage(registryModel, true); }); } - function getRegistry() { - return RegistryService.retrieveRegistryFromRepository(container.Config.Image); - } - function setMainNetworkAndCreateContainer() { var networks = config.NetworkingConfig.EndpointsConfig; var networksNames = Object.keys(networks); @@ -276,19 +286,11 @@ function ($q, $scope, $state, $transition$, $filter, Commit, ContainerHelper, Co ); } - function createResourceControlIfNeeded(newContainer) { - if (!container.ResourceControl) { - return $q.when(); - } - var containerIdentifier = newContainer.Id; - var resourceControl = container.ResourceControl; - var users = resourceControl.UserAccesses.map(function(u) { - return u.UserId; - }); - var teams = resourceControl.TeamAccesses.map(function(t) { - return t.TeamId; - }); - return ResourceControlService.createResourceControl(resourceControl.Public, users, teams, containerIdentifier, 'container', []); + function createResourceControl(newContainer) { + const userId = Authentication.getUserDetails().ID; + const oldResourceControl = container.ResourceControl; + const newResourceControl = newContainer.Portainer.ResourceControl; + return ResourceControlService.duplicateResourceControl(userId, oldResourceControl, newResourceControl); } function notifyAndChangeView() { diff --git a/app/docker/views/images/edit/image.html b/app/docker/views/images/edit/image.html index 8b6641548..5dcc3ba26 100644 --- a/app/docker/views/images/edit/image.html +++ b/app/docker/views/images/edit/image.html @@ -64,9 +64,8 @@
@@ -78,7 +77,7 @@
- +
diff --git a/app/docker/views/images/edit/imageController.js b/app/docker/views/images/edit/imageController.js index f090b6fdc..c13cd72ef 100644 --- a/app/docker/views/images/edit/imageController.js +++ b/app/docker/views/images/edit/imageController.js @@ -1,11 +1,11 @@ import _ from 'lodash-es'; +import { PorImageRegistryModel } from 'Docker/models/porImageRegistry'; angular.module('portainer.docker') -.controller('ImageController', ['$q', '$scope', '$transition$', '$state', '$timeout', 'ImageService', 'RegistryService', 'Notifications', 'HttpRequestHelper', 'ModalService', 'FileSaver', 'Blob', -function ($q, $scope, $transition$, $state, $timeout, ImageService, RegistryService, Notifications, HttpRequestHelper, ModalService, FileSaver, Blob) { +.controller('ImageController', ['$q', '$scope', '$transition$', '$state', '$timeout', 'ImageService', 'ImageHelper', 'RegistryService', 'Notifications', 'HttpRequestHelper', 'ModalService', 'FileSaver', 'Blob', +function ($q, $scope, $transition$, $state, $timeout, ImageService, ImageHelper, RegistryService, Notifications, HttpRequestHelper, ModalService, FileSaver, Blob) { $scope.formValues = { - Image: '', - Registry: '' + RegistryModel: new PorImageRegistryModel() }; $scope.state = { @@ -27,10 +27,11 @@ function ($q, $scope, $transition$, $state, $timeout, ImageService, RegistryServ }; $scope.tagImage = function() { - var image = $scope.formValues.Image; - var registry = $scope.formValues.Registry; + const registryModel = $scope.formValues.RegistryModel; - ImageService.tagImage($transition$.params().id, image, registry.URL) + const image = ImageHelper.createImageConfigForContainer(registryModel); + + ImageService.tagImage($transition$.params().id, image.fromImage) .then(function success() { Notifications.success('Image successfully tagged'); $state.go('docker.images.image', {id: $transition$.params().id}, {reload: true}); @@ -42,10 +43,9 @@ function ($q, $scope, $transition$, $state, $timeout, ImageService, RegistryServ $scope.pushTag = function(repository) { $('#uploadResourceHint').show(); - RegistryService.retrieveRegistryFromRepository(repository) - .then(function success(data) { - var registry = data; - return ImageService.pushImage(repository, registry); + RegistryService.retrievePorRegistryModelFromRepository(repository) + .then(function success(registryModel) { + return ImageService.pushImage(registryModel); }) .then(function success() { Notifications.success('Image successfully pushed', repository); @@ -60,10 +60,9 @@ function ($q, $scope, $transition$, $state, $timeout, ImageService, RegistryServ $scope.pullTag = function(repository) { $('#downloadResourceHint').show(); - RegistryService.retrieveRegistryFromRepository(repository) - .then(function success(data) { - var registry = data; - return ImageService.pullImage(repository, registry, false); + RegistryService.retrievePorRegistryModelFromRepository(repository) + .then(function success(registryModel) { + return ImageService.pullImage(registryModel, false); }) .then(function success() { Notifications.success('Image successfully pulled', repository); diff --git a/app/docker/views/images/images.html b/app/docker/views/images/images.html index d1cfba39a..d8d82e043 100644 --- a/app/docker/views/images/images.html +++ b/app/docker/views/images/images.html @@ -16,18 +16,12 @@
- -
-
- Note: if you don't specify the tag in the image name, latest will be used. -
-
-
Deployment @@ -40,7 +34,7 @@
- diff --git a/app/docker/views/images/imagesController.js b/app/docker/views/images/imagesController.js index 8369d0e67..aed6f8cf6 100644 --- a/app/docker/views/images/imagesController.js +++ b/app/docker/views/images/imagesController.js @@ -1,4 +1,5 @@ import _ from 'lodash-es'; +import { PorImageRegistryModel } from 'Docker/models/porImageRegistry'; angular.module('portainer.docker') .controller('ImagesController', ['$scope', '$state', 'ImageService', 'Notifications', 'ModalService', 'HttpRequestHelper', 'FileSaver', 'Blob', 'EndpointProvider', @@ -9,22 +10,20 @@ function ($scope, $state, ImageService, Notifications, ModalService, HttpRequest }; $scope.formValues = { - Image: '', - Registry: '', + RegistryModel: new PorImageRegistryModel(), NodeName: null }; $scope.pullImage = function() { - var image = $scope.formValues.Image; - var registry = $scope.formValues.Registry; + const registryModel = $scope.formValues.RegistryModel; var nodeName = $scope.formValues.NodeName; HttpRequestHelper.setPortainerAgentTargetHeader(nodeName); $scope.state.actionInProgress = true; - ImageService.pullImage(image, registry, false) + ImageService.pullImage(registryModel, false) .then(function success() { - Notifications.success('Image successfully pulled', image); + Notifications.success('Image successfully pulled', registryModel.Image); $state.reload(); }) .catch(function error(err) { diff --git a/app/docker/views/networks/create/createNetworkController.js b/app/docker/views/networks/create/createNetworkController.js index 615c713ba..a592582a6 100644 --- a/app/docker/views/networks/create/createNetworkController.js +++ b/app/docker/views/networks/create/createNetworkController.js @@ -134,9 +134,10 @@ angular.module('portainer.docker') $scope.state.actionInProgress = true; NetworkService.create(context.networkConfiguration) .then(function success(data) { - var networkIdentifier = data.Id; - var userId = context.userDetails.ID; - return ResourceControlService.applyResourceControl('network', networkIdentifier, userId, context.accessControlData, []); + const userId = context.userDetails.ID; + const accessControlData = context.accessControlData; + const resourceControl = data.Portainer.ResourceControl; + return ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl); }) .then(function success() { Notifications.success('Network successfully created'); diff --git a/app/docker/views/networks/edit/network.html b/app/docker/views/networks/edit/network.html index 74d2078d0..b5ee95ce0 100644 --- a/app/docker/views/networks/edit/network.html +++ b/app/docker/views/networks/edit/network.html @@ -20,7 +20,7 @@ ID {{ network.Id }} - + @@ -55,7 +55,8 @@ ng-if="network && applicationState.application.authentication" resource-id="network.Id" resource-control="network.ResourceControl" - resource-type="'network'"> + resource-type="'network'" + disable-ownership-change="isSystemNetwork()"> diff --git a/app/docker/views/networks/edit/networkController.js b/app/docker/views/networks/edit/networkController.js index 27ec8787e..294c76c5e 100644 --- a/app/docker/views/networks/edit/networkController.js +++ b/app/docker/views/networks/edit/networkController.js @@ -1,8 +1,6 @@ angular.module('portainer.docker') -.controller('NetworkController', ['$scope', '$state', '$transition$', '$filter', 'NetworkService', 'Container', 'Notifications', 'HttpRequestHelper', 'PREDEFINED_NETWORKS', -function ($scope, $state, $transition$, $filter, NetworkService, Container, Notifications, HttpRequestHelper, PREDEFINED_NETWORKS) { - - $scope.network = {}; +.controller('NetworkController', ['$scope', '$state', '$transition$', '$filter', 'NetworkService', 'Container', 'Notifications', 'HttpRequestHelper', 'NetworkHelper', +function ($scope, $state, $transition$, $filter, NetworkService, Container, Notifications, HttpRequestHelper, NetworkHelper) { $scope.removeNetwork = function removeNetwork() { NetworkService.remove($transition$.params().id, $transition$.params().id) @@ -27,8 +25,12 @@ function ($scope, $state, $transition$, $filter, NetworkService, Container, Noti }); }; - $scope.allowRemove = function allowRemove(item) { - return !PREDEFINED_NETWORKS.includes(item.Name); + $scope.isSystemNetwork = function() { + return $scope.network && NetworkHelper.isSystemNetwork($scope.network); + } + + $scope.allowRemove = function() { + return !$scope.isSystemNetwork(); }; function filterContainersInNetwork(network, containers) { diff --git a/app/docker/views/secrets/create/createSecretController.js b/app/docker/views/secrets/create/createSecretController.js index f0fddc55a..b488ed37d 100644 --- a/app/docker/views/secrets/create/createSecretController.js +++ b/app/docker/views/secrets/create/createSecretController.js @@ -59,9 +59,9 @@ function ($scope, $state, Notifications, SecretService, LabelHelper, Authenticat $scope.create = function () { - var accessControlData = $scope.formValues.AccessControlData; - var userDetails = Authentication.getUserDetails(); - var isAdmin = Authentication.isAdmin(); + const accessControlData = $scope.formValues.AccessControlData; + const userDetails = Authentication.getUserDetails(); + const isAdmin = Authentication.isAdmin(); if (!validateForm(accessControlData, isAdmin)) { return; @@ -71,9 +71,9 @@ function ($scope, $state, Notifications, SecretService, LabelHelper, Authenticat var secretConfiguration = prepareConfiguration(); SecretService.create(secretConfiguration) .then(function success(data) { - var secretIdentifier = data.ID; - var userId = userDetails.ID; - return ResourceControlService.applyResourceControl('secret', secretIdentifier, userId, accessControlData, []); + const userId = userDetails.ID; + const resourceControl = data.Portainer.ResourceControl; + return ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl); }) .then(function success() { Notifications.success('Secret successfully created'); diff --git a/app/docker/views/services/create/createServiceController.js b/app/docker/views/services/create/createServiceController.js index d23e5bf77..477fb6f65 100644 --- a/app/docker/views/services/create/createServiceController.js +++ b/app/docker/views/services/create/createServiceController.js @@ -1,5 +1,6 @@ import _ from 'lodash-es'; import { AccessControlFormData } from '../../../../portainer/components/accessControlForm/porAccessControlFormModel'; +import { PorImageRegistryModel } from 'Docker/models/porImageRegistry'; require('./includes/update-restart.html') require('./includes/secret.html') @@ -12,8 +13,7 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, ConfigService, C $scope.formValues = { Name: '', - Image: '', - Registry: {}, + RegistryModel: new PorImageRegistryModel(), Mode: 'replicated', Replicas: 1, Command: '', @@ -160,8 +160,8 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, ConfigService, C }; function prepareImageConfig(config, input) { - var imageConfig = ImageHelper.createImageConfigForContainer(input.Image, input.Registry.URL); - config.TaskTemplate.ContainerSpec.Image = imageConfig.fromImage + ':' + imageConfig.tag; + var imageConfig = ImageHelper.createImageConfigForContainer(input.RegistryModel); + config.TaskTemplate.ContainerSpec.Image = imageConfig.fromImage; } function preparePortsConfig(config, input) { @@ -427,20 +427,18 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, ConfigService, C } function createNewService(config, accessControlData) { - - var registry = $scope.formValues.Registry; - var authenticationDetails = registry.Authentication ? RegistryService.encodedCredentials(registry) : ''; + const registryModel = $scope.formValues.RegistryModel; + var authenticationDetails = registryModel.Registry.Authentication ? RegistryService.encodedCredentials(registryModel.Registry) : ''; HttpRequestHelper.setRegistryAuthenticationHeader(authenticationDetails); - var serviceIdentifier; Service.create(config).$promise .then(function success(data) { - serviceIdentifier = data.ID; - return $q.when($scope.formValues.Webhook && WebhookService.createServiceWebhook(serviceIdentifier, EndpointProvider.endpointID())); - }) - .then(function success() { - var userId = Authentication.getUserDetails().ID; - return ResourceControlService.applyResourceControl('service', serviceIdentifier, userId, accessControlData, []); + const serviceId = data.ID; + const resourceControl = data.Portainer.ResourceControl; + const userId = Authentication.getUserDetails().ID; + const rcPromise = ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl); + const webhookPromise = $q.when($scope.formValues.Webhook && WebhookService.createServiceWebhook(serviceId, EndpointProvider.endpointID())); + return $q.all([rcPromise, webhookPromise]); }) .then(function success() { Notifications.success('Service successfully created'); diff --git a/app/docker/views/services/create/createservice.html b/app/docker/views/services/create/createservice.html index 9c0e98e13..458148975 100644 --- a/app/docker/views/services/create/createservice.html +++ b/app/docker/views/services/create/createservice.html @@ -23,10 +23,9 @@
@@ -126,7 +125,7 @@
- diff --git a/app/docker/views/services/edit/includes/image.html b/app/docker/views/services/edit/includes/image.html new file mode 100644 index 000000000..8aa91c9fd --- /dev/null +++ b/app/docker/views/services/edit/includes/image.html @@ -0,0 +1,32 @@ +
+ + + + + + + + + +

Image modification is disabled while service is updating.

+
+ + + +
+
diff --git a/app/docker/views/services/edit/service.html b/app/docker/views/services/edit/service.html index 13ef42d57..7f0737309 100644 --- a/app/docker/views/services/edit/service.html +++ b/app/docker/views/services/edit/service.html @@ -66,10 +66,7 @@ Image - - - + {{ service.Image }} @@ -118,12 +115,12 @@

@@ -83,6 +114,10 @@
+ order-by="Name" remove-action="removeTags" retag-action="retagAction" + advanced-features-available="short.Images.length > 0" + pagination-action="paginationAction" + loading="state.loading"> +
\ No newline at end of file diff --git a/app/extensions/registry-management/views/repositories/edit/registryRepositoryController.js b/app/extensions/registry-management/views/repositories/edit/registryRepositoryController.js index 03f0b961d..30f9a6c1a 100644 --- a/app/extensions/registry-management/views/repositories/edit/registryRepositoryController.js +++ b/app/extensions/registry-management/views/repositories/edit/registryRepositoryController.js @@ -1,105 +1,335 @@ import _ from 'lodash-es'; +import { RepositoryTagViewModel, RepositoryShortTag } from '../../../models/repositoryTag'; angular.module('portainer.app') - .controller('RegistryRepositoryController', ['$q', '$scope', '$transition$', '$state', 'RegistryV2Service', 'RegistryService', 'ModalService', 'Notifications', - function ($q, $scope, $transition$, $state, RegistryV2Service, RegistryService, ModalService, Notifications) { + .controller('RegistryRepositoryController', ['$q', '$async', '$scope', '$uibModal', '$interval', '$transition$', '$state', 'RegistryServiceSelector', 'RegistryService', 'ModalService', 'Notifications', 'ImageHelper', + function ($q, $async, $scope, $uibModal, $interval, $transition$, $state, RegistryServiceSelector, RegistryService, ModalService, Notifications, ImageHelper) { $scope.state = { - actionInProgress: false + actionInProgress: false, + loading: false, + tagsRetrieval: { + auto: true, + running: false, + limit: 100, + progression: 0, + elapsedTime: 0, + asyncGenerator: null, + clock: null + }, + tagsRetag: { + running: false, + progression: 0, + elapsedTime: 0, + asyncGenerator: null, + clock: null + }, + tagsDelete: { + running: false, + progression: 0, + elapsedTime: 0, + asyncGenerator: null, + clock: null + }, }; $scope.formValues = { - Tag: '' + Tag: '' // new tag name on add feature + }; + $scope.tags = []; // RepositoryTagViewModel (for datatable) + $scope.short = { + Tags: [], // RepositoryShortTag + Images: [] // strings extracted from short.Tags }; - $scope.tags = []; $scope.repository = { - Name: [], - Tags: [], - Images: [] + Name: '', + Tags: [], // string list }; - $scope.$watch('tags.length', function () { - var images = $scope.tags.map(function (item) { - return item.ImageId; + function toSeconds(time) { + return time / 1000; + } + function toPercent(progress, total) { + return (progress / total * 100).toFixed(); + } + + function openModal(resolve) { + return $uibModal.open({ + component: 'progressionModal', + backdrop: 'static', + keyboard: false, + resolve: resolve }); - $scope.repository.Images = _.uniq(images); - }); + } + + $scope.paginationAction = function (tags) { + $scope.state.loading = true; + RegistryServiceSelector.getTagsDetails($scope.registry, $scope.repository.Name, tags) + .then(function success(data) { + for (var i = 0; i < data.length; i++) { + var idx = _.findIndex($scope.tags, {'Name': data[i].Name}); + if (idx !== -1) { + $scope.tags[idx] = data[i]; + } + } + $scope.state.loading = false; + }).catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve tags details'); + }); + }; + + /** + * RETRIEVAL SECTION + */ + function updateRetrievalClock(startTime) { + $scope.state.tagsRetrieval.elapsedTime = toSeconds(Date.now() - startTime); + } + + function createRetrieveAsyncGenerator() { + $scope.state.tagsRetrieval.asyncGenerator = + RegistryServiceSelector.shortTagsWithProgress($scope.registry, $scope.repository.Name, $scope.repository.Tags); + } + + function resetTagsRetrievalState() { + $scope.state.tagsRetrieval.running = false; + $scope.state.tagsRetrieval.progression = 0; + $scope.state.tagsRetrieval.elapsedTime = 0; + $scope.state.tagsRetrieval.clock = null; + } + + function computeImages() { + const images = _.map($scope.short.Tags, 'ImageId'); + $scope.short.Images = _.without(_.uniq(images), ''); + } + + $scope.startStopRetrieval = function () { + if ($scope.state.tagsRetrieval.running) { + $scope.state.tagsRetrieval.asyncGenerator.return(); + $interval.cancel($scope.state.tagsRetrieval.clock); + } else { + retrieveTags().then(() => { + createRetrieveAsyncGenerator(); + if ($scope.short.Tags.length === 0) { + resetTagsRetrievalState(); + } else { + computeImages(); + } + }); + } + }; + + function retrieveTags() { + return $async(retrieveTagsAsync); + } + + async function retrieveTagsAsync() { + $scope.state.tagsRetrieval.running = true; + const startTime = Date.now(); + $scope.state.tagsRetrieval.clock = $interval(updateRetrievalClock, 1000, 0, true, startTime); + for await (const partialResult of $scope.state.tagsRetrieval.asyncGenerator) { + if (typeof partialResult === 'number') { + $scope.state.tagsRetrieval.progression = toPercent(partialResult, $scope.repository.Tags.length); + } else { + $scope.short.Tags = _.sortBy(partialResult, 'Name'); + } + } + $scope.state.tagsRetrieval.running = false; + $interval.cancel($scope.state.tagsRetrieval.clock); + } + /** + * !END RETRIEVAL SECTION + */ + + /** + * ADD TAG SECTION + */ + + async function addTagAsync() { + try { + $scope.state.actionInProgress = true; + if (!ImageHelper.isValidTag($scope.formValues.Tag)) { + throw {msg: 'Invalid tag pattern, see info for more details on format.'} + } + const tag = $scope.short.Tags.find((item) => item.ImageId === $scope.formValues.SelectedImage); + const manifest = tag.ManifestV2; + await RegistryServiceSelector.addTag($scope.registry, $scope.repository.Name, $scope.formValues.Tag, manifest) + + Notifications.success('Success', 'Tag successfully added'); + $scope.short.Tags.push(new RepositoryShortTag($scope.formValues.Tag, tag.ImageId, tag.ImageDigest, tag.ManifestV2)); + + await loadRepositoryDetails(); + $scope.formValues.Tag = ''; + delete $scope.formValues.SelectedImage; + } catch (err) { + Notifications.error('Failure', err, 'Unable to add tag'); + } finally { + $scope.state.actionInProgress = false; + } + } $scope.addTag = function () { - var manifest = $scope.tags.find(function (item) { - return item.ImageId === $scope.formValues.SelectedImage; - }).ManifestV2; - RegistryV2Service.addTag($scope.registryId, $scope.repository.Name, $scope.formValues.Tag, manifest) - .then(function success() { - Notifications.success('Success', 'Tag successfully added'); - $state.reload(); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to add tag'); - }); + return $async(addTagAsync); }; + /** + * !END ADD TAG SECTION + */ - $scope.retagAction = function (tag) { - RegistryV2Service.deleteManifest($scope.registryId, $scope.repository.Name, tag.Digest) - .then(function success() { - var promises = []; - var tagsToAdd = $scope.tags.filter(function (item) { - return item.Digest === tag.Digest; - }); - tagsToAdd.map(function (item) { - var tagValue = item.Modified && item.Name !== item.NewName ? item.NewName : item.Name; - promises.push(RegistryV2Service.addTag($scope.registryId, $scope.repository.Name, tagValue, item.ManifestV2)); - }); - return $q.all(promises); - }) - .then(function success() { - Notifications.success('Success', 'Tag successfully modified'); - $state.reload(); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to modify tag'); - tag.Modified = false; - tag.NewValue = tag.Value; + /** + * RETAG SECTION + */ + function updateRetagClock(startTime) { + $scope.state.tagsRetag.elapsedTime = toSeconds(Date.now() - startTime); + } + + function createRetagAsyncGenerator(modifiedTags, modifiedDigests, impactedTags) { + $scope.state.tagsRetag.asyncGenerator = + RegistryServiceSelector.retagWithProgress($scope.registry, $scope.repository.Name, modifiedTags, modifiedDigests, impactedTags); + } + + async function retagActionAsync() { + let modal = null; + try { + $scope.state.tagsRetag.running = true; + + const modifiedTags = _.filter($scope.tags, (item) => item.Modified === true); + for (const tag of modifiedTags) { + if (!ImageHelper.isValidTag(tag.NewName)) { + throw {msg: 'Invalid tag pattern, see info for more details on format.'} + } + } + modal = await openModal({ + message: () => 'Retag is in progress! Closing your browser or refreshing the page while this operation is in progress will result in loss of tags.', + progressLabel: () => 'Retag progress', + context: () => $scope.state.tagsRetag }); - }; + const modifiedDigests = _.uniq(_.map(modifiedTags, 'ImageDigest')); + const impactedTags = _.filter($scope.short.Tags, (item) => _.includes(modifiedDigests, item.ImageDigest)); - $scope.removeTags = function (selectedItems) { + const totalOps = modifiedDigests.length + impactedTags.length; + + createRetagAsyncGenerator(modifiedTags, modifiedDigests, impactedTags); + + const startTime = Date.now(); + $scope.state.tagsRetag.clock = $interval(updateRetagClock, 1000, 0, true, startTime); + for await (const partialResult of $scope.state.tagsRetag.asyncGenerator) { + if (typeof partialResult === 'number') { + $scope.state.tagsRetag.progression = toPercent(partialResult, totalOps); + } + } + + _.map(modifiedTags, (item) => { + const idx = _.findIndex($scope.short.Tags, (i) => i.Name === item.Name); + $scope.short.Tags[idx].Name = item.NewName; + }); + + Notifications.success('Success', 'Tags successfully renamed'); + + await loadRepositoryDetails(); + } catch (err) { + Notifications.error('Failure', err, 'Unable to rename tags'); + } finally { + $interval.cancel($scope.state.tagsRetag.clock); + $scope.state.tagsRetag.running = false; + if (modal) { + modal.close(); + } + } + } + + $scope.retagAction = function() { + return $async(retagActionAsync); + } + /** + * !END RETAG SECTION + */ + + /** + * REMOVE TAGS SECTION + */ + + function updateDeleteClock(startTime) { + $scope.state.tagsDelete.elapsedTime = toSeconds(Date.now() - startTime); + } + + function createDeleteAsyncGenerator(modifiedDigests, impactedTags) { + $scope.state.tagsDelete.asyncGenerator = + RegistryServiceSelector.deleteTagsWithProgress($scope.registry, $scope.repository.Name, modifiedDigests, impactedTags); + } + + async function removeTagsAsync(selectedTags) { + let modal = null; + try { + $scope.state.tagsDelete.running = true; + modal = await openModal({ + message: () => 'Tag delete is in progress! Closing your browser or refreshing the page while this operation is in progress will result in loss of tags.', + progressLabel: () => 'Deletion progress', + context: () => $scope.state.tagsDelete + }); + + const deletedTagNames = _.map(selectedTags, 'Name'); + const deletedShortTags = _.filter($scope.short.Tags, (item) => _.includes(deletedTagNames, item.Name)); + const modifiedDigests = _.uniq(_.map(deletedShortTags, 'ImageDigest')); + const impactedTags = _.filter($scope.short.Tags, (item) => _.includes(modifiedDigests, item.ImageDigest)); + const tagsToKeep = _.without(impactedTags, ...deletedShortTags); + + const totalOps = modifiedDigests.length + tagsToKeep.length; + + createDeleteAsyncGenerator(modifiedDigests, tagsToKeep); + + const startTime = Date.now(); + $scope.state.tagsDelete.clock = $interval(updateDeleteClock, 1000, 0, true, startTime); + for await (const partialResult of $scope.state.tagsDelete.asyncGenerator) { + if (typeof partialResult === 'number') { + $scope.state.tagsDelete.progression = toPercent(partialResult, totalOps); + } + } + + _.pull($scope.short.Tags, ...deletedShortTags); + $scope.short.Images = _.map(_.uniqBy($scope.short.Tags, 'ImageId'), 'ImageId'); + + Notifications.success('Success', 'Tags successfully deleted'); + + if ($scope.short.Tags.length === 0) { + $state.go('portainer.registries.registry.repositories', {id: $scope.registry.Id}, {reload: true}); + } + await loadRepositoryDetails(); + } catch (err) { + Notifications.error('Failure', err, 'Unable to delete tags'); + } finally { + $interval.cancel($scope.state.tagsDelete.clock); + $scope.state.tagsDelete.running = false; + modal.close(); + } + } + + $scope.removeTags = function(selectedItems) { ModalService.confirmDeletion( 'Are you sure you want to remove the selected tags ?', - function onConfirm(confirmed) { + (confirmed) => { if (!confirmed) { return; } - var promises = []; - var uniqItems = _.uniqBy(selectedItems, 'Digest'); - uniqItems.map(function (item) { - promises.push(RegistryV2Service.deleteManifest($scope.registryId, $scope.repository.Name, item.Digest)); - }); - $q.all(promises) - .then(function success() { - var promises = []; - var tagsToReupload = _.differenceBy($scope.tags, selectedItems, 'Name'); - tagsToReupload.map(function (item) { - promises.push(RegistryV2Service.addTag($scope.registryId, $scope.repository.Name, item.Name, item.ManifestV2)); - }); - return $q.all(promises); - }) - .then(function success(data) { - Notifications.success('Success', 'Tags successfully deleted'); - if (data.length === 0) { - $state.go('portainer.registries.registry.repositories', { - id: $scope.registryId - }, { - reload: true - }); - } else { - $state.reload(); - } - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to delete tags'); - }); + return $async(removeTagsAsync, selectedItems); }); - }; + } + /** + * !END REMOVE TAGS SECTION + */ + + /** + * REMOVE REPOSITORY SECTION + */ + async function removeRepositoryAsync() { + try { + const digests = _.uniqBy($scope.short.Tags, 'ImageDigest'); + const promises = []; + _.map(digests, (item) => promises.push(RegistryServiceSelector.deleteManifest($scope.registry, $scope.repository.Name, item.ImageDigest))); + await Promise.all(promises); + Notifications.success('Success', 'Repository sucessfully removed'); + $state.go('portainer.registries.registry.repositories', {id: $scope.registry.Id}, {reload: true}); + } catch (err) { + Notifications.error('Failure', err, 'Unable to delete repository'); + } + } $scope.removeRepository = function () { ModalService.confirmDeletion( @@ -108,53 +338,81 @@ angular.module('portainer.app') if (!confirmed) { return; } - var promises = []; - var uniqItems = _.uniqBy($scope.tags, 'Digest'); - uniqItems.map(function (item) { - promises.push(RegistryV2Service.deleteManifest($scope.registryId, $scope.repository.Name, item.Digest)); - }); - $q.all(promises) - .then(function success() { - Notifications.success('Success', 'Repository sucessfully removed'); - $state.go('portainer.registries.registry.repositories', { - id: $scope.registryId - }, { - reload: true - }); - }).catch(function error(err) { - Notifications.error('Failure', err, 'Unable to delete repository'); - }); + return $async(removeRepositoryAsync); } ); }; + /** + * !END REMOVE REPOSITORY SECTION + */ - function initView() { - var registryId = $scope.registryId = $transition$.params().id; - var repository = $scope.repository.Name = $transition$.params().repository; - $q.all({ - registry: RegistryService.registry(registryId), - tags: RegistryV2Service.tags(registryId, repository) - }) - .then(function success(data) { - $scope.registry = data.registry; - $scope.repository.Tags = [].concat(data.tags || []); - $scope.tags = []; - for (var i = 0; i < $scope.repository.Tags.length; i++) { - var tag = data.tags[i]; - RegistryV2Service.tag(registryId, repository, tag) - .then(function success(data) { - $scope.tags.push(data); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve tag information'); - }); - } - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve repository information'); - }); + /** + * INIT SECTION + */ + async function loadRepositoryDetails() { + try { + const registry = $scope.registry; + const repository = $scope.repository.Name; + const tags = await RegistryServiceSelector.tags(registry, repository); + $scope.tags = []; + $scope.repository.Tags = []; + $scope.repository.Tags = _.sortBy(_.concat($scope.repository.Tags, _.without(tags.tags, null))); + _.map($scope.repository.Tags, (item) => $scope.tags.push(new RepositoryTagViewModel(item))); + } catch (err) { + Notifications.error('Failure', err, 'Unable to retrieve tags details'); + } } - initView(); + async function initView() { + try { + const registryId = $transition$.params().id; + $scope.repository.Name = $transition$.params().repository; + $scope.state.loading = true; + + $scope.registry = await RegistryService.registry(registryId); + await loadRepositoryDetails(); + if ($scope.repository.Tags.length > $scope.state.tagsRetrieval.limit) { + $scope.state.tagsRetrieval.auto = false; + } + createRetrieveAsyncGenerator(); + } catch (err) { + Notifications.error('Failure', err, 'Unable to retrieve repository information'); + } finally { + $scope.state.loading = false; + } + } + + $scope.$on('$destroy', () => { + if ($scope.state.tagsRetrieval.asyncGenerator) { + $scope.state.tagsRetrieval.asyncGenerator.return(); + } + if ($scope.state.tagsRetrieval.clock) { + $interval.cancel($scope.state.tagsRetrieval.clock); + } + if ($scope.state.tagsRetag.asyncGenerator) { + $scope.state.tagsRetag.asyncGenerator.return(); + } + if ($scope.state.tagsRetag.clock) { + $interval.cancel($scope.state.tagsRetag.clock); + } + if ($scope.state.tagsDelete.asyncGenerator) { + $scope.state.tagsDelete.asyncGenerator.return(); + } + if ($scope.state.tagsDelete.clock) { + $interval.cancel($scope.state.tagsDelete.clock); + } + }); + + this.$onInit = function() { + return $async(initView) + .then(() => { + if ($scope.state.tagsRetrieval.auto) { + $scope.startStopRetrieval(); + } + }); + }; + /** + * !END INIT SECTION + */ } - ]); + ]); \ No newline at end of file diff --git a/app/extensions/registry-management/views/repositories/registryRepositories.html b/app/extensions/registry-management/views/repositories/registryRepositories.html index 5e3210ca7..ec145445a 100644 --- a/app/extensions/registry-management/views/repositories/registryRepositories.html +++ b/app/extensions/registry-management/views/repositories/registryRepositories.html @@ -5,7 +5,7 @@ - Registries > {{ registry.Name }}{{ registry.Name}} > Repositories + Registries > {{ registry.Name }}{{ registry.Name}} > Repositories @@ -31,7 +31,7 @@ + order-by="Name" pagination-action="paginationAction" loading="state.loading">
diff --git a/app/extensions/registry-management/views/repositories/registryRepositoriesController.js b/app/extensions/registry-management/views/repositories/registryRepositoriesController.js index 0ff931eff..aed77e343 100644 --- a/app/extensions/registry-management/views/repositories/registryRepositoriesController.js +++ b/app/extensions/registry-management/views/repositories/registryRepositoriesController.js @@ -1,13 +1,41 @@ +import _ from 'lodash-es'; + +import { RegistryTypes } from 'Extensions/registry-management/models/registryTypes'; + angular.module('portainer.extensions.registrymanagement') -.controller('RegistryRepositoriesController', ['$transition$', '$scope', 'RegistryService', 'RegistryV2Service', 'Notifications', 'Authentication', -function ($transition$, $scope, RegistryService, RegistryV2Service, Notifications, Authentication) { +.controller('RegistryRepositoriesController', ['$transition$', '$scope', 'RegistryService', 'RegistryServiceSelector', 'Notifications', 'Authentication', +function ($transition$, $scope, RegistryService, RegistryServiceSelector, Notifications, Authentication) { $scope.state = { - displayInvalidConfigurationMessage: false + displayInvalidConfigurationMessage: false, + loading: false + }; + + $scope.paginationAction = function (repositories) { + if ($scope.registry.Type === RegistryTypes.GITLAB) { + return; + } + $scope.state.loading = true; + RegistryServiceSelector.getRepositoriesDetails($scope.registry, repositories) + .then(function success(data) { + for (var i = 0; i < data.length; i++) { + var idx = _.findIndex($scope.repositories, {'Name': data[i].Name}); + if (idx !== -1) { + if (data[i].TagsCount === 0) { + $scope.repositories.splice(idx, 1); + } else { + $scope.repositories[idx].TagsCount = data[i].TagsCount; + } + } + } + $scope.state.loading = false; + }).catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve repositories details'); + }); }; function initView() { - var registryId = $transition$.params().id; + const registryId = $transition$.params().id; var authenticationEnabled = $scope.applicationState.application.authentication; if (authenticationEnabled) { @@ -17,10 +45,9 @@ function ($transition$, $scope, RegistryService, RegistryV2Service, Notification RegistryService.registry(registryId) .then(function success(data) { $scope.registry = data; - - RegistryV2Service.ping(registryId, false) + RegistryServiceSelector.ping($scope.registry, false) .then(function success() { - return RegistryV2Service.repositories(registryId); + return RegistryServiceSelector.repositories($scope.registry); }) .then(function success(data) { $scope.repositories = data; diff --git a/app/extensions/registry-management/views/repositories/tag/registryRepositoryTag.html b/app/extensions/registry-management/views/repositories/tag/registryRepositoryTag.html new file mode 100644 index 000000000..69316f24e --- /dev/null +++ b/app/extensions/registry-management/views/repositories/tag/registryRepositoryTag.html @@ -0,0 +1,175 @@ + + + + + + + + Registries > + {{ ctrl.registry.Name }} > + {{ ctrl.context.repository }} > + {{ ctrl.context.tag }} + + + +
+
+ + + +
+
+
+
+
+ {{ tag }} +
+
+
+
+
+
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
ID + {{ ctrl.details.Id }} +
Parent{{ ctrl.details.Parent }}
Created{{ ctrl.details.Created|getisodate }}
BuildDocker {{ ctrl.details.DockerVersion }} on {{ ctrl.details.Os}}, {{ ctrl.details.Architecture }}
Author{{ ctrl.details.Author }}
+
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
CMD{{ ctrl.details.Command|command }}
ENTRYPOINT{{ ctrl.details.Entrypoint|command }}
EXPOSE + + {{ port }} + +
VOLUME + + {{ volume }} + +
ENV + + + + + +
{{ var|key: '=' }}{{ var|value: '=' }}
+
+
+
+
+ +
+ +
+
+ + + + + + + + + + + + + + +
+ + Order + + + + + + Layer + + + +
+ {{ layer.Order }} + +
+ + + {{ layer.CreatedBy | truncate:130 }} + + + + + + +
+
+ + {{ layer.CreatedBy }} + +
+
+
+
+
+
\ No newline at end of file diff --git a/app/extensions/registry-management/views/repositories/tag/registryRepositoryTagController.js b/app/extensions/registry-management/views/repositories/tag/registryRepositoryTagController.js new file mode 100644 index 000000000..9136302ae --- /dev/null +++ b/app/extensions/registry-management/views/repositories/tag/registryRepositoryTagController.js @@ -0,0 +1,57 @@ +import _ from 'lodash-es'; +import angular from 'angular'; +import { RegistryImageLayerViewModel } from 'Extensions/registry-management/models/registryImageLayer'; +import { RegistryImageDetailsViewModel } from 'Extensions/registry-management/models/registryImageDetails'; + +class RegistryRepositoryTagController { + + /* @ngInject */ + constructor($transition$, $async, Notifications, RegistryService, RegistryServiceSelector, imagelayercommandFilter) { + this.$transition$ = $transition$; + this.$async = $async; + this.Notifications = Notifications; + this.RegistryService = RegistryService; + this.RegistryServiceSelector = RegistryServiceSelector; + this.imagelayercommandFilter = imagelayercommandFilter; + + this.context = {}; + this.onInit = this.onInit.bind(this); + } + + toggleLayerCommand(layerId) { + $('#layer-command-expander'+layerId+' span').toggleClass('glyphicon-plus-sign glyphicon-minus-sign'); + $('#layer-command-'+layerId+'-short').toggle(); + $('#layer-command-'+layerId+'-full').toggle(); + } + + order(sortType) { + this.Sort.Reverse = (this.Sort.Type === sortType) ? !this.Sort.Reverse : false; + this.Sort.Type = sortType; + } + + async onInit() { + this.context.registryId = this.$transition$.params().id; + this.context.repository = this.$transition$.params().repository; + this.context.tag = this.$transition$.params().tag; + this.Sort = { + Type: 'Order', + Reverse: false + } + try { + this.registry = await this.RegistryService.registry(this.context.registryId); + this.tag = await this.RegistryServiceSelector.tag(this.registry, this.context.repository, this.context.tag); + const length = this.tag.History.length; + this.history = _.map(this.tag.History, (layer, idx) => new RegistryImageLayerViewModel(length - idx, layer)); + _.forEach(this.history, (item) => item.CreatedBy = this.imagelayercommandFilter(item.CreatedBy)) + this.details = new RegistryImageDetailsViewModel(this.tag.History[0]); + } catch (error) { + this.Notifications.error('Failure', error, 'Unable to retrieve tag') + } + } + + $onInit() { + return this.$async(this.onInit); + } +} +export default RegistryRepositoryTagController; +angular.module('portainer.extensions.registrymanagement').controller('RegistryRepositoryTagController', RegistryRepositoryTagController); diff --git a/app/libraries/isteven-angular-multiselect/.npmignore b/app/libraries/isteven-angular-multiselect/.npmignore deleted file mode 100644 index 84a23b94d..000000000 --- a/app/libraries/isteven-angular-multiselect/.npmignore +++ /dev/null @@ -1,8 +0,0 @@ -.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 deleted file mode 100644 index 6e524fa92..000000000 --- a/app/libraries/isteven-angular-multiselect/LICENSE.txt +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 9c6255bcf..000000000 --- a/app/libraries/isteven-angular-multiselect/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# 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 deleted file mode 100644 index 44dfc95f5..000000000 --- a/app/libraries/isteven-angular-multiselect/isteven-multi-select.css +++ /dev/null @@ -1,299 +0,0 @@ -/* - * 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 deleted file mode 100644 index 02b136aa3..000000000 --- a/app/libraries/isteven-angular-multiselect/isteven-multi-select.js +++ /dev/null @@ -1,1127 +0,0 @@ -/* - * 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 deleted file mode 100644 index 9aa2e3960..000000000 --- a/app/libraries/isteven-angular-multiselect/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "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 5d96da47a..0d61052b2 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -1,3 +1,30 @@ +import _ from 'lodash-es'; + +async function initAuthentication(authManager, Authentication, $rootScope, $state) { + authManager.checkAuthOnRefresh(); + // The unauthenticated event is broadcasted by the jwtInterceptor when + // hitting a 401. We're using this instead of the usual combination of + // authManager.redirectWhenUnauthenticated() + unauthenticatedRedirector + // to have more controls on which URL should trigger the unauthenticated state. + $rootScope.$on('unauthenticated', function (event, data) { + if (!_.includes(data.config.url, '/v2/') && !_.includes(data.config.url, '/api/v4/')) { + $state.go('portainer.auth', { error: 'Your session has expired' }); + } + }); + + await Authentication.init(); +} + +function initAnalytics(Analytics, $rootScope) { + Analytics.offline(false); + Analytics.registerScriptTags(); + Analytics.registerTrackers(); + $rootScope.$on('$stateChangeSuccess', function (event, toState) { + Analytics.trackPage(toState.url); + Analytics.pageView(); + }); +} + angular.module('portainer.app', []) .config(['$stateRegistryProvider', function ($stateRegistryProvider) { 'use strict'; @@ -6,10 +33,30 @@ angular.module('portainer.app', []) name: 'root', abstract: true, resolve: { - requiresLogin: ['StateManager', function (StateManager) { - var applicationState = StateManager.getState(); - return applicationState.application.authentication; - }] + initStateManager: ['StateManager', 'Authentication', 'Notifications', 'Analytics', 'authManager', '$rootScope', '$state', '$async', '$q', + (StateManager, Authentication, Notifications, Analytics, authManager, $rootScope, $state, $async, $q) => { + const deferred = $q.defer(); + const appState = StateManager.getState(); + if (!appState.loading) { + deferred.resolve(); + } else { + StateManager.initialize() + .then(function success(state) { + if (state.application.analytics) { + initAnalytics(Analytics, $rootScope); + } + if (state.application.authentication) { + return $async(initAuthentication, authManager, Authentication, $rootScope, $state); + } + }) + .then(() => deferred.resolve()) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve application settings'); + deferred.reject(err); + }); + } + return deferred.promise; + }] }, views: { 'sidebar@': { @@ -60,9 +107,6 @@ angular.module('portainer.app', []) controllerAs: 'ctrl' }, 'sidebar@': {} - }, - data: { - requiresLogin: false } }; @@ -170,9 +214,6 @@ angular.module('portainer.app', []) name: 'portainer.init', abstract: true, url: '/init', - data: { - requiresLogin: false - }, views: { 'sidebar@': {} } diff --git a/app/portainer/components/accessControlForm/porAccessControlForm.html b/app/portainer/components/accessControlForm/porAccessControlForm.html index 9b62663fc..2dbc533c9 100644 --- a/app/portainer/components/accessControlForm/porAccessControlForm.html +++ b/app/portainer/components/accessControlForm/porAccessControlForm.html @@ -71,7 +71,7 @@
-
+
-
+