diff --git a/api/http/handler/dockerhub.go b/api/http/handler/dockerhub.go index a5ff36b57..75acc0517 100644 --- a/api/http/handler/dockerhub.go +++ b/api/http/handler/dockerhub.go @@ -52,6 +52,8 @@ func (handler *DockerHubHandler) handleGetDockerHub(w http.ResponseWriter, r *ht return } + dockerhub.Password = "" + encodeJSON(w, dockerhub, handler.Logger) return } diff --git a/api/http/handler/registry.go b/api/http/handler/registry.go index 9afeb1178..37cb2c971 100644 --- a/api/http/handler/registry.go +++ b/api/http/handler/registry.go @@ -91,6 +91,10 @@ func (handler *RegistryHandler) handleGetRegistries(w http.ResponseWriter, r *ht return } + for i := range filteredRegistries { + filteredRegistries[i].Password = "" + } + encodeJSON(w, filteredRegistries, handler.Logger) } @@ -159,6 +163,8 @@ func (handler *RegistryHandler) handleGetRegistry(w http.ResponseWriter, r *http return } + registry.Password = "" + encodeJSON(w, registry, handler.Logger) } diff --git a/api/http/proxy/factory.go b/api/http/proxy/factory.go index 1602f9d8d..91015367c 100644 --- a/api/http/proxy/factory.go +++ b/api/http/proxy/factory.go @@ -15,6 +15,8 @@ type proxyFactory struct { ResourceControlService portainer.ResourceControlService TeamMembershipService portainer.TeamMembershipService SettingsService portainer.SettingsService + RegistryService portainer.RegistryService + DockerHubService portainer.DockerHubService } func (factory *proxyFactory) newExtensionHTTPPRoxy(u *url.URL) http.Handler { @@ -45,6 +47,8 @@ func (factory *proxyFactory) newDockerSocketProxy(path string) http.Handler { ResourceControlService: factory.ResourceControlService, TeamMembershipService: factory.TeamMembershipService, SettingsService: factory.SettingsService, + RegistryService: factory.RegistryService, + DockerHubService: factory.DockerHubService, dockerTransport: newSocketTransport(path), } proxy.Transport = transport @@ -57,6 +61,8 @@ func (factory *proxyFactory) createDockerReverseProxy(u *url.URL) *httputil.Reve ResourceControlService: factory.ResourceControlService, TeamMembershipService: factory.TeamMembershipService, SettingsService: factory.SettingsService, + RegistryService: factory.RegistryService, + DockerHubService: factory.DockerHubService, dockerTransport: &http.Transport{}, } proxy.Transport = transport diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go index a5b57a535..747c7870a 100644 --- a/api/http/proxy/manager.go +++ b/api/http/proxy/manager.go @@ -17,7 +17,7 @@ type Manager struct { } // NewManager initializes a new proxy Service -func NewManager(resourceControlService portainer.ResourceControlService, teamMembershipService portainer.TeamMembershipService, settingsService portainer.SettingsService) *Manager { +func NewManager(resourceControlService portainer.ResourceControlService, teamMembershipService portainer.TeamMembershipService, settingsService portainer.SettingsService, registryService portainer.RegistryService, dockerHubService portainer.DockerHubService) *Manager { return &Manager{ proxies: cmap.New(), extensionProxies: cmap.New(), @@ -25,6 +25,8 @@ func NewManager(resourceControlService portainer.ResourceControlService, teamMem ResourceControlService: resourceControlService, TeamMembershipService: teamMembershipService, SettingsService: settingsService, + RegistryService: registryService, + DockerHubService: dockerHubService, }, } } diff --git a/api/http/proxy/registry.go b/api/http/proxy/registry.go new file mode 100644 index 000000000..5edeb73b7 --- /dev/null +++ b/api/http/proxy/registry.go @@ -0,0 +1,37 @@ +package proxy + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/http/security" +) + +func createRegistryAuthenticationHeader(serverAddress string, accessContext *registryAccessContext) *registryAuthenticationHeader { + var authenticationHeader *registryAuthenticationHeader + + if serverAddress == "" { + authenticationHeader = ®istryAuthenticationHeader{ + Username: accessContext.dockerHub.Username, + Password: accessContext.dockerHub.Password, + Serveraddress: "docker.io", + } + } else { + var matchingRegistry *portainer.Registry + for _, registry := range accessContext.registries { + if registry.URL == serverAddress && + (accessContext.isAdmin || (!accessContext.isAdmin && security.AuthorizedRegistryAccess(®istry, accessContext.userID, accessContext.teamMemberships))) { + matchingRegistry = ®istry + break + } + } + + if matchingRegistry != nil { + authenticationHeader = ®istryAuthenticationHeader{ + Username: matchingRegistry.Username, + Password: matchingRegistry.Password, + Serveraddress: matchingRegistry.URL, + } + } + } + + return authenticationHeader +} diff --git a/api/http/proxy/transport.go b/api/http/proxy/transport.go index 3b0b1fa3c..0e61dc1a5 100644 --- a/api/http/proxy/transport.go +++ b/api/http/proxy/transport.go @@ -1,6 +1,8 @@ package proxy import ( + "encoding/base64" + "encoding/json" "net/http" "path" "strings" @@ -14,6 +16,8 @@ type ( dockerTransport *http.Transport ResourceControlService portainer.ResourceControlService TeamMembershipService portainer.TeamMembershipService + RegistryService portainer.RegistryService + DockerHubService portainer.DockerHubService SettingsService portainer.SettingsService } restrictedOperationContext struct { @@ -22,6 +26,18 @@ type ( 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 *restrictedOperationContext labelBlackList []portainer.Pair @@ -62,6 +78,8 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon return p.proxyTaskRequest(request) case strings.HasPrefix(path, "/build"): return p.proxyBuildRequest(request) + case strings.HasPrefix(path, "/images"): + return p.proxyImageRequest(request) default: return p.executeDockerRequest(request) } @@ -119,7 +137,7 @@ func (p *proxyTransport) proxyContainerRequest(request *http.Request) (*http.Res func (p *proxyTransport) proxyServiceRequest(request *http.Request) (*http.Response, error) { switch requestPath := request.URL.Path; requestPath { case "/services/create": - return p.executeDockerRequest(request) + return p.replaceRegistryAuthenticationHeader(request) case "/services": return p.rewriteOperation(request, serviceListOperation) @@ -235,6 +253,54 @@ func (p *proxyTransport) proxyBuildRequest(request *http.Request) (*http.Respons 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 match, _ := path.Match("/images/*/push", requestPath); match { + 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) { @@ -270,7 +336,7 @@ func (p *proxyTransport) restrictedOperation(request *http.Request, resourceID s return p.executeDockerRequest(request) } -// rewriteOperation will create a new operation context with data that will be used +// 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) { @@ -341,6 +407,43 @@ func (p *proxyTransport) administratorOperation(request *http.Request) (*http.Re 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) (*restrictedOperationContext, error) { var err error tokenData, err := security.RetrieveTokenData(request) diff --git a/api/http/security/authorization.go b/api/http/security/authorization.go index 976c2947f..54932f673 100644 --- a/api/http/security/authorization.go +++ b/api/http/security/authorization.go @@ -140,3 +140,22 @@ func AuthorizedEndpointAccess(endpoint *portainer.Endpoint, userID portainer.Use } return false } + +// AuthorizedRegistryAccess ensure that the user can access the specified registry. +// It will check if the user is part of the authorized users or part of a team that is +// listed in the authorized teams. +func AuthorizedRegistryAccess(registry *portainer.Registry, userID portainer.UserID, memberships []portainer.TeamMembership) bool { + for _, authorizedUserID := range registry.AuthorizedUsers { + if authorizedUserID == userID { + return true + } + } + for _, membership := range memberships { + for _, authorizedTeamID := range registry.AuthorizedTeams { + if membership.TeamID == authorizedTeamID { + return true + } + } + } + return false +} diff --git a/api/http/security/filter.go b/api/http/security/filter.go index 9f28f19c0..ffe5e1c49 100644 --- a/api/http/security/filter.go +++ b/api/http/security/filter.go @@ -69,7 +69,7 @@ func FilterRegistries(registries []portainer.Registry, context *RestrictedReques filteredRegistries = make([]portainer.Registry, 0) for _, registry := range registries { - if isRegistryAccessAuthorized(®istry, context.UserID, context.UserMemberships) { + if AuthorizedRegistryAccess(®istry, context.UserID, context.UserMemberships) { filteredRegistries = append(filteredRegistries, registry) } } @@ -87,7 +87,7 @@ func FilterEndpoints(endpoints []portainer.Endpoint, context *RestrictedRequestC filteredEndpoints = make([]portainer.Endpoint, 0) for _, endpoint := range endpoints { - if isEndpointAccessAuthorized(&endpoint, context.UserID, context.UserMemberships) { + if AuthorizedEndpointAccess(&endpoint, context.UserID, context.UserMemberships) { filteredEndpoints = append(filteredEndpoints, endpoint) } } @@ -95,35 +95,3 @@ func FilterEndpoints(endpoints []portainer.Endpoint, context *RestrictedRequestC return filteredEndpoints, nil } - -func isRegistryAccessAuthorized(registry *portainer.Registry, userID portainer.UserID, memberships []portainer.TeamMembership) bool { - for _, authorizedUserID := range registry.AuthorizedUsers { - if authorizedUserID == userID { - return true - } - } - for _, membership := range memberships { - for _, authorizedTeamID := range registry.AuthorizedTeams { - if membership.TeamID == authorizedTeamID { - return true - } - } - } - return false -} - -func isEndpointAccessAuthorized(endpoint *portainer.Endpoint, userID portainer.UserID, memberships []portainer.TeamMembership) bool { - for _, authorizedUserID := range endpoint.AuthorizedUsers { - if authorizedUserID == userID { - return true - } - } - for _, membership := range memberships { - for _, authorizedTeamID := range endpoint.AuthorizedTeams { - if membership.TeamID == authorizedTeamID { - return true - } - } - } - return false -} diff --git a/api/http/server.go b/api/http/server.go index fc5f08972..536344f68 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -42,7 +42,7 @@ type Server struct { // Start starts the HTTP server func (server *Server) Start() error { requestBouncer := security.NewRequestBouncer(server.JWTService, server.UserService, server.TeamMembershipService, server.AuthDisabled) - proxyManager := proxy.NewManager(server.ResourceControlService, server.TeamMembershipService, server.SettingsService) + proxyManager := proxy.NewManager(server.ResourceControlService, server.TeamMembershipService, server.SettingsService, server.RegistryService, server.DockerHubService) var fileHandler = handler.NewFileHandler(filepath.Join(server.AssetsPath, "public")) var authHandler = handler.NewAuthHandler(requestBouncer, server.AuthDisabled) diff --git a/api/portainer.go b/api/portainer.go index fc98a6b36..aa9827e77 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -152,7 +152,7 @@ type ( URL string `json:"URL"` Authentication bool `json:"Authentication"` Username string `json:"Username"` - Password string `json:"Password"` + Password string `json:"Password,omitempty"` AuthorizedUsers []UserID `json:"AuthorizedUsers"` AuthorizedTeams []TeamID `json:"AuthorizedTeams"` } @@ -162,7 +162,7 @@ type ( DockerHub struct { Authentication bool `json:"Authentication"` Username string `json:"Username"` - Password string `json:"Password"` + Password string `json:"Password,omitempty"` } // EndpointID represents an endpoint identifier. diff --git a/app/portainer/services/api/registryService.js b/app/portainer/services/api/registryService.js index b6193f89b..ff5d00f18 100644 --- a/app/portainer/services/api/registryService.js +++ b/app/portainer/services/api/registryService.js @@ -37,8 +37,6 @@ angular.module('portainer.app') service.encodedCredentials = function(registry) { var credentials = { - username: registry.Username, - password: registry.Password, serveraddress: registry.URL }; return btoa(JSON.stringify(credentials));