diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index b0904102c..09fcf89c7 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -169,6 +169,7 @@ func (store *Store) MigrateData(force bool) error { UserService: store.UserService, VersionService: store.VersionService, FileService: store.fileService, + DockerhubService: store.DockerHubService, AuthorizationService: authorization.NewService(store), } migrator := migrator.NewMigrator(migratorParams) diff --git a/api/bolt/init.go b/api/bolt/init.go index 7ce23f138..a91a6b2e6 100644 --- a/api/bolt/init.go +++ b/api/bolt/init.go @@ -55,22 +55,6 @@ func (store *Store) Init() error { return err } - _, err = store.DockerHubService.DockerHub() - if err == errors.ErrObjectNotFound { - defaultDockerHub := &portainer.DockerHub{ - Authentication: false, - Username: "", - Password: "", - } - - err := store.DockerHubService.UpdateDockerHub(defaultDockerHub) - if err != nil { - return err - } - } else if err != nil { - return err - } - groups, err := store.EndpointGroupService.EndpointGroups() if err != nil { return err diff --git a/api/bolt/log/log.go b/api/bolt/log/log.go new file mode 100644 index 000000000..5ae90946a --- /dev/null +++ b/api/bolt/log/log.go @@ -0,0 +1,41 @@ +package log + +import ( + "fmt" + "log" +) + +const ( + INFO = "INFO" + ERROR = "ERROR" + DEBUG = "DEBUG" + FATAL = "FATAL" +) + +type ScopedLog struct { + scope string +} + +func NewScopedLog(scope string) *ScopedLog { + return &ScopedLog{scope: scope} +} + +func (slog *ScopedLog) print(kind string, message string) { + log.Printf("[%s] [%s] %s", kind, slog.scope, message) +} + +func (slog *ScopedLog) Debug(message string) { + slog.print(DEBUG, fmt.Sprintf("[message: %s]", message)) +} + +func (slog *ScopedLog) Info(message string) { + slog.print(INFO, fmt.Sprintf("[message: %s]", message)) +} + +func (slog *ScopedLog) Error(message string, err error) { + slog.print(ERROR, fmt.Sprintf("[message: %s] [error: %s]", message, err)) +} + +func (slog *ScopedLog) NotImplemented(method string) { + log.Fatalf("[%s] [%s] [%s]", FATAL, slog.scope, fmt.Sprintf("%s is not yet implemented", method)) +} diff --git a/api/bolt/log/log.test.go b/api/bolt/log/log.test.go new file mode 100644 index 000000000..7330d5405 --- /dev/null +++ b/api/bolt/log/log.test.go @@ -0,0 +1 @@ +package log diff --git a/api/bolt/migrator/migrate_dbversion32.go b/api/bolt/migrator/migrate_dbversion32.go new file mode 100644 index 000000000..3d800bd36 --- /dev/null +++ b/api/bolt/migrator/migrate_dbversion32.go @@ -0,0 +1,124 @@ +package migrator + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" +) + +func (m *Migrator) migrateDBVersionTo32() error { + err := m.updateRegistriesToDB32() + if err != nil { + return err + } + + err = m.updateDockerhubToDB32() + if err != nil { + return err + } + + return nil +} + +func (m *Migrator) updateRegistriesToDB32() error { + registries, err := m.registryService.Registries() + if err != nil { + return err + } + + endpoints, err := m.endpointService.Endpoints() + if err != nil { + return err + } + + for _, registry := range registries { + + registry.RegistryAccesses = portainer.RegistryAccesses{} + + for _, endpoint := range endpoints { + + filteredUserAccessPolicies := portainer.UserAccessPolicies{} + for userId, registryPolicy := range registry.UserAccessPolicies { + if _, found := endpoint.UserAccessPolicies[userId]; found { + filteredUserAccessPolicies[userId] = registryPolicy + } + } + + filteredTeamAccessPolicies := portainer.TeamAccessPolicies{} + for teamId, registryPolicy := range registry.TeamAccessPolicies { + if _, found := endpoint.TeamAccessPolicies[teamId]; found { + filteredTeamAccessPolicies[teamId] = registryPolicy + } + } + + registry.RegistryAccesses[endpoint.ID] = portainer.RegistryAccessPolicies{ + UserAccessPolicies: filteredUserAccessPolicies, + TeamAccessPolicies: filteredTeamAccessPolicies, + Namespaces: []string{}, + } + } + m.registryService.UpdateRegistry(registry.ID, ®istry) + } + return nil +} + +func (m *Migrator) updateDockerhubToDB32() error { + dockerhub, err := m.dockerhubService.DockerHub() + if err == errors.ErrObjectNotFound { + return nil + } else if err != nil { + return err + } + + if !dockerhub.Authentication { + return nil + } + + registry := &portainer.Registry{ + Type: portainer.DockerHubRegistry, + Name: "Dockerhub (authenticated - migrated)", + URL: "docker.io", + Authentication: true, + Username: dockerhub.Username, + Password: dockerhub.Password, + RegistryAccesses: portainer.RegistryAccesses{}, + } + + endpoints, err := m.endpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range endpoints { + + if endpoint.Type != portainer.KubernetesLocalEnvironment && + endpoint.Type != portainer.AgentOnKubernetesEnvironment && + endpoint.Type != portainer.EdgeAgentOnKubernetesEnvironment { + + userAccessPolicies := portainer.UserAccessPolicies{} + for userId := range endpoint.UserAccessPolicies { + if _, found := endpoint.UserAccessPolicies[userId]; found { + userAccessPolicies[userId] = portainer.AccessPolicy{ + RoleID: 0, + } + } + } + + teamAccessPolicies := portainer.TeamAccessPolicies{} + for teamId := range endpoint.TeamAccessPolicies { + if _, found := endpoint.TeamAccessPolicies[teamId]; found { + teamAccessPolicies[teamId] = portainer.AccessPolicy{ + RoleID: 0, + } + } + } + + registry.RegistryAccesses[endpoint.ID] = portainer.RegistryAccessPolicies{ + UserAccessPolicies: userAccessPolicies, + TeamAccessPolicies: teamAccessPolicies, + Namespaces: []string{}, + } + } + } + + return m.registryService.CreateRegistry(registry) +} diff --git a/api/bolt/migrator/migrator.go b/api/bolt/migrator/migrator.go index 8d99b5bfa..daae8b184 100644 --- a/api/bolt/migrator/migrator.go +++ b/api/bolt/migrator/migrator.go @@ -3,10 +3,12 @@ package migrator import ( "github.com/boltdb/bolt" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/dockerhub" "github.com/portainer/portainer/api/bolt/endpoint" "github.com/portainer/portainer/api/bolt/endpointgroup" "github.com/portainer/portainer/api/bolt/endpointrelation" "github.com/portainer/portainer/api/bolt/extension" + plog "github.com/portainer/portainer/api/bolt/log" "github.com/portainer/portainer/api/bolt/registry" "github.com/portainer/portainer/api/bolt/resourcecontrol" "github.com/portainer/portainer/api/bolt/role" @@ -20,6 +22,8 @@ import ( "github.com/portainer/portainer/api/internal/authorization" ) +var migrateLog = plog.NewScopedLog("bolt, migrate") + type ( // Migrator defines a service to migrate data after a Portainer version update. Migrator struct { @@ -41,6 +45,7 @@ type ( versionService *version.Service fileService portainer.FileService authorizationService *authorization.Service + dockerhubService *dockerhub.Service } // Parameters represents the required parameters to create a new Migrator instance. @@ -63,6 +68,7 @@ type ( VersionService *version.Service FileService portainer.FileService AuthorizationService *authorization.Service + DockerhubService *dockerhub.Service } ) @@ -87,6 +93,7 @@ func NewMigrator(parameters *Parameters) *Migrator { versionService: parameters.VersionService, fileService: parameters.FileService, authorizationService: parameters.AuthorizationService, + dockerhubService: parameters.DockerhubService, } } @@ -366,5 +373,13 @@ func (m *Migrator) Migrate() error { } } + // Portainer 2.9.0 + if m.currentDBVersion < 32 { + err := m.migrateDBVersionTo32() + if err != nil { + return err + } + } + return m.versionService.StoreDBVersion(portainer.DBVersion) } diff --git a/api/bolt/services.go b/api/bolt/services.go index 4cdc84069..ec4c8ecc6 100644 --- a/api/bolt/services.go +++ b/api/bolt/services.go @@ -167,11 +167,6 @@ func (store *Store) CustomTemplate() portainer.CustomTemplateService { return store.CustomTemplateService } -// DockerHub gives access to the DockerHub data management layer -func (store *Store) DockerHub() portainer.DockerHubService { - return store.DockerHubService -} - // EdgeGroup gives access to the EdgeGroup data management layer func (store *Store) EdgeGroup() portainer.EdgeGroupService { return store.EdgeGroupService diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index a852abd56..d056f9908 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -134,8 +134,8 @@ func initDockerClientFactory(signatureService portainer.DigitalSignatureService, return docker.NewClientFactory(signatureService, reverseTunnelService) } -func initKubernetesClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, instanceID string) *kubecli.ClientFactory { - return kubecli.NewClientFactory(signatureService, reverseTunnelService, instanceID) +func initKubernetesClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, instanceID string, dataStore portainer.DataStore) *kubecli.ClientFactory { + return kubecli.NewClientFactory(signatureService, reverseTunnelService, instanceID, dataStore) } func initSnapshotService(snapshotInterval string, dataStore portainer.DataStore, dockerClientFactory *docker.ClientFactory, kubernetesClientFactory *kubecli.ClientFactory, shutdownCtx context.Context) (portainer.SnapshotService, error) { @@ -382,7 +382,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { } dockerClientFactory := initDockerClientFactory(digitalSignatureService, reverseTunnelService) - kubernetesClientFactory := initKubernetesClientFactory(digitalSignatureService, reverseTunnelService, instanceID) + kubernetesClientFactory := initKubernetesClientFactory(digitalSignatureService, reverseTunnelService, instanceID, dataStore) snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory, shutdownCtx) if err != nil { diff --git a/api/exec/swarm_stack.go b/api/exec/swarm_stack.go index cf59f7607..faf6cf723 100644 --- a/api/exec/swarm_stack.go +++ b/api/exec/swarm_stack.go @@ -10,7 +10,7 @@ import ( "path" "runtime" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" ) // SwarmStackManager represents a service for managing stacks. @@ -42,7 +42,7 @@ func NewSwarmStackManager(binaryPath, dataPath string, signatureService portaine } // Login executes the docker login command against a list of registries (including DockerHub). -func (manager *SwarmStackManager) Login(dockerhub *portainer.DockerHub, registries []portainer.Registry, endpoint *portainer.Endpoint) { +func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoint *portainer.Endpoint) { command, args := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) for _, registry := range registries { if registry.Authentication { @@ -50,11 +50,6 @@ func (manager *SwarmStackManager) Login(dockerhub *portainer.DockerHub, registri runCommandAndCaptureStdErr(command, registryArgs, nil, "") } } - - if dockerhub.Authentication { - dockerhubArgs := append(args, "login", "--username", dockerhub.Username, "--password", dockerhub.Password) - runCommandAndCaptureStdErr(command, dockerhubArgs, nil, "") - } } // Logout executes the docker logout command. diff --git a/api/go.mod b/api/go.mod index bfafa47fa..2f4e2e7b2 100644 --- a/api/go.mod +++ b/api/go.mod @@ -34,6 +34,7 @@ require ( golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 gopkg.in/alecthomas/kingpin.v2 v2.2.6 + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c k8s.io/api v0.17.2 k8s.io/apimachinery v0.17.2 k8s.io/client-go v0.17.2 diff --git a/api/http/handler/dockerhub/dockerhub_inspect.go b/api/http/handler/dockerhub/dockerhub_inspect.go deleted file mode 100644 index e7dc713f8..000000000 --- a/api/http/handler/dockerhub/dockerhub_inspect.go +++ /dev/null @@ -1,28 +0,0 @@ -package dockerhub - -import ( - "net/http" - - httperror "github.com/portainer/libhttp/error" - "github.com/portainer/libhttp/response" -) - -// @id DockerHubInspect -// @summary Retrieve DockerHub information -// @description Use this endpoint to retrieve the information used to connect to the DockerHub -// @description **Access policy**: authenticated -// @tags dockerhub -// @security jwt -// @produce json -// @success 200 {object} portainer.DockerHub -// @failure 500 "Server error" -// @router /dockerhub [get] -func (handler *Handler) dockerhubInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - dockerhub, err := handler.DataStore.DockerHub().DockerHub() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err} - } - - hideFields(dockerhub) - return response.JSON(w, dockerhub) -} diff --git a/api/http/handler/dockerhub/dockerhub_update.go b/api/http/handler/dockerhub/dockerhub_update.go deleted file mode 100644 index 536b84420..000000000 --- a/api/http/handler/dockerhub/dockerhub_update.go +++ /dev/null @@ -1,68 +0,0 @@ -package dockerhub - -import ( - "errors" - "net/http" - - "github.com/asaskevich/govalidator" - httperror "github.com/portainer/libhttp/error" - "github.com/portainer/libhttp/request" - "github.com/portainer/libhttp/response" - portainer "github.com/portainer/portainer/api" -) - -type dockerhubUpdatePayload struct { - // Enable authentication against DockerHub - Authentication bool `validate:"required" example:"false"` - // Username used to authenticate against the DockerHub - Username string `validate:"required" example:"hub_user"` - // Password used to authenticate against the DockerHub - Password string `validate:"required" example:"hub_password"` -} - -func (payload *dockerhubUpdatePayload) Validate(r *http.Request) error { - if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) { - return errors.New("Invalid credentials. Username and password must be specified when authentication is enabled") - } - return nil -} - -// @id DockerHubUpdate -// @summary Update DockerHub information -// @description Use this endpoint to update the information used to connect to the DockerHub -// @description **Access policy**: administrator -// @tags dockerhub -// @security jwt -// @accept json -// @produce json -// @param body body dockerhubUpdatePayload true "DockerHub information" -// @success 204 "Success" -// @failure 400 "Invalid request" -// @failure 500 "Server error" -// @router /dockerhub [put] -func (handler *Handler) dockerhubUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - var payload dockerhubUpdatePayload - err := request.DecodeAndValidateJSONPayload(r, &payload) - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} - } - - dockerhub := &portainer.DockerHub{ - Authentication: false, - Username: "", - Password: "", - } - - if payload.Authentication { - dockerhub.Authentication = true - dockerhub.Username = payload.Username - dockerhub.Password = payload.Password - } - - err = handler.DataStore.DockerHub().UpdateDockerHub(dockerhub) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the Dockerhub changes inside the database", err} - } - - return response.Empty(w) -} diff --git a/api/http/handler/dockerhub/handler.go b/api/http/handler/dockerhub/handler.go deleted file mode 100644 index f1328acb8..000000000 --- a/api/http/handler/dockerhub/handler.go +++ /dev/null @@ -1,33 +0,0 @@ -package dockerhub - -import ( - "net/http" - - "github.com/gorilla/mux" - httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/security" -) - -func hideFields(dockerHub *portainer.DockerHub) { - dockerHub.Password = "" -} - -// Handler is the HTTP handler used to handle DockerHub operations. -type Handler struct { - *mux.Router - DataStore portainer.DataStore -} - -// NewHandler creates a handler to manage Dockerhub operations. -func NewHandler(bouncer *security.RequestBouncer) *Handler { - h := &Handler{ - Router: mux.NewRouter(), - } - h.Handle("/dockerhub", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.dockerhubInspect))).Methods(http.MethodGet) - h.Handle("/dockerhub", - bouncer.AdminAccess(httperror.LoggerHandler(h.dockerhubUpdate))).Methods(http.MethodPut) - - return h -} diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index 946fa7d7e..50e462869 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -322,8 +322,8 @@ func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload) TLSConfig: portainer.TLSConfiguration{ TLS: false, }, - AuthorizedUsers: []portainer.UserID{}, - AuthorizedTeams: []portainer.TeamID{}, + UserAccessPolicies: portainer.UserAccessPolicies{}, + TeamAccessPolicies: portainer.TeamAccessPolicies{}, Extensions: []portainer.EndpointExtension{}, TagIDs: payload.TagIDs, Status: portainer.EndpointStatusUp, diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go index 9fda9b74c..a2dcf1924 100644 --- a/api/http/handler/endpoints/endpoint_delete.go +++ b/api/http/handler/endpoints/endpoint_delete.go @@ -103,6 +103,22 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) * } } + registries, err := handler.DataStore.Registry().Registries() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} + } + + for idx := range registries { + registry := ®istries[idx] + if _, ok := registry.RegistryAccesses[endpoint.ID]; ok { + delete(registry.RegistryAccesses, endpoint.ID) + err = handler.DataStore.Registry().UpdateRegistry(registry.ID, registry) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to update registry accesses", Err: err} + } + } + } + return response.Empty(w) } diff --git a/api/http/handler/endpoints/endpoint_dockerhub_status.go b/api/http/handler/endpoints/endpoint_dockerhub_status.go index 793b85715..92cfbef9c 100644 --- a/api/http/handler/endpoints/endpoint_dockerhub_status.go +++ b/api/http/handler/endpoints/endpoint_dockerhub_status.go @@ -22,7 +22,7 @@ type dockerhubStatusResponse struct { Limit int `json:"limit"` } -// GET request on /api/endpoints/{id}/dockerhub/status +// GET request on /api/endpoints/{id}/dockerhub/{registryId} func (handler *Handler) endpointDockerhubStatus(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { @@ -40,13 +40,30 @@ func (handler *Handler) endpointDockerhubStatus(w http.ResponseWriter, r *http.R return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment type", errors.New("Invalid environment type")} } - dockerhub, err := handler.DataStore.DockerHub().DockerHub() + registryID, err := request.RetrieveNumericRouteVariableValue(r, "registryId") if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} + } + + var registry *portainer.Registry + + if registryID == 0 { + registry = &portainer.Registry{} + } else { + registry, err = handler.DataStore.Registry().Registry(portainer.RegistryID(registryID)) + if err == bolterrors.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} + } + + if registry.Type != portainer.DockerHubRegistry { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry type", errors.New("Invalid registry type")} + } } httpClient := client.NewHTTPClient() - token, err := getDockerHubToken(httpClient, dockerhub) + token, err := getDockerHubToken(httpClient, registry) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub token from DockerHub", err} } @@ -59,7 +76,7 @@ func (handler *Handler) endpointDockerhubStatus(w http.ResponseWriter, r *http.R return response.JSON(w, resp) } -func getDockerHubToken(httpClient *client.HTTPClient, dockerhub *portainer.DockerHub) (string, error) { +func getDockerHubToken(httpClient *client.HTTPClient, registry *portainer.Registry) (string, error) { type dockerhubTokenResponse struct { Token string `json:"token"` } @@ -71,8 +88,8 @@ func getDockerHubToken(httpClient *client.HTTPClient, dockerhub *portainer.Docke return "", err } - if dockerhub.Authentication { - req.SetBasicAuth(dockerhub.Username, dockerhub.Password) + if registry.Authentication { + req.SetBasicAuth(registry.Username, registry.Password) } resp, err := httpClient.Do(req) diff --git a/api/http/handler/endpoints/endpoint_registries_inspect.go b/api/http/handler/endpoints/endpoint_registries_inspect.go new file mode 100644 index 000000000..405fe9495 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_registries_inspect.go @@ -0,0 +1,50 @@ +package endpoints + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" +) + +// GET request on /endpoints/{id}/registries/{registryId} +func (handler *Handler) endpointRegistryInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid endpoint identifier route variable", Err: err} + } + + registryID, err := request.RetrieveNumericRouteVariableValue(r, "registryId") + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid registry identifier route variable", Err: err} + } + + registry, err := handler.DataStore.Registry().Registry(portainer.RegistryID(registryID)) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find a registry with the specified identifier inside the database", Err: err} + } else if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find a registry with the specified identifier inside the database", Err: err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err} + } + + user, err := handler.DataStore.User().User(securityContext.UserID) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve user from the database", Err: err} + } + + if !security.AuthorizedRegistryAccess(registry, user, securityContext.UserMemberships, portainer.EndpointID(endpointID)) { + return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Access denied to resource", Err: httperrors.ErrResourceAccessDenied} + } + + hideRegistryFields(registry, !securityContext.IsAdmin) + return response.JSON(w, registry) +} diff --git a/api/http/handler/endpoints/endpoint_registries_list.go b/api/http/handler/endpoints/endpoint_registries_list.go new file mode 100644 index 000000000..93233fada --- /dev/null +++ b/api/http/handler/endpoints/endpoint_registries_list.go @@ -0,0 +1,130 @@ +package endpoints + +import ( + "net/http" + + "github.com/pkg/errors" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/security" + endpointutils "github.com/portainer/portainer/api/internal/endpoint" +) + +// GET request on /endpoints/{id}/registries?namespace +func (handler *Handler) endpointRegistriesList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + user, err := handler.DataStore.User().User(securityContext.UserID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user from the database", err} + } + + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid endpoint identifier route variable", Err: err} + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + isAdmin := securityContext.IsAdmin + + registries, err := handler.DataStore.Registry().Registries() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} + } + + if endpointutils.IsKubernetesEndpoint(endpoint) { + namespace, _ := request.RetrieveQueryParameter(r, "namespace", true) + + if namespace == "" && !isAdmin { + return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Missing namespace query parameter", Err: errors.New("missing namespace query parameter")} + } + + if namespace != "" { + + authorized, err := handler.isNamespaceAuthorized(endpoint, namespace, user.ID, securityContext.UserMemberships, isAdmin) + if err != nil { + return &httperror.HandlerError{http.StatusNotFound, "Unable to check for namespace authorization", err} + } + + if !authorized { + return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "User is not authorized to use namespace", Err: errors.New("user is not authorized to use namespace")} + } + + registries = filterRegistriesByNamespace(registries, endpoint.ID, namespace) + } + + } else if !isAdmin { + registries = security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID) + } + + for idx := range registries { + hideRegistryFields(®istries[idx], !isAdmin) + } + + return response.JSON(w, registries) +} + +func (handler *Handler) isNamespaceAuthorized(endpoint *portainer.Endpoint, namespace string, userId portainer.UserID, memberships []portainer.TeamMembership, isAdmin bool) (bool, error) { + if isAdmin || namespace == "" { + return true, nil + } + + if namespace == "default" { + return true, nil + } + + kcl, err := handler.K8sClientFactory.GetKubeClient(endpoint) + if err != nil { + return false, errors.Wrap(err, "unable to retrieve kubernetes client") + } + + accessPolicies, err := kcl.GetNamespaceAccessPolicies() + if err != nil { + return false, errors.Wrap(err, "unable to retrieve endpoint's namespaces policies") + } + + namespacePolicy, ok := accessPolicies[namespace] + if !ok { + return false, nil + } + + return security.AuthorizedAccess(userId, memberships, namespacePolicy.UserAccessPolicies, namespacePolicy.TeamAccessPolicies), nil +} + +func filterRegistriesByNamespace(registries []portainer.Registry, endpointId portainer.EndpointID, namespace string) []portainer.Registry { + if namespace == "" { + return registries + } + + filteredRegistries := []portainer.Registry{} + + for _, registry := range registries { + for _, authorizedNamespace := range registry.RegistryAccesses[endpointId].Namespaces { + if authorizedNamespace == namespace { + filteredRegistries = append(filteredRegistries, registry) + } + } + } + + return filteredRegistries +} + +func hideRegistryFields(registry *portainer.Registry, hideAccesses bool) { + registry.Password = "" + registry.ManagementConfiguration = nil + if hideAccesses { + registry.RegistryAccesses = nil + } +} diff --git a/api/http/handler/endpoints/endpoint_registry_access.go b/api/http/handler/endpoints/endpoint_registry_access.go new file mode 100644 index 000000000..8dde41d35 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_registry_access.go @@ -0,0 +1,149 @@ +package endpoints + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/security" +) + +type registryAccessPayload struct { + UserAccessPolicies portainer.UserAccessPolicies + TeamAccessPolicies portainer.TeamAccessPolicies + Namespaces []string +} + +func (payload *registryAccessPayload) Validate(r *http.Request) error { + return nil +} + +// PUT request on /endpoints/{id}/registries/{registryId} +func (handler *Handler) endpointRegistryAccess(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid endpoint identifier route variable", Err: err} + } + + registryID, err := request.RetrieveNumericRouteVariableValue(r, "registryId") + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid registry identifier route variable", Err: err} + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err} + } else if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + } + + if !securityContext.IsAdmin { + return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "User is not authorized", Err: err} + } + + registry, err := handler.DataStore.Registry().Registry(portainer.RegistryID(registryID)) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err} + } else if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err} + } + + var payload registryAccessPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err} + } + + if registry.RegistryAccesses == nil { + registry.RegistryAccesses = portainer.RegistryAccesses{} + } + + if _, ok := registry.RegistryAccesses[endpoint.ID]; !ok { + registry.RegistryAccesses[endpoint.ID] = portainer.RegistryAccessPolicies{} + } + + registryAccess := registry.RegistryAccesses[endpoint.ID] + + if endpoint.Type == portainer.KubernetesLocalEnvironment || endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { + err := handler.updateKubeAccess(endpoint, registry, registryAccess.Namespaces, payload.Namespaces) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to update kube access policies", Err: err} + } + + registryAccess.Namespaces = payload.Namespaces + } else { + registryAccess.UserAccessPolicies = payload.UserAccessPolicies + registryAccess.TeamAccessPolicies = payload.TeamAccessPolicies + } + + registry.RegistryAccesses[portainer.EndpointID(endpointID)] = registryAccess + + handler.DataStore.Registry().UpdateRegistry(registry.ID, registry) + + return response.Empty(w) +} + +func (handler *Handler) updateKubeAccess(endpoint *portainer.Endpoint, registry *portainer.Registry, oldNamespaces, newNamespaces []string) error { + oldNamespacesSet := toSet(oldNamespaces) + newNamespacesSet := toSet(newNamespaces) + + namespacesToRemove := setDifference(oldNamespacesSet, newNamespacesSet) + namespacesToAdd := setDifference(newNamespacesSet, oldNamespacesSet) + + cli, err := handler.K8sClientFactory.GetKubeClient(endpoint) + if err != nil { + return err + } + + for namespace := range namespacesToRemove { + err := cli.DeleteRegistrySecret(registry, namespace) + if err != nil { + return err + } + } + + for namespace := range namespacesToAdd { + err := cli.CreateRegistrySecret(registry, namespace) + if err != nil { + return err + } + } + + return nil +} + +type stringSet map[string]bool + +func toSet(list []string) stringSet { + set := stringSet{} + for _, el := range list { + set[el] = true + } + return set +} + +// setDifference returns the set difference tagsA - tagsB +func setDifference(setA stringSet, setB stringSet) stringSet { + set := stringSet{} + + for el := range setA { + if !setB[el] { + set[el] = true + } + } + + return set +} diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index cd82092e7..bee2eb1d6 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -6,6 +6,7 @@ import ( "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/kubernetes/cli" "net/http" @@ -28,6 +29,7 @@ type Handler struct { ProxyManager *proxy.Manager ReverseTunnelService portainer.ReverseTunnelService SnapshotService portainer.SnapshotService + K8sClientFactory *cli.ClientFactory ComposeStackManager portainer.ComposeStackManager AuthorizationService *authorization.Service } @@ -53,7 +55,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.AdminAccess(httperror.LoggerHandler(h.endpointUpdate))).Methods(http.MethodPut) h.Handle("/endpoints/{id}", bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDelete))).Methods(http.MethodDelete) - h.Handle("/endpoints/{id}/dockerhub", + h.Handle("/endpoints/{id}/dockerhub/{registryId}", bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointDockerhubStatus))).Methods(http.MethodGet) h.Handle("/endpoints/{id}/extensions", bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointExtensionAdd))).Methods(http.MethodPost) @@ -63,5 +65,11 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost) h.Handle("/endpoints/{id}/status", bouncer.PublicAccess(httperror.LoggerHandler(h.endpointStatusInspect))).Methods(http.MethodGet) + h.Handle("/endpoints/{id}/registries", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointRegistriesList))).Methods(http.MethodGet) + h.Handle("/endpoints/{id}/registries/{registryId}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointRegistryInspect))).Methods(http.MethodGet) + h.Handle("/endpoints/{id}/registries/{registryId}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointRegistryAccess))).Methods(http.MethodPut) return h } diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 2942c3a17..f4d1eceea 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -7,7 +7,6 @@ import ( "github.com/portainer/portainer/api/http/handler/auth" "github.com/portainer/portainer/api/http/handler/backup" "github.com/portainer/portainer/api/http/handler/customtemplates" - "github.com/portainer/portainer/api/http/handler/dockerhub" "github.com/portainer/portainer/api/http/handler/edgegroups" "github.com/portainer/portainer/api/http/handler/edgejobs" "github.com/portainer/portainer/api/http/handler/edgestacks" @@ -39,7 +38,6 @@ type Handler struct { AuthHandler *auth.Handler BackupHandler *backup.Handler CustomTemplatesHandler *customtemplates.Handler - DockerHubHandler *dockerhub.Handler EdgeGroupsHandler *edgegroups.Handler EdgeJobsHandler *edgejobs.Handler EdgeStacksHandler *edgestacks.Handler @@ -88,8 +86,6 @@ type Handler struct { // @tag.description Authenticate against Portainer HTTP API // @tag.name custom_templates // @tag.description Manage Custom Templates -// @tag.name dockerhub -// @tag.description Manage how Portainer connects to the DockerHub // @tag.name edge_groups // @tag.description Manage Edge Groups // @tag.name edge_jobs @@ -146,8 +142,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api", h.BackupHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/restore"): http.StripPrefix("/api", h.BackupHandler).ServeHTTP(w, r) - case strings.HasPrefix(r.URL.Path, "/api/dockerhub"): - http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/custom_templates"): http.StripPrefix("/api", h.CustomTemplatesHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/edge_stacks"): diff --git a/api/http/handler/registries/handler.go b/api/http/handler/registries/handler.go index 8c921c696..e8dbaeefc 100644 --- a/api/http/handler/registries/handler.go +++ b/api/http/handler/registries/handler.go @@ -8,20 +8,25 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/kubernetes/cli" ) -func hideFields(registry *portainer.Registry) { +func hideFields(registry *portainer.Registry, hideAccesses bool) { registry.Password = "" registry.ManagementConfiguration = nil + if hideAccesses { + registry.RegistryAccesses = nil + } } // Handler is the HTTP handler used to handle registry operations. type Handler struct { *mux.Router - requestBouncer *security.RequestBouncer - DataStore portainer.DataStore - FileService portainer.FileService - ProxyManager *proxy.Manager + requestBouncer *security.RequestBouncer + DataStore portainer.DataStore + FileService portainer.FileService + ProxyManager *proxy.Manager + K8sClientFactory *cli.ClientFactory } // NewHandler creates a handler to manage registry operations. @@ -34,8 +39,8 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { func newHandler(bouncer *security.RequestBouncer) *Handler { return &Handler{ - Router: mux.NewRouter(), - requestBouncer: bouncer, + Router: mux.NewRouter(), + requestBouncer: bouncer, } } @@ -60,4 +65,15 @@ type accessGuard interface { AdminAccess(h http.Handler) http.Handler RestrictedAccess(h http.Handler) http.Handler AuthenticatedAccess(h http.Handler) http.Handler -} \ No newline at end of file +} + +func (handler *Handler) registriesHaveSameURLAndCredentials(r1, r2 *portainer.Registry) bool { + hasSameUrl := r1.URL == r2.URL + hasSameCredentials := r1.Authentication == r2.Authentication && (!r1.Authentication || (r1.Authentication && r1.Username == r2.Username)) + + if r1.Type != portainer.GitlabRegistry || r2.Type != portainer.GitlabRegistry { + return hasSameUrl && hasSameCredentials + } + + return hasSameUrl && hasSameCredentials && r1.Gitlab.ProjectPath == r2.Gitlab.ProjectPath +} diff --git a/api/http/handler/registries/registry_configure.go b/api/http/handler/registries/registry_configure.go index 307101177..cc9398a61 100644 --- a/api/http/handler/registries/registry_configure.go +++ b/api/http/handler/registries/registry_configure.go @@ -10,6 +10,8 @@ import ( "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" ) type registryConfigurePayload struct { @@ -93,9 +95,12 @@ func (payload *registryConfigurePayload) Validate(r *http.Request) error { // @failure 500 "Server error" // @router /registries/{id}/configure [post] func (handler *Handler) registryConfigure(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + if !securityContext.IsAdmin { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to configure registry", httperrors.ErrResourceAccessDenied} } payload := ®istryConfigurePayload{} @@ -104,6 +109,11 @@ func (handler *Handler) registryConfigure(w http.ResponseWriter, r *http.Request return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} + } + registry, err := handler.DataStore.Registry().Registry(portainer.RegistryID(registryID)) if err == bolterrors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} diff --git a/api/http/handler/registries/registry_create.go b/api/http/handler/registries/registry_create.go index f724d7e2e..017121887 100644 --- a/api/http/handler/registries/registry_create.go +++ b/api/http/handler/registries/registry_create.go @@ -10,13 +10,15 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" ) type registryCreatePayload struct { // Name that will be used to identify this registry Name string `example:"my-registry" validate:"required"` - // Registry Type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry), 4 (Gitlab registry) or 5 (ProGet registry) - Type portainer.RegistryType `example:"1" validate:"required" enums:"1,2,3,4,5"` + // Registry Type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry), 4 (Gitlab registry), 5 (ProGet registry) or 6 (DockerHub) + Type portainer.RegistryType `example:"1" validate:"required" enums:"1,2,3,4,5,6"` // URL or IP address of the Docker registry URL string `example:"registry.mydomain.tld:2375/feed" validate:"required"` // BaseURL required for ProGet registry @@ -45,9 +47,9 @@ func (payload *registryCreatePayload) Validate(_ *http.Request) error { } switch payload.Type { - case portainer.QuayRegistry, portainer.AzureRegistry, portainer.CustomRegistry, portainer.GitlabRegistry, portainer.ProGetRegistry: + case portainer.QuayRegistry, portainer.AzureRegistry, portainer.CustomRegistry, portainer.GitlabRegistry, portainer.ProGetRegistry, portainer.DockerHubRegistry: default: - return errors.New("invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry), 4 (Gitlab registry) or 5 (ProGet registry)") + return errors.New("invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry), 4 (Gitlab registry), 5 (ProGet registry) or 6 (DockerHub)") } if payload.Type == portainer.ProGetRegistry && payload.BaseURL == "" { @@ -71,24 +73,41 @@ func (payload *registryCreatePayload) Validate(_ *http.Request) error { // @failure 500 "Server error" // @router /registries [post] func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + if !securityContext.IsAdmin { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to create registry", httperrors.ErrResourceAccessDenied} + } + var payload registryCreatePayload - err := request.DecodeAndValidateJSONPayload(r, &payload) + err = request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } registry := &portainer.Registry{ - Type: portainer.RegistryType(payload.Type), - Name: payload.Name, - URL: payload.URL, - BaseURL: payload.BaseURL, - Authentication: payload.Authentication, - Username: payload.Username, - Password: payload.Password, - UserAccessPolicies: portainer.UserAccessPolicies{}, - TeamAccessPolicies: portainer.TeamAccessPolicies{}, - Gitlab: payload.Gitlab, - Quay: payload.Quay, + Type: portainer.RegistryType(payload.Type), + Name: payload.Name, + URL: payload.URL, + BaseURL: payload.BaseURL, + Authentication: payload.Authentication, + Username: payload.Username, + Password: payload.Password, + RegistryAccesses: portainer.RegistryAccesses{}, + Gitlab: payload.Gitlab, + Quay: payload.Quay, + } + + registries, err := handler.DataStore.Registry().Registries() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} + } + for _, r := range registries { + if handler.registriesHaveSameURLAndCredentials(&r, registry) { + return &httperror.HandlerError{http.StatusConflict, "Another registry with the same URL and credentials already exists", errors.New("A registry is already defined for this URL and credentials")} + } } err = handler.DataStore.Registry().CreateRegistry(registry) @@ -96,6 +115,6 @@ func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the registry inside the database", err} } - hideFields(registry) + hideFields(registry, true) return response.JSON(w, registry) } diff --git a/api/http/handler/registries/registry_create_test.go b/api/http/handler/registries/registry_create_test.go index 2e76bce28..722301164 100644 --- a/api/http/handler/registries/registry_create_test.go +++ b/api/http/handler/registries/registry_create_test.go @@ -8,6 +8,7 @@ import ( "testing" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" "github.com/stretchr/testify/assert" ) @@ -82,6 +83,14 @@ func TestHandler_registryCreate(t *testing.T) { r := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(payloadBytes)) w := httptest.NewRecorder() + restrictedContext := &security.RestrictedRequestContext{ + IsAdmin: true, + UserID: portainer.UserID(1), + } + + ctx := security.StoreRestrictedRequestContext(r, restrictedContext) + r = r.WithContext(ctx) + registry := portainer.Registry{} handler := Handler{} handler.DataStore = testDataStore{ diff --git a/api/http/handler/registries/registry_delete.go b/api/http/handler/registries/registry_delete.go index a5cf8d417..d5db6769a 100644 --- a/api/http/handler/registries/registry_delete.go +++ b/api/http/handler/registries/registry_delete.go @@ -8,6 +8,8 @@ import ( "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/errors" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" ) // @id RegistryDelete @@ -23,6 +25,14 @@ import ( // @failure 500 "Server error" // @router /registries/{id} [delete] func (handler *Handler) registryDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + if !securityContext.IsAdmin { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to delete registry", httperrors.ErrResourceAccessDenied} + } + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} diff --git a/api/http/handler/registries/registry_inspect.go b/api/http/handler/registries/registry_inspect.go index 7803f420d..29b57fbc2 100644 --- a/api/http/handler/registries/registry_inspect.go +++ b/api/http/handler/registries/registry_inspect.go @@ -5,7 +5,6 @@ import ( portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" - "github.com/portainer/portainer/api/http/errors" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" @@ -27,6 +26,7 @@ import ( // @failure 500 "Server error" // @router /registries/{id} [get] func (handler *Handler) registryInspect(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} @@ -39,11 +39,6 @@ func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request) 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", errors.ErrEndpointAccessDenied} - } - - hideFields(registry) + hideFields(registry, false) return response.JSON(w, registry) } diff --git a/api/http/handler/registries/registry_list.go b/api/http/handler/registries/registry_list.go index a387f7f32..8e9519f68 100644 --- a/api/http/handler/registries/registry_list.go +++ b/api/http/handler/registries/registry_list.go @@ -5,6 +5,7 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/response" + httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" ) @@ -21,21 +22,18 @@ import ( // @failure 500 "Server error" // @router /registries [get] func (handler *Handler) registryList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + if !securityContext.IsAdmin { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to list registries, use /endpoints/:endpointId/registries route instead", httperrors.ErrResourceAccessDenied} + } + registries, err := handler.DataStore.Registry().Registries() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} } - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} - } - - filteredRegistries := security.FilterRegistries(registries, securityContext) - - for idx := range filteredRegistries { - hideFields(&filteredRegistries[idx]) - } - - return response.JSON(w, filteredRegistries) + return response.JSON(w, registries) } diff --git a/api/http/handler/registries/registry_update.go b/api/http/handler/registries/registry_update.go index 66c6473d7..bf89fab82 100644 --- a/api/http/handler/registries/registry_update.go +++ b/api/http/handler/registries/registry_update.go @@ -9,18 +9,25 @@ import ( "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" ) type registryUpdatePayload struct { - Name *string `json:",omitempty" example:"my-registry" validate:"required"` - URL *string `json:",omitempty" example:"registry.mydomain.tld:2375/feed" validate:"required"` - BaseURL *string `json:",omitempty" example:"registry.mydomain.tld:2375"` - Authentication *bool `json:",omitempty" example:"false" validate:"required"` - Username *string `json:",omitempty" example:"registry_user"` - Password *string `json:",omitempty" example:"registry_password"` - UserAccessPolicies portainer.UserAccessPolicies `json:",omitempty"` - TeamAccessPolicies portainer.TeamAccessPolicies `json:",omitempty"` - Quay *portainer.QuayRegistryData + // Name that will be used to identify this registry + Name *string `validate:"required" example:"my-registry"` + // URL or IP address of the Docker registry + URL *string `validate:"required" example:"registry.mydomain.tld:2375"` + // BaseURL is used for quay registry + BaseURL *string `json:",omitempty" example:"registry.mydomain.tld:2375"` + // Is authentication against this registry enabled + Authentication *bool `example:"false" validate:"required"` + // Username used to authenticate against this registry. Required when Authentication is true + Username *string `example:"registry_user"` + // Password used to authenticate against this registry. required when Authentication is true + Password *string `example:"registry_password"` + RegistryAccesses *portainer.RegistryAccesses + Quay *portainer.QuayRegistryData } func (payload *registryUpdatePayload) Validate(r *http.Request) error { @@ -44,17 +51,19 @@ func (payload *registryUpdatePayload) Validate(r *http.Request) error { // @failure 500 "Server error" // @router /registries/{id} [put] func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + if !securityContext.IsAdmin { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update registry", httperrors.ErrResourceAccessDenied} + } + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} } - var payload registryUpdatePayload - err = request.DecodeAndValidateJSONPayload(r, &payload) - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} - } - registry, err := handler.DataStore.Registry().Registry(portainer.RegistryID(registryID)) if err == bolterrors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} @@ -62,23 +71,17 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} } + var payload registryUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + if payload.Name != nil { registry.Name = *payload.Name } - if payload.URL != nil { - registries, err := handler.DataStore.Registry().Registries() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} - } - for _, r := range registries { - if r.ID != registry.ID && hasSameURL(&r, registry) { - return &httperror.HandlerError{http.StatusConflict, "Another registry with the same URL already exists", errors.New("A registry is already defined for this URL")} - } - } - - registry.URL = *payload.URL - } + shouldUpdateSecrets := false if registry.Type == portainer.ProGetRegistry && payload.BaseURL != nil { registry.BaseURL = *payload.BaseURL @@ -87,6 +90,7 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) * if payload.Authentication != nil { if *payload.Authentication { registry.Authentication = true + shouldUpdateSecrets = shouldUpdateSecrets || (payload.Username != nil && *payload.Username != registry.Username) || (payload.Password != nil && *payload.Password != registry.Password) if payload.Username != nil { registry.Username = *payload.Username @@ -103,12 +107,35 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) * } } - if payload.UserAccessPolicies != nil { - registry.UserAccessPolicies = payload.UserAccessPolicies + if payload.URL != nil { + shouldUpdateSecrets = shouldUpdateSecrets || (*payload.URL != registry.URL) + + registry.URL = *payload.URL + registries, err := handler.DataStore.Registry().Registries() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} + } + for _, r := range registries { + if r.ID != registry.ID && handler.registriesHaveSameURLAndCredentials(&r, registry) { + return &httperror.HandlerError{http.StatusConflict, "Another registry with the same URL and credentials already exists", errors.New("A registry is already defined for this URL and credentials")} + } + } } - if payload.TeamAccessPolicies != nil { - registry.TeamAccessPolicies = payload.TeamAccessPolicies + if shouldUpdateSecrets { + for endpointID, endpointAccess := range registry.RegistryAccesses { + endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update access to registry", err} + } + + if endpoint.Type == portainer.KubernetesLocalEnvironment || endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { + err = handler.updateEndpointRegistryAccess(endpoint, registry, endpointAccess) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update access to registry", err} + } + } + } } if payload.Quay != nil { @@ -123,10 +150,24 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) * return response.JSON(w, registry) } -func hasSameURL(r1, r2 *portainer.Registry) bool { - if r1.Type != portainer.GitlabRegistry || r2.Type != portainer.GitlabRegistry { - return r1.URL == r2.URL +func (handler *Handler) updateEndpointRegistryAccess(endpoint *portainer.Endpoint, registry *portainer.Registry, endpointAccess portainer.RegistryAccessPolicies) error { + + cli, err := handler.K8sClientFactory.GetKubeClient(endpoint) + if err != nil { + return err } - return r1.URL == r2.URL && r1.Gitlab.ProjectPath == r2.Gitlab.ProjectPath + for _, namespace := range endpointAccess.Namespaces { + err := cli.DeleteRegistrySecret(registry, namespace) + if err != nil { + return err + } + + err = cli.CreateRegistrySecret(registry, namespace) + if err != nil { + return err + } + } + + return nil } diff --git a/api/http/handler/registries/registry_update_test.go b/api/http/handler/registries/registry_update_test.go index 8e0fdabc7..d2767ff4f 100644 --- a/api/http/handler/registries/registry_update_test.go +++ b/api/http/handler/registries/registry_update_test.go @@ -8,6 +8,7 @@ import ( "testing" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" "github.com/stretchr/testify/assert" ) @@ -48,6 +49,14 @@ func TestHandler_registryUpdate(t *testing.T) { r := httptest.NewRequest(http.MethodPut, "/registries/5", bytes.NewReader(payloadBytes)) w := httptest.NewRecorder() + restrictedContext := &security.RestrictedRequestContext{ + IsAdmin: true, + UserID: portainer.UserID(1), + } + + ctx := security.StoreRestrictedRequestContext(r, restrictedContext) + r = r.WithContext(ctx) + updatedRegistry := portainer.Registry{} handler := newHandler(nil) handler.initRouter(TestBouncer{}) diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go index 0a3b8e526..f66c2572d 100644 --- a/api/http/handler/stacks/create_compose_stack.go +++ b/api/http/handler/stacks/create_compose_stack.go @@ -290,7 +290,6 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, type composeStackDeploymentConfig struct { stack *portainer.Stack endpoint *portainer.Endpoint - dockerhub *portainer.DockerHub registries []portainer.Registry isAdmin bool user *portainer.User @@ -302,26 +301,20 @@ func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portai return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err} } - dockerhub, err := handler.DataStore.DockerHub().DockerHub() + user, err := handler.DataStore.User().User(securityContext.UserID) if err != nil { - return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve DockerHub details from the database", Err: err} + return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to load user information from the database", Err: err} } registries, err := handler.DataStore.Registry().Registries() if err != nil { return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve registries from the database", Err: err} } - filteredRegistries := security.FilterRegistries(registries, securityContext) - - user, err := handler.DataStore.User().User(securityContext.UserID) - if err != nil { - return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to load user information from the database", Err: err} - } + filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID) config := &composeStackDeploymentConfig{ stack: stack, endpoint: endpoint, - dockerhub: dockerhub, registries: filteredRegistries, isAdmin: securityContext.IsAdmin, user: user, @@ -366,7 +359,7 @@ func (handler *Handler) deployComposeStack(config *composeStackDeploymentConfig) handler.stackCreationMutex.Lock() defer handler.stackCreationMutex.Unlock() - handler.SwarmStackManager.Login(config.dockerhub, config.registries, config.endpoint) + handler.SwarmStackManager.Login(config.registries, config.endpoint) err = handler.ComposeStackManager.Up(config.stack, config.endpoint) if err != nil { diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go index 747f1b456..b439addbf 100644 --- a/api/http/handler/stacks/create_swarm_stack.go +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -300,7 +300,6 @@ func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r type swarmStackDeploymentConfig struct { stack *portainer.Stack endpoint *portainer.Endpoint - dockerhub *portainer.DockerHub registries []portainer.Registry prune bool isAdmin bool @@ -313,26 +312,20 @@ func (handler *Handler) createSwarmDeployConfig(r *http.Request, stack *portaine return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} } - dockerhub, err := handler.DataStore.DockerHub().DockerHub() + user, err := handler.DataStore.User().User(securityContext.UserID) if err != nil { - return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err} + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to load user information from the database", err} } registries, err := handler.DataStore.Registry().Registries() if err != nil { return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} } - filteredRegistries := security.FilterRegistries(registries, securityContext) - - user, err := handler.DataStore.User().User(securityContext.UserID) - if err != nil { - return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to load user information from the database", err} - } + filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID) config := &swarmStackDeploymentConfig{ stack: stack, endpoint: endpoint, - dockerhub: dockerhub, registries: filteredRegistries, prune: prune, isAdmin: securityContext.IsAdmin, @@ -367,7 +360,7 @@ func (handler *Handler) deploySwarmStack(config *swarmStackDeploymentConfig) err handler.stackCreationMutex.Lock() defer handler.stackCreationMutex.Unlock() - handler.SwarmStackManager.Login(config.dockerhub, config.registries, config.endpoint) + handler.SwarmStackManager.Login(config.registries, config.endpoint) err = handler.SwarmStackManager.Deploy(config.stack, config.prune, config.endpoint) if err != nil { diff --git a/api/http/proxy/factory/azure/containergroup.go b/api/http/proxy/factory/azure/containergroup.go index 242968de5..99c7b2a88 100644 --- a/api/http/proxy/factory/azure/containergroup.go +++ b/api/http/proxy/factory/azure/containergroup.go @@ -5,7 +5,7 @@ import ( "net/http" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/http/proxy/factory/utils" ) // proxy for /subscriptions/*/resourceGroups/*/providers/Microsoft.ContainerInstance/containerGroups/* @@ -49,7 +49,7 @@ func (transport *Transport) proxyContainerGroupPutRequest(request *http.Request) errObj := map[string]string{ "message": "A container instance with the same name already exists inside the selected resource group", } - err = responseutils.RewriteResponse(resp, errObj, http.StatusConflict) + err = utils.RewriteResponse(resp, errObj, http.StatusConflict) return resp, err } @@ -58,7 +58,7 @@ func (transport *Transport) proxyContainerGroupPutRequest(request *http.Request) return response, err } - responseObject, err := responseutils.GetResponseAsJSONObject(response) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return response, err } @@ -80,7 +80,7 @@ func (transport *Transport) proxyContainerGroupPutRequest(request *http.Request) responseObject = decorateObject(responseObject, resourceControl) - err = responseutils.RewriteResponse(response, responseObject, http.StatusOK) + err = utils.RewriteResponse(response, responseObject, http.StatusOK) if err != nil { return response, err } @@ -94,7 +94,7 @@ func (transport *Transport) proxyContainerGroupGetRequest(request *http.Request) return response, err } - responseObject, err := responseutils.GetResponseAsJSONObject(response) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return nil, err } @@ -106,7 +106,7 @@ func (transport *Transport) proxyContainerGroupGetRequest(request *http.Request) responseObject = transport.decorateContainerGroup(responseObject, context) - responseutils.RewriteResponse(response, responseObject, http.StatusOK) + utils.RewriteResponse(response, responseObject, http.StatusOK) return response, nil } @@ -118,7 +118,7 @@ func (transport *Transport) proxyContainerGroupDeleteRequest(request *http.Reque } if !transport.userCanDeleteContainerGroup(request, context) { - return responseutils.WriteAccessDeniedResponse() + return utils.WriteAccessDeniedResponse() } response, err := http.DefaultTransport.RoundTrip(request) @@ -126,14 +126,14 @@ func (transport *Transport) proxyContainerGroupDeleteRequest(request *http.Reque return response, err } - responseObject, err := responseutils.GetResponseAsJSONObject(response) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return nil, err } transport.removeResourceControl(responseObject, context) - responseutils.RewriteResponse(response, responseObject, http.StatusOK) + utils.RewriteResponse(response, responseObject, http.StatusOK) return response, nil } diff --git a/api/http/proxy/factory/azure/containergroups.go b/api/http/proxy/factory/azure/containergroups.go index ccb441b3b..e567ec5b7 100644 --- a/api/http/proxy/factory/azure/containergroups.go +++ b/api/http/proxy/factory/azure/containergroups.go @@ -4,7 +4,7 @@ import ( "fmt" "net/http" - "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/http/proxy/factory/utils" ) // proxy for /subscriptions/*/providers/Microsoft.ContainerInstance/containerGroups @@ -23,7 +23,7 @@ func (transport *Transport) proxyContainerGroupsGetRequest(request *http.Request return nil, err } - responseObject, err := responseutils.GetResponseAsJSONObject(response) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return nil, err } @@ -39,10 +39,10 @@ func (transport *Transport) proxyContainerGroupsGetRequest(request *http.Request filteredValue := transport.filterContainerGroups(decoratedValue, context) responseObject["value"] = filteredValue - responseutils.RewriteResponse(response, responseObject, http.StatusOK) + utils.RewriteResponse(response, responseObject, http.StatusOK) } else { return nil, fmt.Errorf("The container groups response has no value property") } return response, nil -} \ No newline at end of file +} diff --git a/api/http/proxy/factory/docker/access_control.go b/api/http/proxy/factory/docker/access_control.go index 7f1cb4157..8db016ed9 100644 --- a/api/http/proxy/factory/docker/access_control.go +++ b/api/http/proxy/factory/docker/access_control.go @@ -7,7 +7,7 @@ import ( "github.com/portainer/portainer/api/internal/stackutils" - "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/http/proxy/factory/utils" "github.com/portainer/portainer/api/internal/authorization" portainer "github.com/portainer/portainer/api" @@ -162,7 +162,7 @@ func (transport *Transport) applyAccessControlOnResource(parameters *resourceOpe systemResourceControl := findSystemNetworkResourceControl(responseObject) if systemResourceControl != nil { responseObject = decorateObject(responseObject, systemResourceControl) - return responseutils.RewriteResponse(response, responseObject, http.StatusOK) + return utils.RewriteResponse(response, responseObject, http.StatusOK) } } @@ -175,15 +175,15 @@ func (transport *Transport) applyAccessControlOnResource(parameters *resourceOpe } if resourceControl == nil && (executor.operationContext.isAdmin) { - return responseutils.RewriteResponse(response, responseObject, http.StatusOK) + return utils.RewriteResponse(response, responseObject, http.StatusOK) } if executor.operationContext.isAdmin || (resourceControl != nil && authorization.UserCanAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl)) { responseObject = decorateObject(responseObject, resourceControl) - return responseutils.RewriteResponse(response, responseObject, http.StatusOK) + return utils.RewriteResponse(response, responseObject, http.StatusOK) } - return responseutils.RewriteAccessDeniedResponse(response) + return utils.RewriteAccessDeniedResponse(response) } func (transport *Transport) applyAccessControlOnResourceList(parameters *resourceOperationParameters, resourceData []interface{}, executor *operationExecutor) ([]interface{}, error) { diff --git a/api/http/proxy/factory/docker/configs.go b/api/http/proxy/factory/docker/configs.go index 74d10759d..4820b74c6 100644 --- a/api/http/proxy/factory/docker/configs.go +++ b/api/http/proxy/factory/docker/configs.go @@ -7,7 +7,7 @@ import ( "github.com/docker/docker/client" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/http/proxy/factory/utils" "github.com/portainer/portainer/api/internal/authorization" ) @@ -34,7 +34,7 @@ func getInheritedResourceControlFromConfigLabels(dockerClient *client.Client, en 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) + responseArray, err := utils.GetResponseAsJSONArray(response) if err != nil { return err } @@ -50,7 +50,7 @@ func (transport *Transport) configListOperation(response *http.Response, executo return err } - return responseutils.RewriteResponse(response, responseArray, http.StatusOK) + return utils.RewriteResponse(response, responseArray, http.StatusOK) } // configInspectOperation extracts the response as a JSON object, verify that the user @@ -58,7 +58,7 @@ func (transport *Transport) configListOperation(response *http.Response, executo 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) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return err } @@ -78,9 +78,9 @@ func (transport *Transport) configInspectOperation(response *http.Response, exec // https://docs.docker.com/engine/api/v1.37/#operation/ConfigList // https://docs.docker.com/engine/api/v1.37/#operation/ConfigInspect func selectorConfigLabels(responseObject map[string]interface{}) map[string]interface{} { - secretSpec := responseutils.GetJSONObject(responseObject, "Spec") + secretSpec := utils.GetJSONObject(responseObject, "Spec") if secretSpec != nil { - secretLabelsObject := responseutils.GetJSONObject(secretSpec, "Labels") + secretLabelsObject := utils.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 index 97108355e..dc92ae379 100644 --- a/api/http/proxy/factory/docker/containers.go +++ b/api/http/proxy/factory/docker/containers.go @@ -10,7 +10,7 @@ import ( "github.com/docker/docker/client" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/http/proxy/factory/utils" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" ) @@ -46,7 +46,7 @@ func getInheritedResourceControlFromContainerLabels(dockerClient *client.Client, 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) + responseArray, err := utils.GetResponseAsJSONArray(response) if err != nil { return err } @@ -69,7 +69,7 @@ func (transport *Transport) containerListOperation(response *http.Response, exec } } - return responseutils.RewriteResponse(response, responseArray, http.StatusOK) + return utils.RewriteResponse(response, responseArray, http.StatusOK) } // containerInspectOperation extracts the response as a JSON object, verify that the user @@ -77,7 +77,7 @@ func (transport *Transport) containerListOperation(response *http.Response, exec 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) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return err } @@ -96,9 +96,9 @@ func (transport *Transport) containerInspectOperation(response *http.Response, e // 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") + containerConfigObject := utils.GetJSONObject(responseObject, "Config") if containerConfigObject != nil { - containerLabelsObject := responseutils.GetJSONObject(containerConfigObject, "Labels") + containerLabelsObject := utils.GetJSONObject(containerConfigObject, "Labels") return containerLabelsObject } return nil @@ -109,7 +109,7 @@ func selectorContainerLabelsFromContainerInspectOperation(responseObject map[str // 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") + containerLabelsObject := utils.GetJSONObject(responseObject, "Labels") return containerLabelsObject } diff --git a/api/http/proxy/factory/docker/networks.go b/api/http/proxy/factory/docker/networks.go index b38ce68ec..05df57589 100644 --- a/api/http/proxy/factory/docker/networks.go +++ b/api/http/proxy/factory/docker/networks.go @@ -10,7 +10,7 @@ import ( "github.com/docker/docker/client" - "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/http/proxy/factory/utils" "github.com/portainer/portainer/api/internal/authorization" ) @@ -38,7 +38,7 @@ func getInheritedResourceControlFromNetworkLabels(dockerClient *client.Client, e 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) + responseArray, err := utils.GetResponseAsJSONArray(response) if err != nil { return err } @@ -54,7 +54,7 @@ func (transport *Transport) networkListOperation(response *http.Response, execut return err } - return responseutils.RewriteResponse(response, responseArray, http.StatusOK) + return utils.RewriteResponse(response, responseArray, http.StatusOK) } // networkInspectOperation extracts the response as a JSON object, verify that the user @@ -62,7 +62,7 @@ func (transport *Transport) networkListOperation(response *http.Response, execut 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) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return err } @@ -99,5 +99,5 @@ func findSystemNetworkResourceControl(networkObject map[string]interface{}) *por // 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") + return utils.GetJSONObject(responseObject, "Labels") } diff --git a/api/http/proxy/factory/docker/registry.go b/api/http/proxy/factory/docker/registry.go index c07ebae3d..38f0bd903 100644 --- a/api/http/proxy/factory/docker/registry.go +++ b/api/http/proxy/factory/docker/registry.go @@ -1,39 +1,43 @@ package docker import ( - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" ) type ( registryAccessContext struct { isAdmin bool - userID portainer.UserID + user *portainer.User + endpointID portainer.EndpointID teamMemberships []portainer.TeamMembership registries []portainer.Registry - dockerHub *portainer.DockerHub } + registryAuthenticationHeader struct { Username string `json:"username"` Password string `json:"password"` Serveraddress string `json:"serveraddress"` } + + portainerRegistryAuthenticationHeader struct { + RegistryId portainer.RegistryID `json:"registryId"` + } ) -func createRegistryAuthenticationHeader(serverAddress string, accessContext *registryAccessContext) *registryAuthenticationHeader { +func createRegistryAuthenticationHeader(registryId portainer.RegistryID, accessContext *registryAccessContext) *registryAuthenticationHeader { var authenticationHeader *registryAuthenticationHeader - if serverAddress == "" { + if registryId == 0 { // dockerhub (anonymous) authenticationHeader = ®istryAuthenticationHeader{ - Username: accessContext.dockerHub.Username, - Password: accessContext.dockerHub.Password, Serveraddress: "docker.io", } - } else { + } else { // any "custom" registry var matchingRegistry *portainer.Registry for _, registry := range accessContext.registries { - if registry.URL == serverAddress && - (accessContext.isAdmin || (!accessContext.isAdmin && security.AuthorizedRegistryAccess(®istry, accessContext.userID, accessContext.teamMemberships))) { + if registry.ID == registryId && + (accessContext.isAdmin || + security.AuthorizedRegistryAccess(®istry, accessContext.user, accessContext.teamMemberships, accessContext.endpointID)) { matchingRegistry = ®istry break } diff --git a/api/http/proxy/factory/docker/secrets.go b/api/http/proxy/factory/docker/secrets.go index 148073c02..6f7c203f8 100644 --- a/api/http/proxy/factory/docker/secrets.go +++ b/api/http/proxy/factory/docker/secrets.go @@ -7,7 +7,7 @@ import ( "github.com/docker/docker/client" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/http/proxy/factory/utils" "github.com/portainer/portainer/api/internal/authorization" ) @@ -34,7 +34,7 @@ func getInheritedResourceControlFromSecretLabels(dockerClient *client.Client, en 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) + responseArray, err := utils.GetResponseAsJSONArray(response) if err != nil { return err } @@ -50,7 +50,7 @@ func (transport *Transport) secretListOperation(response *http.Response, executo return err } - return responseutils.RewriteResponse(response, responseArray, http.StatusOK) + return utils.RewriteResponse(response, responseArray, http.StatusOK) } // secretInspectOperation extracts the response as a JSON object, verify that the user @@ -58,7 +58,7 @@ func (transport *Transport) secretListOperation(response *http.Response, executo 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) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return err } @@ -78,9 +78,9 @@ func (transport *Transport) secretInspectOperation(response *http.Response, exec // https://docs.docker.com/engine/api/v1.37/#operation/SecretList // https://docs.docker.com/engine/api/v1.37/#operation/SecretInspect func selectorSecretLabels(responseObject map[string]interface{}) map[string]interface{} { - secretSpec := responseutils.GetJSONObject(responseObject, "Spec") + secretSpec := utils.GetJSONObject(responseObject, "Spec") if secretSpec != nil { - secretLabelsObject := responseutils.GetJSONObject(secretSpec, "Labels") + secretLabelsObject := utils.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 index 683859f73..205c48c60 100644 --- a/api/http/proxy/factory/docker/services.go +++ b/api/http/proxy/factory/docker/services.go @@ -12,7 +12,7 @@ import ( "github.com/docker/docker/client" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/http/proxy/factory/utils" "github.com/portainer/portainer/api/internal/authorization" ) @@ -39,7 +39,7 @@ func getInheritedResourceControlFromServiceLabels(dockerClient *client.Client, e 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) + responseArray, err := utils.GetResponseAsJSONArray(response) if err != nil { return err } @@ -55,7 +55,7 @@ func (transport *Transport) serviceListOperation(response *http.Response, execut return err } - return responseutils.RewriteResponse(response, responseArray, http.StatusOK) + return utils.RewriteResponse(response, responseArray, http.StatusOK) } // serviceInspectOperation extracts the response as a JSON object, verify that the user @@ -63,7 +63,7 @@ func (transport *Transport) serviceListOperation(response *http.Response, execut 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) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return err } @@ -83,9 +83,9 @@ func (transport *Transport) serviceInspectOperation(response *http.Response, exe // 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") + serviceSpecObject := utils.GetJSONObject(responseObject, "Spec") if serviceSpecObject != nil { - return responseutils.GetJSONObject(serviceSpecObject, "Labels") + return utils.GetJSONObject(serviceSpecObject, "Labels") } return nil } diff --git a/api/http/proxy/factory/docker/swarm.go b/api/http/proxy/factory/docker/swarm.go index bc3ff9c4d..be39a4b0f 100644 --- a/api/http/proxy/factory/docker/swarm.go +++ b/api/http/proxy/factory/docker/swarm.go @@ -3,7 +3,7 @@ package docker import ( "net/http" - "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/http/proxy/factory/utils" ) // swarmInspectOperation extracts the response as a JSON object and rewrites the response based @@ -11,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 := responseutils.GetResponseAsJSONObject(response) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return err } @@ -21,5 +21,5 @@ func swarmInspectOperation(response *http.Response, executor *operationExecutor) delete(responseObject, "TLSInfo") } - return responseutils.RewriteResponse(response, responseObject, http.StatusOK) + return utils.RewriteResponse(response, responseObject, http.StatusOK) } diff --git a/api/http/proxy/factory/docker/tasks.go b/api/http/proxy/factory/docker/tasks.go index ad13398fd..f91c1a81c 100644 --- a/api/http/proxy/factory/docker/tasks.go +++ b/api/http/proxy/factory/docker/tasks.go @@ -4,7 +4,7 @@ import ( "net/http" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/http/proxy/factory/utils" ) const ( @@ -16,7 +16,7 @@ const ( 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) + responseArray, err := utils.GetResponseAsJSONArray(response) if err != nil { return err } @@ -32,18 +32,18 @@ func (transport *Transport) taskListOperation(response *http.Response, executor return err } - return responseutils.RewriteResponse(response, responseArray, http.StatusOK) + return utils.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") + taskSpecObject := utils.GetJSONObject(responseObject, "Spec") if taskSpecObject != nil { - containerSpecObject := responseutils.GetJSONObject(taskSpecObject, "ContainerSpec") + containerSpecObject := utils.GetJSONObject(taskSpecObject, "ContainerSpec") if containerSpecObject != nil { - return responseutils.GetJSONObject(containerSpecObject, "Labels") + return utils.GetJSONObject(containerSpecObject, "Labels") } } return nil diff --git a/api/http/proxy/factory/docker/transport.go b/api/http/proxy/factory/docker/transport.go index daeebdc4b..501a521fa 100644 --- a/api/http/proxy/factory/docker/transport.go +++ b/api/http/proxy/factory/docker/transport.go @@ -5,17 +5,19 @@ import ( "encoding/base64" "encoding/json" "errors" + "fmt" "io/ioutil" "log" "net/http" "path" "regexp" + "strconv" "strings" "github.com/docker/docker/client" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/docker" - "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/http/proxy/factory/utils" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" ) @@ -169,12 +171,31 @@ func (transport *Transport) proxyAgentRequest(r *http.Request) (*http.Response, // volume browser request return transport.restrictedResourceOperation(r, resourceID, volumeName, portainer.VolumeResourceControl, true) case strings.HasPrefix(requestPath, "/dockerhub"): - dockerhub, err := transport.dataStore.DockerHub().DockerHub() + requestPath, registryIdString := path.Split(r.URL.Path) + + registryID, err := strconv.Atoi(registryIdString) if err != nil { - return nil, err + return nil, fmt.Errorf("missing registry id: %w", err) } - newBody, err := json.Marshal(dockerhub) + r.URL.Path = strings.TrimSuffix(requestPath, "/") + + registry := &portainer.Registry{ + Type: portainer.DockerHubRegistry, + } + + if registryID != 0 { + registry, err = transport.dataStore.Registry().Registry(portainer.RegistryID(registryID)) + if err != nil { + return nil, fmt.Errorf("failed fetching registry: %w", err) + } + } + + if registry.Type != portainer.DockerHubRegistry { + return nil, errors.New("Invalid registry type") + } + + newBody, err := json.Marshal(registry) if err != nil { return nil, err } @@ -397,13 +418,13 @@ func (transport *Transport) replaceRegistryAuthenticationHeader(request *http.Re return nil, err } - var originalHeaderData registryAuthenticationHeader + var originalHeaderData portainerRegistryAuthenticationHeader err = json.Unmarshal(decodedHeaderData, &originalHeaderData) if err != nil { return nil, err } - authenticationHeader := createRegistryAuthenticationHeader(originalHeaderData.Serveraddress, accessContext) + authenticationHeader := createRegistryAuthenticationHeader(originalHeaderData.RegistryId, accessContext) headerData, err := json.Marshal(authenticationHeader) if err != nil { @@ -433,7 +454,7 @@ func (transport *Transport) restrictedResourceOperation(request *http.Request, r } if !securitySettings.AllowVolumeBrowserForRegularUsers { - return responseutils.WriteAccessDeniedResponse() + return utils.WriteAccessDeniedResponse() } } @@ -468,12 +489,12 @@ func (transport *Transport) restrictedResourceOperation(request *http.Request, r } if inheritedResourceControl == nil || !authorization.UserCanAccessResource(tokenData.ID, userTeamIDs, inheritedResourceControl) { - return responseutils.WriteAccessDeniedResponse() + return utils.WriteAccessDeniedResponse() } } if resourceControl != nil && !authorization.UserCanAccessResource(tokenData.ID, userTeamIDs, resourceControl) { - return responseutils.WriteAccessDeniedResponse() + return utils.WriteAccessDeniedResponse() } } @@ -537,7 +558,7 @@ func (transport *Transport) interceptAndRewriteRequest(request *http.Request, op // https://docs.docker.com/engine/api/v1.37/#operation/SecretCreate // https://docs.docker.com/engine/api/v1.37/#operation/ConfigCreate func (transport *Transport) decorateGenericResourceCreationResponse(response *http.Response, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType, userID portainer.UserID) error { - responseObject, err := responseutils.GetResponseAsJSONObject(response) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return err } @@ -556,7 +577,7 @@ func (transport *Transport) decorateGenericResourceCreationResponse(response *ht responseObject = decorateObject(responseObject, resourceControl) - return responseutils.RewriteResponse(response, responseObject, http.StatusOK) + return utils.RewriteResponse(response, responseObject, http.StatusOK) } func (transport *Transport) decorateGenericResourceCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) { @@ -619,7 +640,7 @@ func (transport *Transport) administratorOperation(request *http.Request) (*http } if tokenData.Role != portainer.AdministratorRole { - return responseutils.WriteAccessDeniedResponse() + return utils.WriteAccessDeniedResponse() } return transport.executeDockerRequest(request) @@ -632,15 +653,15 @@ func (transport *Transport) createRegistryAccessContext(request *http.Request) ( } accessContext := ®istryAccessContext{ - isAdmin: true, - userID: tokenData.ID, + isAdmin: true, + endpointID: transport.endpoint.ID, } - hub, err := transport.dataStore.DockerHub().DockerHub() + user, err := transport.dataStore.User().User(tokenData.ID) if err != nil { return nil, err } - accessContext.dockerHub = hub + accessContext.user = user registries, err := transport.dataStore.Registry().Registries() if err != nil { @@ -648,7 +669,7 @@ func (transport *Transport) createRegistryAccessContext(request *http.Request) ( } accessContext.registries = registries - if tokenData.Role != portainer.AdministratorRole { + if user.Role != portainer.AdministratorRole { accessContext.isAdmin = false teamMemberships, err := transport.dataStore.TeamMembership().TeamMembershipsByUserID(tokenData.ID) diff --git a/api/http/proxy/factory/docker/volumes.go b/api/http/proxy/factory/docker/volumes.go index 1f77c4585..2c0b304f7 100644 --- a/api/http/proxy/factory/docker/volumes.go +++ b/api/http/proxy/factory/docker/volumes.go @@ -9,7 +9,7 @@ import ( "github.com/docker/docker/client" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/http/proxy/factory/utils" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" ) @@ -37,7 +37,7 @@ func getInheritedResourceControlFromVolumeLabels(dockerClient *client.Client, en 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) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return err } @@ -68,7 +68,7 @@ func (transport *Transport) volumeListOperation(response *http.Response, executo responseObject["Volumes"] = volumeData } - return responseutils.RewriteResponse(response, responseObject, http.StatusOK) + return utils.RewriteResponse(response, responseObject, http.StatusOK) } // volumeInspectOperation extracts the response as a JSON object, verify that the user @@ -76,7 +76,7 @@ func (transport *Transport) volumeListOperation(response *http.Response, executo 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) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return err } @@ -101,7 +101,7 @@ func (transport *Transport) volumeInspectOperation(response *http.Response, exec // 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") + return utils.GetJSONObject(responseObject, "Labels") } func (transport *Transport) decorateVolumeResourceCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) { @@ -142,7 +142,7 @@ func (transport *Transport) decorateVolumeResourceCreationOperation(request *htt } func (transport *Transport) decorateVolumeCreationResponse(response *http.Response, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType, userID portainer.UserID) error { - responseObject, err := responseutils.GetResponseAsJSONObject(response) + responseObject, err := utils.GetResponseAsJSONObject(response) if err != nil { return err } @@ -159,7 +159,7 @@ func (transport *Transport) decorateVolumeCreationResponse(response *http.Respon responseObject = decorateObject(responseObject, resourceControl) - return responseutils.RewriteResponse(response, responseObject, http.StatusOK) + return utils.RewriteResponse(response, responseObject, http.StatusOK) } func (transport *Transport) restrictedVolumeOperation(requestPath string, request *http.Request) (*http.Response, error) { diff --git a/api/http/proxy/factory/kubernetes.go b/api/http/proxy/factory/kubernetes.go index a1774c0d2..d4aba1769 100644 --- a/api/http/proxy/factory/kubernetes.go +++ b/api/http/proxy/factory/kubernetes.go @@ -39,7 +39,7 @@ func (factory *ProxyFactory) newKubernetesLocalProxy(endpoint *portainer.Endpoin return nil, err } - transport, err := kubernetes.NewLocalTransport(tokenManager) + transport, err := kubernetes.NewLocalTransport(tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore) if err != nil { return nil, err } @@ -72,7 +72,7 @@ func (factory *ProxyFactory) newKubernetesEdgeHTTPProxy(endpoint *portainer.Endp endpointURL.Scheme = "http" proxy := newSingleHostReverseProxyWithHostHeader(endpointURL) - proxy.Transport = kubernetes.NewEdgeTransport(factory.dataStore, factory.reverseTunnelService, endpoint.ID, tokenManager) + proxy.Transport = kubernetes.NewEdgeTransport(factory.reverseTunnelService, endpoint, tokenManager, factory.kubernetesClientFactory, factory.dataStore) return proxy, nil } @@ -103,7 +103,7 @@ func (factory *ProxyFactory) newKubernetesAgentHTTPSProxy(endpoint *portainer.En } proxy := newSingleHostReverseProxyWithHostHeader(remoteURL) - proxy.Transport = kubernetes.NewAgentTransport(factory.dataStore, factory.signatureService, tlsConfig, tokenManager) + proxy.Transport = kubernetes.NewAgentTransport(factory.signatureService, tlsConfig, tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore) return proxy, nil } diff --git a/api/http/proxy/factory/kubernetes/agent_transport.go b/api/http/proxy/factory/kubernetes/agent_transport.go new file mode 100644 index 000000000..ea5af40a1 --- /dev/null +++ b/api/http/proxy/factory/kubernetes/agent_transport.go @@ -0,0 +1,60 @@ +package kubernetes + +import ( + "crypto/tls" + "net/http" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/kubernetes/cli" +) + +type agentTransport struct { + *baseTransport + signatureService portainer.DigitalSignatureService +} + +// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer agent +func NewAgentTransport(signatureService portainer.DigitalSignatureService, tlsConfig *tls.Config, tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore portainer.DataStore) *agentTransport { + transport := &agentTransport{ + baseTransport: newBaseTransport( + &http.Transport{ + TLSClientConfig: tlsConfig, + }, + tokenManager, + endpoint, + k8sClientFactory, + dataStore, + ), + signatureService: signatureService, + } + + return transport +} + +// RoundTrip is the implementation of the the http.RoundTripper interface +func (transport *agentTransport) RoundTrip(request *http.Request) (*http.Response, error) { + token, err := getRoundTripToken(request, transport.tokenManager) + if err != nil { + return nil, err + } + + request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token) + + if strings.HasPrefix(request.URL.Path, "/v2") { + err := decorateAgentRequest(request, transport.dataStore) + if err != nil { + return nil, err + } + } + + 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) + + return transport.baseTransport.RoundTrip(request) +} diff --git a/api/http/proxy/factory/kubernetes/edge_transport.go b/api/http/proxy/factory/kubernetes/edge_transport.go new file mode 100644 index 000000000..22178fa05 --- /dev/null +++ b/api/http/proxy/factory/kubernetes/edge_transport.go @@ -0,0 +1,57 @@ +package kubernetes + +import ( + "net/http" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/kubernetes/cli" +) + +type edgeTransport struct { + *baseTransport + reverseTunnelService portainer.ReverseTunnelService +} + +// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer Edge agent +func NewEdgeTransport(reverseTunnelService portainer.ReverseTunnelService, endpoint *portainer.Endpoint, tokenManager *tokenManager, k8sClientFactory *cli.ClientFactory, dataStore portainer.DataStore) *edgeTransport { + transport := &edgeTransport{ + baseTransport: newBaseTransport( + &http.Transport{}, + tokenManager, + endpoint, + k8sClientFactory, + dataStore, + ), + reverseTunnelService: reverseTunnelService, + } + + return transport +} + +// RoundTrip is the implementation of the the http.RoundTripper interface +func (transport *edgeTransport) RoundTrip(request *http.Request) (*http.Response, error) { + token, err := getRoundTripToken(request, transport.tokenManager) + if err != nil { + return nil, err + } + + request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token) + + if strings.HasPrefix(request.URL.Path, "/v2") { + err := decorateAgentRequest(request, transport.dataStore) + if err != nil { + return nil, err + } + } + + response, err := transport.baseTransport.RoundTrip(request) + + if err == nil { + transport.reverseTunnelService.SetTunnelStatusToActive(transport.endpoint.ID) + } else { + transport.reverseTunnelService.SetTunnelStatusToIdle(transport.endpoint.ID) + } + + return response, err +} diff --git a/api/http/proxy/factory/kubernetes/local_transport.go b/api/http/proxy/factory/kubernetes/local_transport.go new file mode 100644 index 000000000..916d1f6c1 --- /dev/null +++ b/api/http/proxy/factory/kubernetes/local_transport.go @@ -0,0 +1,45 @@ +package kubernetes + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/api/kubernetes/cli" +) + +type localTransport struct { + *baseTransport +} + +// NewLocalTransport returns a new transport that can be used to send requests to the local Kubernetes API +func NewLocalTransport(tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore portainer.DataStore) (*localTransport, error) { + config, err := crypto.CreateTLSConfigurationFromBytes(nil, nil, nil, true, true) + if err != nil { + return nil, err + } + + transport := &localTransport{ + baseTransport: newBaseTransport( + &http.Transport{ + TLSClientConfig: config, + }, + tokenManager, + endpoint, + k8sClientFactory, + dataStore, + ), + } + + return transport, nil +} + +// RoundTrip is the implementation of the the http.RoundTripper interface +func (transport *localTransport) RoundTrip(request *http.Request) (*http.Response, error) { + _, err := transport.prepareRoundTrip(request) + if err != nil { + return nil, err + } + + return transport.baseTransport.RoundTrip(request) +} diff --git a/api/http/proxy/factory/kubernetes/namespaces.go b/api/http/proxy/factory/kubernetes/namespaces.go new file mode 100644 index 000000000..3272649d7 --- /dev/null +++ b/api/http/proxy/factory/kubernetes/namespaces.go @@ -0,0 +1,45 @@ +package kubernetes + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" +) + +func (transport *baseTransport) proxyNamespaceDeleteOperation(request *http.Request, namespace string) (*http.Response, error) { + registries, err := transport.dataStore.Registry().Registries() + if err != nil { + return nil, err + } + + for _, registry := range registries { + for endpointID, registryAccessPolicies := range registry.RegistryAccesses { + if endpointID != transport.endpoint.ID { + continue + } + + namespaces := []string{} + for _, ns := range registryAccessPolicies.Namespaces { + if ns == namespace { + continue + } + namespaces = append(namespaces, ns) + } + + if len(namespaces) != len(registryAccessPolicies.Namespaces) { + updatedAccessPolicies := portainer.RegistryAccessPolicies{ + Namespaces: namespaces, + UserAccessPolicies: registryAccessPolicies.UserAccessPolicies, + TeamAccessPolicies: registryAccessPolicies.TeamAccessPolicies, + } + + registry.RegistryAccesses[endpointID] = updatedAccessPolicies + err := transport.dataStore.Registry().UpdateRegistry(registry.ID, ®istry) + if err != nil { + return nil, err + } + } + } + } + return transport.executeKubernetesRequest(request) +} diff --git a/api/http/proxy/factory/kubernetes/secrets.go b/api/http/proxy/factory/kubernetes/secrets.go new file mode 100644 index 000000000..6fc2ed7f6 --- /dev/null +++ b/api/http/proxy/factory/kubernetes/secrets.go @@ -0,0 +1,170 @@ +package kubernetes + +import ( + "net/http" + "path" + + "github.com/portainer/portainer/api/http/proxy/factory/utils" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/kubernetes/privateregistries" + v1 "k8s.io/api/core/v1" +) + +func (transport *baseTransport) proxySecretRequest(request *http.Request, namespace, requestPath string) (*http.Response, error) { + switch request.Method { + case "POST": + return transport.proxySecretCreationOperation(request) + case "GET": + if path.Base(requestPath) == "secrets" { + return transport.proxySecretListOperation(request) + } + return transport.proxySecretInspectOperation(request) + case "PUT": + return transport.proxySecretUpdateOperation(request) + case "DELETE": + return transport.proxySecretDeleteOperation(request, namespace) + default: + return transport.executeKubernetesRequest(request) + } +} + +func (transport *baseTransport) proxySecretCreationOperation(request *http.Request) (*http.Response, error) { + body, err := utils.GetRequestAsMap(request) + if err != nil { + return nil, err + } + + if isSecretRepresentPrivateRegistry(body) { + return utils.WriteAccessDeniedResponse() + } + + err = utils.RewriteRequest(request, body) + if err != nil { + return nil, err + } + + return transport.executeKubernetesRequest(request) +} + +func (transport *baseTransport) proxySecretListOperation(request *http.Request) (*http.Response, error) { + response, err := transport.executeKubernetesRequest(request) + if err != nil { + return nil, err + } + + isAdmin, err := security.IsAdmin(request) + if err != nil { + return nil, err + } + + if isAdmin { + return response, nil + } + + body, err := utils.GetResponseAsJSONObject(response) + if err != nil { + return nil, err + } + + items := utils.GetArrayObject(body, "items") + + if items == nil { + utils.RewriteResponse(response, body, response.StatusCode) + return response, nil + } + + filteredItems := []interface{}{} + for _, item := range items { + itemObj := item.(map[string]interface{}) + if !isSecretRepresentPrivateRegistry(itemObj) { + filteredItems = append(filteredItems, item) + } + } + + body["items"] = filteredItems + + utils.RewriteResponse(response, body, response.StatusCode) + return response, nil +} + +func (transport *baseTransport) proxySecretInspectOperation(request *http.Request) (*http.Response, error) { + response, err := transport.executeKubernetesRequest(request) + if err != nil { + return nil, err + } + + isAdmin, err := security.IsAdmin(request) + if err != nil { + return nil, err + } + + if isAdmin { + return response, nil + } + + body, err := utils.GetResponseAsJSONObject(response) + if err != nil { + return nil, err + } + + if isSecretRepresentPrivateRegistry(body) { + return utils.WriteAccessDeniedResponse() + } + + err = utils.RewriteResponse(response, body, response.StatusCode) + if err != nil { + return nil, err + } + + return response, nil +} + +func (transport *baseTransport) proxySecretUpdateOperation(request *http.Request) (*http.Response, error) { + body, err := utils.GetRequestAsMap(request) + if err != nil { + return nil, err + } + + if isSecretRepresentPrivateRegistry(body) { + return utils.WriteAccessDeniedResponse() + } + + err = utils.RewriteRequest(request, body) + if err != nil { + return nil, err + } + + return transport.executeKubernetesRequest(request) +} + +func (transport *baseTransport) proxySecretDeleteOperation(request *http.Request, namespace string) (*http.Response, error) { + kcl, err := transport.k8sClientFactory.GetKubeClient(transport.endpoint) + if err != nil { + return nil, err + } + + secretName := path.Base(request.RequestURI) + + isRegistry, err := kcl.IsRegistrySecret(namespace, secretName) + if err != nil { + return nil, err + } + + if isRegistry { + return utils.WriteAccessDeniedResponse() + } + + return transport.executeKubernetesRequest(request) +} + +func isSecretRepresentPrivateRegistry(secret map[string]interface{}) bool { + if secret["type"].(string) != string(v1.SecretTypeDockerConfigJson) { + return false + } + + metadata := utils.GetJSONObject(secret, "metadata") + annotations := utils.GetJSONObject(metadata, "annotations") + _, ok := annotations[privateregistries.RegistryIDLabel] + + return ok +} diff --git a/api/http/proxy/factory/kubernetes/transport.go b/api/http/proxy/factory/kubernetes/transport.go index 377c7a3dc..b71ea906c 100644 --- a/api/http/proxy/factory/kubernetes/transport.go +++ b/api/http/proxy/factory/kubernetes/transport.go @@ -2,153 +2,107 @@ package kubernetes import ( "bytes" - "crypto/tls" "encoding/json" + "errors" "fmt" "io/ioutil" "log" "net/http" + "path" + "regexp" + "strconv" "strings" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/kubernetes/cli" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/crypto" ) -type ( - localTransport struct { - httpTransport *http.Transport - tokenManager *tokenManager - endpointIdentifier portainer.EndpointID - } - - agentTransport struct { - dataStore portainer.DataStore - httpTransport *http.Transport - tokenManager *tokenManager - signatureService portainer.DigitalSignatureService - endpointIdentifier portainer.EndpointID - } - - edgeTransport struct { - dataStore portainer.DataStore - httpTransport *http.Transport - tokenManager *tokenManager - reverseTunnelService portainer.ReverseTunnelService - endpointIdentifier portainer.EndpointID - } -) - -// NewLocalTransport returns a new transport that can be used to send requests to the local Kubernetes API -func NewLocalTransport(tokenManager *tokenManager) (*localTransport, error) { - config, err := crypto.CreateTLSConfigurationFromBytes(nil, nil, nil, true, true) - if err != nil { - return nil, err - } - - transport := &localTransport{ - httpTransport: &http.Transport{ - TLSClientConfig: config, - }, - tokenManager: tokenManager, - } - - return transport, nil +type baseTransport struct { + httpTransport *http.Transport + tokenManager *tokenManager + endpoint *portainer.Endpoint + k8sClientFactory *cli.ClientFactory + dataStore portainer.DataStore } -// RoundTrip is the implementation of the the http.RoundTripper interface -func (transport *localTransport) RoundTrip(request *http.Request) (*http.Response, error) { - token, err := getRoundTripToken(request, transport.tokenManager, transport.endpointIdentifier) +func newBaseTransport(httpTransport *http.Transport, tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore portainer.DataStore) *baseTransport { + return &baseTransport{ + httpTransport: httpTransport, + tokenManager: tokenManager, + endpoint: endpoint, + k8sClientFactory: k8sClientFactory, + dataStore: dataStore, + } +} + +// #region KUBERNETES PROXY + +// proxyKubernetesRequest intercepts a Kubernetes API request and apply logic based +// on the requested operation. +func (transport *baseTransport) proxyKubernetesRequest(request *http.Request) (*http.Response, error) { + apiVersionRe := regexp.MustCompile(`^(/kubernetes)?/api/v[0-9](\.[0-9])?`) + requestPath := apiVersionRe.ReplaceAllString(request.URL.Path, "") + + switch { + case strings.EqualFold(requestPath, "/namespaces"): + return transport.executeKubernetesRequest(request) + case strings.HasPrefix(requestPath, "/namespaces"): + return transport.proxyNamespacedRequest(request, requestPath) + default: + return transport.executeKubernetesRequest(request) + } +} + +func (transport *baseTransport) proxyNamespacedRequest(request *http.Request, fullRequestPath string) (*http.Response, error) { + requestPath := strings.TrimPrefix(fullRequestPath, "/namespaces/") + split := strings.SplitN(requestPath, "/", 2) + namespace := split[0] + + requestPath = "" + if len(split) > 1 { + requestPath = split[1] + } + + switch { + case strings.HasPrefix(requestPath, "secrets"): + return transport.proxySecretRequest(request, namespace, requestPath) + case requestPath == "" && request.Method == "DELETE": + return transport.proxyNamespaceDeleteOperation(request, namespace) + default: + return transport.executeKubernetesRequest(request) + } +} + +func (transport *baseTransport) executeKubernetesRequest(request *http.Request) (*http.Response, error) { + + resp, err := transport.httpTransport.RoundTrip(request) + + return resp, err +} + +// #endregion + +// #region ROUND TRIP + +func (transport *baseTransport) prepareRoundTrip(request *http.Request) (string, error) { + token, err := getRoundTripToken(request, transport.tokenManager) if err != nil { - return nil, err + return "", err } request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - return transport.httpTransport.RoundTrip(request) -} - -// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer agent -func NewAgentTransport(datastore portainer.DataStore, signatureService portainer.DigitalSignatureService, tlsConfig *tls.Config, tokenManager *tokenManager) *agentTransport { - transport := &agentTransport{ - dataStore: datastore, - httpTransport: &http.Transport{ - TLSClientConfig: tlsConfig, - }, - tokenManager: tokenManager, - signatureService: signatureService, - } - - return transport + return token, nil } // RoundTrip is the implementation of the the http.RoundTripper interface -func (transport *agentTransport) RoundTrip(request *http.Request) (*http.Response, error) { - token, err := getRoundTripToken(request, transport.tokenManager, transport.endpointIdentifier) - if err != nil { - return nil, err - } - - request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token) - - if strings.HasPrefix(request.URL.Path, "/v2") { - decorateAgentRequest(request, transport.dataStore) - } - - 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) - - return transport.httpTransport.RoundTrip(request) +func (transport *baseTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return transport.proxyKubernetesRequest(request) } -// NewEdgeTransport returns a new transport that can be used to send signed requests to a Portainer Edge agent -func NewEdgeTransport(datastore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, endpointIdentifier portainer.EndpointID, tokenManager *tokenManager) *edgeTransport { - transport := &edgeTransport{ - dataStore: datastore, - httpTransport: &http.Transport{}, - tokenManager: tokenManager, - reverseTunnelService: reverseTunnelService, - endpointIdentifier: endpointIdentifier, - } - - return transport -} - -// RoundTrip is the implementation of the the http.RoundTripper interface -func (transport *edgeTransport) RoundTrip(request *http.Request) (*http.Response, error) { - token, err := getRoundTripToken(request, transport.tokenManager, transport.endpointIdentifier) - if err != nil { - return nil, err - } - - request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token) - - if strings.HasPrefix(request.URL.Path, "/v2") { - decorateAgentRequest(request, transport.dataStore) - } - - response, err := transport.httpTransport.RoundTrip(request) - - if err == nil { - transport.reverseTunnelService.SetTunnelStatusToActive(transport.endpointIdentifier) - } else { - transport.reverseTunnelService.SetTunnelStatusToIdle(transport.endpointIdentifier) - } - - return response, err -} - -func getRoundTripToken( - request *http.Request, - tokenManager *tokenManager, - endpointIdentifier portainer.EndpointID, -) (string, error) { +func getRoundTripToken(request *http.Request, tokenManager *tokenManager) (string, error) { tokenData, err := security.RetrieveTokenData(request) if err != nil { return "", err @@ -168,32 +122,56 @@ func getRoundTripToken( return token, nil } +// #endregion + +// #region DECORATE FUNCTIONS + func decorateAgentRequest(r *http.Request, dataStore portainer.DataStore) error { requestPath := strings.TrimPrefix(r.URL.Path, "/v2") switch { case strings.HasPrefix(requestPath, "/dockerhub"): - decorateAgentDockerHubRequest(r, dataStore) + return decorateAgentDockerHubRequest(r, dataStore) } return nil } func decorateAgentDockerHubRequest(r *http.Request, dataStore portainer.DataStore) error { - dockerhub, err := dataStore.DockerHub().DockerHub() + requestPath, registryIdString := path.Split(r.URL.Path) + + registryID, err := strconv.Atoi(registryIdString) if err != nil { - return err + return fmt.Errorf("missing registry id: %w", err) } - newBody, err := json.Marshal(dockerhub) + r.URL.Path = strings.TrimSuffix(requestPath, "/") + + registry := &portainer.Registry{ + Type: portainer.DockerHubRegistry, + } + + if registryID != 0 { + registry, err = dataStore.Registry().Registry(portainer.RegistryID(registryID)) + if err != nil { + return fmt.Errorf("failed fetching registry: %w", err) + } + } + + if registry.Type != portainer.DockerHubRegistry { + return errors.New("invalid registry type") + } + + newBody, err := json.Marshal(registry) if err != nil { - return err + return fmt.Errorf("failed marshaling registry: %w", err) } r.Method = http.MethodPost - r.Body = ioutil.NopCloser(bytes.NewReader(newBody)) r.ContentLength = int64(len(newBody)) return nil } + +// #endregion diff --git a/api/http/proxy/factory/responseutils/json.go b/api/http/proxy/factory/responseutils/json.go deleted file mode 100644 index 15af94c60..000000000 --- a/api/http/proxy/factory/responseutils/json.go +++ /dev/null @@ -1,11 +0,0 @@ -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/utils/json.go b/api/http/proxy/factory/utils/json.go new file mode 100644 index 000000000..2d44e17f4 --- /dev/null +++ b/api/http/proxy/factory/utils/json.go @@ -0,0 +1,91 @@ +package utils + +import ( + "compress/gzip" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + + "gopkg.in/yaml.v3" +) + +// 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 +} + +// GetArrayObject will extract an array from a specific property of another JSON object. +// Returns nil if nothing is associated to the specified key. +func GetArrayObject(jsonObject map[string]interface{}, property string) []interface{} { + object := jsonObject[property] + if object != nil { + return object.([]interface{}) + } + return nil +} + +func getBody(body io.ReadCloser, contentType string, isGzip bool) (interface{}, error) { + if body == nil { + return nil, errors.New("unable to parse response: empty response body") + } + + reader := body + + if isGzip { + gzipReader, err := gzip.NewReader(reader) + if err != nil { + return nil, err + } + + reader = gzipReader + } + + defer reader.Close() + + bodyBytes, err := ioutil.ReadAll(reader) + if err != nil { + return nil, err + } + + err = body.Close() + if err != nil { + return nil, err + } + + var data interface{} + err = unmarshal(contentType, bodyBytes, &data) + if err != nil { + return nil, err + } + + return data, nil +} + +func marshal(contentType string, data interface{}) ([]byte, error) { + switch contentType { + case "application/yaml": + return yaml.Marshal(data) + case "application/json", "": + return json.Marshal(data) + } + + return nil, fmt.Errorf("content type is not supported for marshaling: %s", contentType) +} + +func unmarshal(contentType string, body []byte, returnBody interface{}) error { + switch contentType { + case "application/yaml": + return yaml.Unmarshal(body, returnBody) + case "application/json", "": + return json.Unmarshal(body, returnBody) + } + + return fmt.Errorf("content type is not supported for unmarshaling: %s", contentType) +} diff --git a/api/http/proxy/factory/utils/request.go b/api/http/proxy/factory/utils/request.go new file mode 100644 index 000000000..92724b7fa --- /dev/null +++ b/api/http/proxy/factory/utils/request.go @@ -0,0 +1,45 @@ +package utils + +import ( + "bytes" + "io/ioutil" + "net/http" + "strconv" +) + +// GetRequestAsMap returns the response content as a generic JSON object +func GetRequestAsMap(request *http.Request) (map[string]interface{}, error) { + data, err := getRequestBody(request) + if err != nil { + return nil, err + } + + return data.(map[string]interface{}), nil +} + +// RewriteRequest will replace the existing request body with the one specified +// in parameters +func RewriteRequest(request *http.Request, newData interface{}) error { + data, err := marshal(getContentType(request.Header), newData) + if err != nil { + return err + } + + body := ioutil.NopCloser(bytes.NewReader(data)) + + request.Body = body + request.ContentLength = int64(len(data)) + + if request.Header == nil { + request.Header = make(http.Header) + } + request.Header.Set("Content-Length", strconv.Itoa(len(data))) + + return nil +} + +func getRequestBody(request *http.Request) (interface{}, error) { + isGzip := request.Header.Get("Content-Encoding") == "gzip" + + return getBody(request.Body, getContentType(request.Header), isGzip) +} diff --git a/api/http/proxy/factory/responseutils/response.go b/api/http/proxy/factory/utils/response.go similarity index 62% rename from api/http/proxy/factory/responseutils/response.go rename to api/http/proxy/factory/utils/response.go index a32cd3252..dc73e5618 100644 --- a/api/http/proxy/factory/responseutils/response.go +++ b/api/http/proxy/factory/utils/response.go @@ -1,9 +1,7 @@ -package responseutils +package utils import ( "bytes" - "compress/gzip" - "encoding/json" "errors" "io/ioutil" "log" @@ -13,7 +11,7 @@ import ( // GetResponseAsJSONObject returns the response content as a generic JSON object func GetResponseAsJSONObject(response *http.Response) (map[string]interface{}, error) { - responseData, err := getResponseBodyAsGenericJSON(response) + responseData, err := getResponseBody(response) if err != nil { return nil, err } @@ -24,7 +22,7 @@ func GetResponseAsJSONObject(response *http.Response) (map[string]interface{}, e // GetResponseAsJSONArray returns the response content as an array of generic JSON object func GetResponseAsJSONArray(response *http.Response) ([]interface{}, error) { - responseData, err := getResponseBodyAsGenericJSON(response) + responseData, err := getResponseBody(response) if err != nil { return nil, err } @@ -44,72 +42,54 @@ func GetResponseAsJSONArray(response *http.Response) ([]interface{}, error) { } } -func getResponseBodyAsGenericJSON(response *http.Response) (interface{}, error) { - if response.Body == nil { - return nil, errors.New("unable to parse response: empty response body") - } - - reader := response.Body - - if response.Header.Get("Content-Encoding") == "gzip" { - response.Header.Del("Content-Encoding") - gzipReader, err := gzip.NewReader(response.Body) - if err != nil { - return nil, err - } - reader = gzipReader - } - - defer reader.Close() - - var data interface{} - body, err := ioutil.ReadAll(reader) - if err != nil { - return nil, err - } - - err = json.Unmarshal(body, &data) - if err != nil { - return nil, err - } - - return data, nil -} - -type dockerErrorResponse struct { +type errorResponse 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) + err := RewriteResponse(response, errorResponse{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) + return RewriteResponse(response, errorResponse{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) + data, err := marshal(getContentType(response.Header), newResponseData) if err != nil { return err } - body := ioutil.NopCloser(bytes.NewReader(jsonData)) + body := ioutil.NopCloser(bytes.NewReader(data)) response.StatusCode = statusCode response.Body = body - response.ContentLength = int64(len(jsonData)) + response.ContentLength = int64(len(data)) if response.Header == nil { response.Header = make(http.Header) } - response.Header.Set("Content-Length", strconv.Itoa(len(jsonData))) + response.Header.Set("Content-Length", strconv.Itoa(len(data))) return nil } + +func getResponseBody(response *http.Response) (interface{}, error) { + isGzip := response.Header.Get("Content-Encoding") == "gzip" + + if isGzip { + response.Header.Del("Content-Encoding") + } + + return getBody(response.Body, getContentType(response.Header), isGzip) +} + +func getContentType(headers http.Header) string { + return headers.Get("Content-type") +} diff --git a/api/http/security/authorization.go b/api/http/security/authorization.go index 2ba3e8743..f205a6493 100644 --- a/api/http/security/authorization.go +++ b/api/http/security/authorization.go @@ -1,9 +1,21 @@ package security import ( - "github.com/portainer/portainer/api" + "net/http" + + portainer "github.com/portainer/portainer/api" ) +// IsAdmin returns true if the logged-in user is an admin +func IsAdmin(request *http.Request) (bool, error) { + tokenData, err := RetrieveTokenData(request) + if err != nil { + return false, err + } + + return tokenData.Role == portainer.AdministratorRole, nil +} + // AuthorizedResourceControlAccess checks whether the user can alter an existing resource control. func AuthorizedResourceControlAccess(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool { if context.IsAdmin || resourceControl.Public { @@ -95,9 +107,9 @@ func AuthorizedTeamManagement(teamID portainer.TeamID, context *RestrictedReques // 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. func authorizedEndpointAccess(endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool { - groupAccess := authorizedAccess(userID, memberships, endpointGroup.UserAccessPolicies, endpointGroup.TeamAccessPolicies) + groupAccess := AuthorizedAccess(userID, memberships, endpointGroup.UserAccessPolicies, endpointGroup.TeamAccessPolicies) if !groupAccess { - return authorizedAccess(userID, memberships, endpoint.UserAccessPolicies, endpoint.TeamAccessPolicies) + return AuthorizedAccess(userID, memberships, endpoint.UserAccessPolicies, endpoint.TeamAccessPolicies) } return true } @@ -106,17 +118,24 @@ func authorizedEndpointAccess(endpoint *portainer.Endpoint, endpointGroup *porta // 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 authorizedEndpointGroupAccess(endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool { - return authorizedAccess(userID, memberships, endpointGroup.UserAccessPolicies, endpointGroup.TeamAccessPolicies) + return AuthorizedAccess(userID, memberships, endpointGroup.UserAccessPolicies, endpointGroup.TeamAccessPolicies) } // 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 { - return authorizedAccess(userID, memberships, registry.UserAccessPolicies, registry.TeamAccessPolicies) +// listed in the authorized teams for a specified endpoint, +func AuthorizedRegistryAccess(registry *portainer.Registry, user *portainer.User, teamMemberships []portainer.TeamMembership, endpointID portainer.EndpointID) bool { + if user.Role == portainer.AdministratorRole { + return true + } + + registryEndpointAccesses := registry.RegistryAccesses[endpointID] + + return AuthorizedAccess(user.ID, teamMemberships, registryEndpointAccesses.UserAccessPolicies, registryEndpointAccesses.TeamAccessPolicies) } -func authorizedAccess(userID portainer.UserID, memberships []portainer.TeamMembership, userAccessPolicies portainer.UserAccessPolicies, teamAccessPolicies portainer.TeamAccessPolicies) bool { +// AuthorizedAccess verifies the userID or memberships are authorized to use an object per the supplied access policies +func AuthorizedAccess(userID portainer.UserID, memberships []portainer.TeamMembership, userAccessPolicies portainer.UserAccessPolicies, teamAccessPolicies portainer.TeamAccessPolicies) bool { _, userAccess := userAccessPolicies[userID] if userAccess { return true diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index 5c57314af..dee955e50 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -128,31 +128,6 @@ func (bouncer *RequestBouncer) AuthorizedEdgeEndpointOperation(r *http.Request, return nil } -// RegistryAccess retrieves the JWT token from the request context and verifies -// that the user can access the specified registry. -// An error is returned when access is denied. -func (bouncer *RequestBouncer) RegistryAccess(r *http.Request, registry *portainer.Registry) error { - tokenData, err := RetrieveTokenData(r) - if err != nil { - return err - } - - if tokenData.Role == portainer.AdministratorRole { - return nil - } - - memberships, err := bouncer.dataStore.TeamMembership().TeamMembershipsByUserID(tokenData.ID) - if err != nil { - return err - } - - if !AuthorizedRegistryAccess(registry, tokenData.ID, memberships) { - return httperrors.ErrEndpointAccessDenied - } - - return nil -} - // handlers are applied backwards to the incoming request: // - add secure handlers to the response // - parse the JWT token and put it into the http context. @@ -213,7 +188,7 @@ func (bouncer *RequestBouncer) mwUpgradeToRestrictedRequest(next http.Handler) h return } - ctx := storeRestrictedRequestContext(r, requestContext) + ctx := StoreRestrictedRequestContext(r, requestContext) next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/api/http/security/context.go b/api/http/security/context.go index 8350fa56f..1601f61ac 100644 --- a/api/http/security/context.go +++ b/api/http/security/context.go @@ -5,7 +5,7 @@ import ( "errors" "net/http" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" ) type ( @@ -33,9 +33,9 @@ func RetrieveTokenData(request *http.Request) (*portainer.TokenData, error) { return tokenData, nil } -// storeRestrictedRequestContext stores a RestrictedRequestContext object inside the request context +// StoreRestrictedRequestContext stores a RestrictedRequestContext object inside the request context // and returns the enhanced context. -func storeRestrictedRequestContext(request *http.Request, requestContext *RestrictedRequestContext) context.Context { +func StoreRestrictedRequestContext(request *http.Request, requestContext *RestrictedRequestContext) context.Context { return context.WithValue(request.Context(), contextRestrictedRequest, requestContext) } diff --git a/api/http/security/filter.go b/api/http/security/filter.go index 1716b043e..be0106447 100644 --- a/api/http/security/filter.go +++ b/api/http/security/filter.go @@ -1,7 +1,7 @@ package security import ( - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" ) // FilterUserTeams filters teams based on user role. @@ -64,15 +64,16 @@ func FilterUsers(users []portainer.User, context *RestrictedRequestContext) []po // FilterRegistries filters registries based on user role and team memberships. // Non administrator users only have access to authorized registries. -func FilterRegistries(registries []portainer.Registry, context *RestrictedRequestContext) []portainer.Registry { - filteredRegistries := registries - if !context.IsAdmin { - filteredRegistries = make([]portainer.Registry, 0) +func FilterRegistries(registries []portainer.Registry, user *portainer.User, teamMemberships []portainer.TeamMembership, endpointID portainer.EndpointID) []portainer.Registry { + if user.Role == portainer.AdministratorRole { + return registries + } - for _, registry := range registries { - if AuthorizedRegistryAccess(®istry, context.UserID, context.UserMemberships) { - filteredRegistries = append(filteredRegistries, registry) - } + filteredRegistries := []portainer.Registry{} + + for _, registry := range registries { + if AuthorizedRegistryAccess(®istry, user, teamMemberships, endpointID) { + filteredRegistries = append(filteredRegistries, registry) } } diff --git a/api/http/server.go b/api/http/server.go index 8f399e7a9..ac5fe0e1b 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -16,7 +16,6 @@ import ( "github.com/portainer/portainer/api/http/handler/auth" "github.com/portainer/portainer/api/http/handler/backup" "github.com/portainer/portainer/api/http/handler/customtemplates" - "github.com/portainer/portainer/api/http/handler/dockerhub" "github.com/portainer/portainer/api/http/handler/edgegroups" "github.com/portainer/portainer/api/http/handler/edgejobs" "github.com/portainer/portainer/api/http/handler/edgestacks" @@ -111,9 +110,6 @@ func (server *Server) Start() error { customTemplatesHandler.FileService = server.FileService customTemplatesHandler.GitService = server.GitService - var dockerHubHandler = dockerhub.NewHandler(requestBouncer) - dockerHubHandler.DataStore = server.DataStore - var edgeGroupsHandler = edgegroups.NewHandler(requestBouncer) edgeGroupsHandler.DataStore = server.DataStore @@ -135,6 +131,7 @@ func (server *Server) Start() error { endpointHandler.FileService = server.FileService endpointHandler.ProxyManager = server.ProxyManager endpointHandler.SnapshotService = server.SnapshotService + endpointHandler.K8sClientFactory = server.KubernetesClientFactory endpointHandler.ReverseTunnelService = server.ReverseTunnelService endpointHandler.ComposeStackManager = server.ComposeStackManager endpointHandler.AuthorizationService = server.AuthorizationService @@ -161,6 +158,7 @@ func (server *Server) Start() error { registryHandler.DataStore = server.DataStore registryHandler.FileService = server.FileService registryHandler.ProxyManager = server.ProxyManager + registryHandler.K8sClientFactory = server.KubernetesClientFactory var resourceControlHandler = resourcecontrols.NewHandler(requestBouncer) resourceControlHandler.DataStore = server.DataStore @@ -219,7 +217,6 @@ func (server *Server) Start() error { AuthHandler: authHandler, BackupHandler: backupHandler, CustomTemplatesHandler: customTemplatesHandler, - DockerHubHandler: dockerHubHandler, EdgeGroupsHandler: edgeGroupsHandler, EdgeJobsHandler: edgeJobsHandler, EdgeStacksHandler: edgeStacksHandler, diff --git a/api/internal/authorization/authorizations.go b/api/internal/authorization/authorizations.go index 805c2e38f..92eae802d 100644 --- a/api/internal/authorization/authorizations.go +++ b/api/internal/authorization/authorizations.go @@ -1,15 +1,15 @@ package authorization import ( - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/kubernetes/cli" ) // Service represents a service used to // update authorizations associated to a user or team. type Service struct { - dataStore portainer.DataStore - K8sClientFactory *cli.ClientFactory + dataStore portainer.DataStore + K8sClientFactory *cli.ClientFactory } // NewService returns a point to a new Service instance. @@ -140,6 +140,7 @@ func DefaultEndpointAuthorizationsForEndpointAdministratorRole() portainer.Autho portainer.OperationDockerAgentUndefined: true, portainer.OperationPortainerResourceControlCreate: true, portainer.OperationPortainerResourceControlUpdate: true, + portainer.OperationPortainerRegistryUpdateAccess: true, portainer.OperationPortainerStackList: true, portainer.OperationPortainerStackInspect: true, portainer.OperationPortainerStackFile: true, diff --git a/api/internal/endpoint/endpoint.go b/api/internal/endpoint/endpoint.go index 378ca70e5..079474d11 100644 --- a/api/internal/endpoint/endpoint.go +++ b/api/internal/endpoint/endpoint.go @@ -9,8 +9,8 @@ func IsKubernetesEndpoint(endpoint *portainer.Endpoint) bool { endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment } -// IsDocketEndpoint returns true if this is a docker endpoint -func IsDocketEndpoint(endpoint *portainer.Endpoint) bool { +// IsDockerEndpoint returns true if this is a docker endpoint +func IsDockerEndpoint(endpoint *portainer.Endpoint) bool { return endpoint.Type == portainer.DockerEnvironment || endpoint.Type == portainer.AgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnDockerEnvironment diff --git a/api/internal/testhelpers/datastore.go b/api/internal/testhelpers/datastore.go index a58da5c7c..970184d86 100644 --- a/api/internal/testhelpers/datastore.go +++ b/api/internal/testhelpers/datastore.go @@ -7,7 +7,6 @@ import ( ) type datastore struct { - dockerHub portainer.DockerHubService customTemplate portainer.CustomTemplateService edgeGroup portainer.EdgeGroupService edgeJob portainer.EdgeJobService @@ -37,7 +36,6 @@ func (d *datastore) CheckCurrentEdition() error { retur func (d *datastore) IsNew() bool { return false } func (d *datastore) MigrateData(force bool) error { return nil } func (d *datastore) RollbackToCE() error { return nil } -func (d *datastore) DockerHub() portainer.DockerHubService { return d.dockerHub } func (d *datastore) CustomTemplate() portainer.CustomTemplateService { return d.customTemplate } func (d *datastore) EdgeGroup() portainer.EdgeGroupService { return d.edgeGroup } func (d *datastore) EdgeJob() portainer.EdgeJobService { return d.edgeJob } diff --git a/api/kubernetes/cli/access.go b/api/kubernetes/cli/access.go index e4b4729c6..af5fa887a 100644 --- a/api/kubernetes/cli/access.go +++ b/api/kubernetes/cli/access.go @@ -12,6 +12,28 @@ type ( namespaceAccessPolicies map[string]portainer.K8sNamespaceAccessPolicy ) +// GetNamespaceAccessPolicies gets the namespace access policies +// from config maps in the portainer namespace +func (kcl *KubeClient) GetNamespaceAccessPolicies() ( + map[string]portainer.K8sNamespaceAccessPolicy, error, +) { + configMap, err := kcl.cli.CoreV1().ConfigMaps(portainerNamespace).Get(portainerConfigMapName, metav1.GetOptions{}) + if k8serrors.IsNotFound(err) { + return nil, nil + } else if err != nil { + return nil, err + } + + accessData := configMap.Data[portainerConfigMapAccessPoliciesKey] + + var policies map[string]portainer.K8sNamespaceAccessPolicy + err = json.Unmarshal([]byte(accessData), &policies) + if err != nil { + return nil, err + } + return policies, nil +} + func (kcl *KubeClient) setupNamespaceAccesses(userID int, teamIDs []int, serviceAccountName string) error { configMap, err := kcl.cli.CoreV1().ConfigMaps(portainerNamespace).Get(portainerConfigMapName, metav1.GetOptions{}) if k8serrors.IsNotFound(err) { @@ -80,28 +102,6 @@ func hasUserAccessToNamespace(userID int, teamIDs []int, policies portainer.K8sN return false } -// GetNamespaceAccessPolicies gets the namespace access policies -// from config maps in the portainer namespace -func (kcl *KubeClient) GetNamespaceAccessPolicies() (map[string]portainer.K8sNamespaceAccessPolicy, error) { - configMap, err := kcl.cli.CoreV1().ConfigMaps(portainerNamespace).Get(portainerConfigMapName, metav1.GetOptions{}) - if k8serrors.IsNotFound(err) { - return nil, nil - } - - if err != nil { - return nil, err - } - - accessData := configMap.Data[portainerConfigMapAccessPoliciesKey] - - var policies map[string]portainer.K8sNamespaceAccessPolicy - err = json.Unmarshal([]byte(accessData), &policies) - if err != nil { - return nil, err - } - return policies, nil -} - // UpdateNamespaceAccessPolicies updates the namespace access policies func (kcl *KubeClient) UpdateNamespaceAccessPolicies(accessPolicies map[string]portainer.K8sNamespaceAccessPolicy) error { data, err := json.Marshal(accessPolicies) diff --git a/api/kubernetes/cli/client.go b/api/kubernetes/cli/client.go index a268150c9..c3e687fd9 100644 --- a/api/kubernetes/cli/client.go +++ b/api/kubernetes/cli/client.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "strconv" + "time" cmap "github.com/orcaman/concurrent-map" @@ -17,6 +18,7 @@ import ( type ( // ClientFactory is used to create Kubernetes clients ClientFactory struct { + dataStore portainer.DataStore reverseTunnelService portainer.ReverseTunnelService signatureService portainer.DigitalSignatureService instanceID string @@ -31,8 +33,9 @@ type ( ) // NewClientFactory returns a new instance of a ClientFactory -func NewClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, instanceID string) *ClientFactory { +func NewClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, instanceID string, dataStore portainer.DataStore) *ClientFactory { return &ClientFactory{ + dataStore: dataStore, signatureService: signatureService, reverseTunnelService: reverseTunnelService, instanceID: instanceID, @@ -133,7 +136,29 @@ func (factory *ClientFactory) buildAgentClient(endpoint *portainer.Endpoint) (*k func (factory *ClientFactory) buildEdgeClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) { tunnel := factory.reverseTunnelService.GetTunnelDetails(endpoint.ID) - endpointURL := fmt.Sprintf("http://localhost:%d/kubernetes", tunnel.Port) + + if tunnel.Status == portainer.EdgeAgentIdle { + err := factory.reverseTunnelService.SetTunnelStatusToRequired(endpoint.ID) + if err != nil { + return nil, fmt.Errorf("failed opening tunnel to endpoint: %w", err) + } + + if endpoint.EdgeCheckinInterval == 0 { + settings, err := factory.dataStore.Settings().Settings() + if err != nil { + return nil, fmt.Errorf("failed fetching settings from db: %w", err) + } + + endpoint.EdgeCheckinInterval = settings.EdgeAgentCheckinInterval + } + + waitForAgentToConnect := time.Duration(endpoint.EdgeCheckinInterval) * time.Second + time.Sleep(waitForAgentToConnect * 2) + + tunnel = factory.reverseTunnelService.GetTunnelDetails(endpoint.ID) + } + + endpointURL := fmt.Sprintf("http://127.0.0.1:%d/kubernetes", tunnel.Port) config, err := clientcmd.BuildConfigFromFlags(endpointURL, "") if err != nil { diff --git a/api/kubernetes/cli/registries.go b/api/kubernetes/cli/registries.go new file mode 100644 index 000000000..122741a9b --- /dev/null +++ b/api/kubernetes/cli/registries.go @@ -0,0 +1,96 @@ +package cli + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + v1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + secretDockerConfigKey = ".dockerconfigjson" +) + +type ( + dockerConfig struct { + Auths map[string]registryDockerConfig `json:"auths"` + } + + registryDockerConfig struct { + Username string `json:"username"` + Password string `json:"password"` + Email string `json:"email"` + } +) + +func (kcl *KubeClient) DeleteRegistrySecret(registry *portainer.Registry, namespace string) error { + err := kcl.cli.CoreV1().Secrets(namespace).Delete(registrySecretName(registry), &metav1.DeleteOptions{}) + if err != nil && !k8serrors.IsNotFound(err) { + return errors.Wrap(err, "failed removing secret") + } + + return nil +} + +func (kcl *KubeClient) CreateRegistrySecret(registry *portainer.Registry, namespace string) error { + config := dockerConfig{ + Auths: map[string]registryDockerConfig{ + registry.URL: { + Username: registry.Username, + Password: registry.Password, + }, + }, + } + + configByte, err := json.Marshal(config) + if err != nil { + return errors.Wrap(err, "failed marshal config") + } + + secret := &v1.Secret{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: registrySecretName(registry), + Annotations: map[string]string{ + "portainer.io/registry.id": strconv.Itoa(int(registry.ID)), + }, + }, + Data: map[string][]byte{ + secretDockerConfigKey: configByte, + }, + Type: v1.SecretTypeDockerConfigJson, + } + + _, err = kcl.cli.CoreV1().Secrets(namespace).Create(secret) + if err != nil && !k8serrors.IsAlreadyExists(err) { + return errors.Wrap(err, "failed saving secret") + } + + return nil + +} + +func (cli *KubeClient) IsRegistrySecret(namespace, secretName string) (bool, error) { + secret, err := cli.cli.CoreV1().Secrets(namespace).Get(secretName, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + return false, nil + } + + return false, err + } + + isSecret := secret.Type == v1.SecretTypeDockerConfigJson + + return isSecret, nil + +} + +func registrySecretName(registry *portainer.Registry) string { + return fmt.Sprintf("registry-%d", registry.ID) +} diff --git a/api/kubernetes/privateregistries/labels.go b/api/kubernetes/privateregistries/labels.go new file mode 100644 index 000000000..dc780814c --- /dev/null +++ b/api/kubernetes/privateregistries/labels.go @@ -0,0 +1,5 @@ +package privateregistries + +const ( + RegistryIDLabel = "portainer.io/registry.id" +) diff --git a/api/portainer.go b/api/portainer.go index ccfeeea7a..5458fca4b 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -511,8 +511,8 @@ type ( Registry struct { // Registry Identifier ID RegistryID `json:"Id" example:"1"` - // Registry Type (1 - Quay, 2 - Azure, 3 - Custom, 4 - Gitlab, 5 - ProGet) - Type RegistryType `json:"Type" enums:"1,2,3,4,5"` + // Registry Type (1 - Quay, 2 - Azure, 3 - Custom, 4 - Gitlab, 5 - ProGet, 6 - DockerHub) + Type RegistryType `json:"Type" enums:"1,2,3,4,5,6"` // Registry Name Name string `json:"Name" example:"my-registry"` // URL or IP address of the Docker registry @@ -528,15 +528,28 @@ type ( ManagementConfiguration *RegistryManagementConfiguration `json:"ManagementConfiguration"` Gitlab GitlabRegistryData `json:"Gitlab"` Quay QuayRegistryData `json:"Quay"` - UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"` - TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"` + RegistryAccesses RegistryAccesses `json:"RegistryAccesses"` // Deprecated fields + // Deprecated in DBVersion == 31 + UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"` + // Deprecated in DBVersion == 31 + TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"` + // Deprecated in DBVersion == 18 AuthorizedUsers []UserID `json:"AuthorizedUsers"` + // Deprecated in DBVersion == 18 AuthorizedTeams []TeamID `json:"AuthorizedTeams"` } + RegistryAccesses map[EndpointID]RegistryAccessPolicies + + RegistryAccessPolicies struct { + UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"` + TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"` + Namespaces []string `json:"Namespaces"` + } + // RegistryID represents a registry identifier RegistryID int @@ -1019,7 +1032,6 @@ type ( CheckCurrentEdition() error BackupTo(w io.Writer) error - DockerHub() DockerHubService CustomTemplate() CustomTemplateService EdgeGroup() EdgeGroupService EdgeJob() EdgeJobService @@ -1050,12 +1062,6 @@ type ( CreateSignature(message string) (string, error) } - // DockerHubService represents a service for managing the DockerHub object - DockerHubService interface { - DockerHub() (*DockerHub, error) - UpdateDockerHub(registry *DockerHub) error - } - // DockerSnapshotter represents a service used to create Docker endpoint snapshots DockerSnapshotter interface { CreateSnapshot(endpoint *Endpoint) (*DockerSnapshot, error) @@ -1169,6 +1175,9 @@ type ( StartExecProcess(namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer) error GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error) UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error + DeleteRegistrySecret(registry *Registry, namespace string) error + CreateRegistrySecret(registry *Registry, namespace string) error + IsRegistrySecret(namespace, secretName string) (bool, error) } // KubernetesDeployer represents a service to deploy a manifest inside a Kubernetes endpoint @@ -1266,7 +1275,7 @@ type ( // SwarmStackManager represents a service to manage Swarm stacks SwarmStackManager interface { - Login(dockerhub *DockerHub, registries []Registry, endpoint *Endpoint) + Login(registries []Registry, endpoint *Endpoint) Logout(endpoint *Endpoint) error Deploy(stack *Stack, prune bool, endpoint *Endpoint) error Remove(stack *Stack, endpoint *Endpoint) error @@ -1345,7 +1354,7 @@ const ( // APIVersion is the version number of the Portainer API APIVersion = "2.6.0" // DBVersion is the version number of the Portainer database - DBVersion = 30 + DBVersion = 32 // ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax ComposeSyntaxMaxVersion = "3.9" // AssetsServerURL represents the URL of the Portainer asset server @@ -1493,6 +1502,8 @@ const ( GitlabRegistry // ProGetRegistry represents a proget registry ProGetRegistry + // DockerHubRegistry represents a dockerhub registry + DockerHubRegistry ) const ( diff --git a/app/agent/rest/dockerhub.js b/app/agent/rest/dockerhub.js index b48481e8a..eb901b494 100644 --- a/app/agent/rest/dockerhub.js +++ b/app/agent/rest/dockerhub.js @@ -4,10 +4,10 @@ angular.module('portainer.agent').factory('AgentDockerhub', AgentDockerhub); function AgentDockerhub($resource, API_ENDPOINT_ENDPOINTS) { return $resource( - `${API_ENDPOINT_ENDPOINTS}/:endpointId/:endpointType/v2/dockerhub`, + `${API_ENDPOINT_ENDPOINTS}/:endpointId/:endpointType/v2/dockerhub/:registryId`, {}, { - limits: { method: 'GET' }, + limits: { method: 'GET', params: { registryId: '@registryId' } }, } ); } diff --git a/app/constants.js b/app/constants.js index febc848e8..57d1c7c41 100644 --- a/app/constants.js +++ b/app/constants.js @@ -1,7 +1,6 @@ angular .module('portainer') .constant('API_ENDPOINT_AUTH', 'api/auth') - .constant('API_ENDPOINT_DOCKERHUB', 'api/dockerhub') .constant('API_ENDPOINT_CUSTOM_TEMPLATES', 'api/custom_templates') .constant('API_ENDPOINT_EDGE_GROUPS', 'api/edge_groups') .constant('API_ENDPOINT_EDGE_JOBS', 'api/edge_jobs') diff --git a/app/docker/__module.js b/app/docker/__module.js index 40cf82c24..94e7e969c 100644 --- a/app/docker/__module.js +++ b/app/docker/__module.js @@ -591,6 +591,26 @@ angular.module('portainer.docker', ['portainer.app']).config([ }, }; + const registries = { + name: 'docker.registries', + url: '/registries', + views: { + 'content@': { + component: 'endpointRegistriesView', + }, + }, + }; + + const registryAccess = { + name: 'docker.registries.access', + url: '/:id/access', + views: { + 'content@': { + component: 'dockerRegistryAccessView', + }, + }, + }; + $stateRegistryProvider.register(configs); $stateRegistryProvider.register(config); $stateRegistryProvider.register(configCreation); @@ -641,5 +661,7 @@ angular.module('portainer.docker', ['portainer.app']).config([ $stateRegistryProvider.register(volumeBrowse); $stateRegistryProvider.register(volumeCreation); $stateRegistryProvider.register(dockerFeaturesConfiguration); + $stateRegistryProvider.register(registries); + $stateRegistryProvider.register(registryAccess); }, ]); diff --git a/app/docker/components/dockerSidebarContent/dockerSidebarContent.html b/app/docker/components/dockerSidebarContent/dockerSidebarContent.html index 484451142..086047756 100644 --- a/app/docker/components/dockerSidebarContent/dockerSidebarContent.html +++ b/app/docker/components/dockerSidebarContent/dockerSidebarContent.html @@ -35,17 +35,19 @@