fix(api): manage registry authentication in the API (#1751)
parent
c267f8bf57
commit
30dfd3d616
|
@ -52,6 +52,8 @@ func (handler *DockerHubHandler) handleGetDockerHub(w http.ResponseWriter, r *ht
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dockerhub.Password = ""
|
||||||
|
|
||||||
encodeJSON(w, dockerhub, handler.Logger)
|
encodeJSON(w, dockerhub, handler.Logger)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -91,6 +91,10 @@ func (handler *RegistryHandler) handleGetRegistries(w http.ResponseWriter, r *ht
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for i := range filteredRegistries {
|
||||||
|
filteredRegistries[i].Password = ""
|
||||||
|
}
|
||||||
|
|
||||||
encodeJSON(w, filteredRegistries, handler.Logger)
|
encodeJSON(w, filteredRegistries, handler.Logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -159,6 +163,8 @@ func (handler *RegistryHandler) handleGetRegistry(w http.ResponseWriter, r *http
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
registry.Password = ""
|
||||||
|
|
||||||
encodeJSON(w, registry, handler.Logger)
|
encodeJSON(w, registry, handler.Logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,8 @@ type proxyFactory struct {
|
||||||
ResourceControlService portainer.ResourceControlService
|
ResourceControlService portainer.ResourceControlService
|
||||||
TeamMembershipService portainer.TeamMembershipService
|
TeamMembershipService portainer.TeamMembershipService
|
||||||
SettingsService portainer.SettingsService
|
SettingsService portainer.SettingsService
|
||||||
|
RegistryService portainer.RegistryService
|
||||||
|
DockerHubService portainer.DockerHubService
|
||||||
}
|
}
|
||||||
|
|
||||||
func (factory *proxyFactory) newExtensionHTTPPRoxy(u *url.URL) http.Handler {
|
func (factory *proxyFactory) newExtensionHTTPPRoxy(u *url.URL) http.Handler {
|
||||||
|
@ -45,6 +47,8 @@ func (factory *proxyFactory) newDockerSocketProxy(path string) http.Handler {
|
||||||
ResourceControlService: factory.ResourceControlService,
|
ResourceControlService: factory.ResourceControlService,
|
||||||
TeamMembershipService: factory.TeamMembershipService,
|
TeamMembershipService: factory.TeamMembershipService,
|
||||||
SettingsService: factory.SettingsService,
|
SettingsService: factory.SettingsService,
|
||||||
|
RegistryService: factory.RegistryService,
|
||||||
|
DockerHubService: factory.DockerHubService,
|
||||||
dockerTransport: newSocketTransport(path),
|
dockerTransport: newSocketTransport(path),
|
||||||
}
|
}
|
||||||
proxy.Transport = transport
|
proxy.Transport = transport
|
||||||
|
@ -57,6 +61,8 @@ func (factory *proxyFactory) createDockerReverseProxy(u *url.URL) *httputil.Reve
|
||||||
ResourceControlService: factory.ResourceControlService,
|
ResourceControlService: factory.ResourceControlService,
|
||||||
TeamMembershipService: factory.TeamMembershipService,
|
TeamMembershipService: factory.TeamMembershipService,
|
||||||
SettingsService: factory.SettingsService,
|
SettingsService: factory.SettingsService,
|
||||||
|
RegistryService: factory.RegistryService,
|
||||||
|
DockerHubService: factory.DockerHubService,
|
||||||
dockerTransport: &http.Transport{},
|
dockerTransport: &http.Transport{},
|
||||||
}
|
}
|
||||||
proxy.Transport = transport
|
proxy.Transport = transport
|
||||||
|
|
|
@ -17,7 +17,7 @@ type Manager struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager initializes a new proxy Service
|
// 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{
|
return &Manager{
|
||||||
proxies: cmap.New(),
|
proxies: cmap.New(),
|
||||||
extensionProxies: cmap.New(),
|
extensionProxies: cmap.New(),
|
||||||
|
@ -25,6 +25,8 @@ func NewManager(resourceControlService portainer.ResourceControlService, teamMem
|
||||||
ResourceControlService: resourceControlService,
|
ResourceControlService: resourceControlService,
|
||||||
TeamMembershipService: teamMembershipService,
|
TeamMembershipService: teamMembershipService,
|
||||||
SettingsService: settingsService,
|
SettingsService: settingsService,
|
||||||
|
RegistryService: registryService,
|
||||||
|
DockerHubService: dockerHubService,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -1,6 +1,8 @@
|
||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -14,6 +16,8 @@ type (
|
||||||
dockerTransport *http.Transport
|
dockerTransport *http.Transport
|
||||||
ResourceControlService portainer.ResourceControlService
|
ResourceControlService portainer.ResourceControlService
|
||||||
TeamMembershipService portainer.TeamMembershipService
|
TeamMembershipService portainer.TeamMembershipService
|
||||||
|
RegistryService portainer.RegistryService
|
||||||
|
DockerHubService portainer.DockerHubService
|
||||||
SettingsService portainer.SettingsService
|
SettingsService portainer.SettingsService
|
||||||
}
|
}
|
||||||
restrictedOperationContext struct {
|
restrictedOperationContext struct {
|
||||||
|
@ -22,6 +26,18 @@ type (
|
||||||
userTeamIDs []portainer.TeamID
|
userTeamIDs []portainer.TeamID
|
||||||
resourceControls []portainer.ResourceControl
|
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 {
|
operationExecutor struct {
|
||||||
operationContext *restrictedOperationContext
|
operationContext *restrictedOperationContext
|
||||||
labelBlackList []portainer.Pair
|
labelBlackList []portainer.Pair
|
||||||
|
@ -62,6 +78,8 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon
|
||||||
return p.proxyTaskRequest(request)
|
return p.proxyTaskRequest(request)
|
||||||
case strings.HasPrefix(path, "/build"):
|
case strings.HasPrefix(path, "/build"):
|
||||||
return p.proxyBuildRequest(request)
|
return p.proxyBuildRequest(request)
|
||||||
|
case strings.HasPrefix(path, "/images"):
|
||||||
|
return p.proxyImageRequest(request)
|
||||||
default:
|
default:
|
||||||
return p.executeDockerRequest(request)
|
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) {
|
func (p *proxyTransport) proxyServiceRequest(request *http.Request) (*http.Response, error) {
|
||||||
switch requestPath := request.URL.Path; requestPath {
|
switch requestPath := request.URL.Path; requestPath {
|
||||||
case "/services/create":
|
case "/services/create":
|
||||||
return p.executeDockerRequest(request)
|
return p.replaceRegistryAuthenticationHeader(request)
|
||||||
|
|
||||||
case "/services":
|
case "/services":
|
||||||
return p.rewriteOperation(request, serviceListOperation)
|
return p.rewriteOperation(request, serviceListOperation)
|
||||||
|
@ -235,6 +253,54 @@ func (p *proxyTransport) proxyBuildRequest(request *http.Request) (*http.Respons
|
||||||
return p.interceptAndRewriteRequest(request, buildOperation)
|
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
|
// restrictedOperation ensures that the current user has the required authorizations
|
||||||
// before executing the original request.
|
// before executing the original request.
|
||||||
func (p *proxyTransport) restrictedOperation(request *http.Request, resourceID string) (*http.Response, error) {
|
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)
|
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 decorate the original request's response as well as retrieve all the black listed labels
|
||||||
// to filter the resources.
|
// to filter the resources.
|
||||||
func (p *proxyTransport) rewriteOperationWithLabelFiltering(request *http.Request, operation restrictedOperationRequest) (*http.Response, error) {
|
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)
|
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) {
|
func (p *proxyTransport) createOperationContext(request *http.Request) (*restrictedOperationContext, error) {
|
||||||
var err error
|
var err error
|
||||||
tokenData, err := security.RetrieveTokenData(request)
|
tokenData, err := security.RetrieveTokenData(request)
|
||||||
|
|
|
@ -140,3 +140,22 @@ func AuthorizedEndpointAccess(endpoint *portainer.Endpoint, userID portainer.Use
|
||||||
}
|
}
|
||||||
return false
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -69,7 +69,7 @@ func FilterRegistries(registries []portainer.Registry, context *RestrictedReques
|
||||||
filteredRegistries = make([]portainer.Registry, 0)
|
filteredRegistries = make([]portainer.Registry, 0)
|
||||||
|
|
||||||
for _, registry := range registries {
|
for _, registry := range registries {
|
||||||
if isRegistryAccessAuthorized(®istry, context.UserID, context.UserMemberships) {
|
if AuthorizedRegistryAccess(®istry, context.UserID, context.UserMemberships) {
|
||||||
filteredRegistries = append(filteredRegistries, registry)
|
filteredRegistries = append(filteredRegistries, registry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -87,7 +87,7 @@ func FilterEndpoints(endpoints []portainer.Endpoint, context *RestrictedRequestC
|
||||||
filteredEndpoints = make([]portainer.Endpoint, 0)
|
filteredEndpoints = make([]portainer.Endpoint, 0)
|
||||||
|
|
||||||
for _, endpoint := range endpoints {
|
for _, endpoint := range endpoints {
|
||||||
if isEndpointAccessAuthorized(&endpoint, context.UserID, context.UserMemberships) {
|
if AuthorizedEndpointAccess(&endpoint, context.UserID, context.UserMemberships) {
|
||||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -95,35 +95,3 @@ func FilterEndpoints(endpoints []portainer.Endpoint, context *RestrictedRequestC
|
||||||
|
|
||||||
return filteredEndpoints, nil
|
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
|
|
||||||
}
|
|
||||||
|
|
|
@ -42,7 +42,7 @@ type Server struct {
|
||||||
// Start starts the HTTP server
|
// Start starts the HTTP server
|
||||||
func (server *Server) Start() error {
|
func (server *Server) Start() error {
|
||||||
requestBouncer := security.NewRequestBouncer(server.JWTService, server.UserService, server.TeamMembershipService, server.AuthDisabled)
|
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 fileHandler = handler.NewFileHandler(filepath.Join(server.AssetsPath, "public"))
|
||||||
var authHandler = handler.NewAuthHandler(requestBouncer, server.AuthDisabled)
|
var authHandler = handler.NewAuthHandler(requestBouncer, server.AuthDisabled)
|
||||||
|
|
|
@ -152,7 +152,7 @@ type (
|
||||||
URL string `json:"URL"`
|
URL string `json:"URL"`
|
||||||
Authentication bool `json:"Authentication"`
|
Authentication bool `json:"Authentication"`
|
||||||
Username string `json:"Username"`
|
Username string `json:"Username"`
|
||||||
Password string `json:"Password"`
|
Password string `json:"Password,omitempty"`
|
||||||
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
|
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
|
||||||
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
|
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
|
||||||
}
|
}
|
||||||
|
@ -162,7 +162,7 @@ type (
|
||||||
DockerHub struct {
|
DockerHub struct {
|
||||||
Authentication bool `json:"Authentication"`
|
Authentication bool `json:"Authentication"`
|
||||||
Username string `json:"Username"`
|
Username string `json:"Username"`
|
||||||
Password string `json:"Password"`
|
Password string `json:"Password,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// EndpointID represents an endpoint identifier.
|
// EndpointID represents an endpoint identifier.
|
||||||
|
|
|
@ -37,8 +37,6 @@ angular.module('portainer.app')
|
||||||
|
|
||||||
service.encodedCredentials = function(registry) {
|
service.encodedCredentials = function(registry) {
|
||||||
var credentials = {
|
var credentials = {
|
||||||
username: registry.Username,
|
|
||||||
password: registry.Password,
|
|
||||||
serveraddress: registry.URL
|
serveraddress: registry.URL
|
||||||
};
|
};
|
||||||
return btoa(JSON.stringify(credentials));
|
return btoa(JSON.stringify(credentials));
|
||||||
|
|
Loading…
Reference in New Issue