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_dbversion29.go b/api/bolt/migrator/migrate_dbversion29.go new file mode 100644 index 000000000..4af4f76fa --- /dev/null +++ b/api/bolt/migrator/migrate_dbversion29.go @@ -0,0 +1,114 @@ +package migrator + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" +) + +func (m *Migrator) updateRegistriesToDB30() 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) UpdateDockerhubToDB30() error { + dockerhub, err := m.dockerhubService.DockerHub() + if err == errors.ErrObjectNotFound { + return nil + } else if err != nil { + return err + } + + if dockerhub.Authentication { + 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{}, + } + } + } + + err = m.registryService.CreateRegistry(registry) + if err != nil { + return err + } + + } + + return nil +} diff --git a/api/bolt/migrator/migrator.go b/api/bolt/migrator/migrator.go index e366bd3df..49d111728 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, } } @@ -358,5 +365,20 @@ func (m *Migrator) Migrate() error { } } + // Portainer CE-2.5.0 + if m.currentDBVersion < 30 { + err := m.updateRegistriesToDB30() + if err != nil { + return err + } + migrateLog.Info("Successful migration of registries to DB version 30") + + err = m.UpdateDockerhubToDB30() + if err != nil { + return err + } + migrateLog.Info("Successful migration of Dockerhub registry to DB version 30") + } + 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/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 0b9a01bd7..252557efc 100644 --- a/api/go.mod +++ b/api/go.mod @@ -35,6 +35,7 @@ require ( golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 gopkg.in/alecthomas/kingpin.v2 v2.2.6 gopkg.in/src-d/go-git.v4 v4.13.1 + 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_registries_list.go b/api/http/handler/endpoints/endpoint_registries_list.go new file mode 100644 index 000000000..f813d41ec --- /dev/null +++ b/api/http/handler/endpoints/endpoint_registries_list.go @@ -0,0 +1,114 @@ +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" +) + +// GET request on /endpoints/{id}/registries +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} + } + + isAdminOrEndpointAdmin := 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 endpoint.Type == portainer.KubernetesLocalEnvironment || endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { + namespace, _ := request.RetrieveQueryParameter(r, "namespace", true) + + if !isAdminOrEndpointAdmin { + authorized, err := handler.isNamespaceAuthorized(endpoint, namespace, user.ID, securityContext.UserMemberships) + 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 !isAdminOrEndpointAdmin { + registries = security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID) + } + + for idx := range registries { + hideRegistryFields(®istries[idx], !isAdminOrEndpointAdmin) + } + + return response.JSON(w, registries) +} + +func (handler *Handler) isNamespaceAuthorized(endpoint *portainer.Endpoint, namespace string, userId portainer.UserID, memberships []portainer.TeamMembership) (bool, error) { + + 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 { + + 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..2fa6d5a32 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_registry_access.go @@ -0,0 +1,150 @@ +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 { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + 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 resource pool 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} + } + + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + } + + isAdminOrEndpointAdmin := securityContext.IsAdmin + if !isAdminOrEndpointAdmin { + 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 e7a41b8c8..fe475f61f 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -5,6 +5,7 @@ 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" "net/http" @@ -27,6 +28,7 @@ type Handler struct { ProxyManager *proxy.Manager ReverseTunnelService portainer.ReverseTunnelService SnapshotService portainer.SnapshotService + K8sClientFactory *cli.ClientFactory ComposeStackManager portainer.ComposeStackManager } @@ -61,5 +63,9 @@ 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.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 035385346..9e41e3015 100644 --- a/api/http/handler/registries/handler.go +++ b/api/http/handler/registries/handler.go @@ -5,23 +5,28 @@ import ( "github.com/gorilla/mux" httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer/api" + 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. @@ -47,3 +52,14 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.AdminAccess(httperror.LoggerHandler(h.proxyRequestsToGitlabAPIWithoutRegistry))) return h } + +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 00b2c0259..cd9eb47c3 100644 --- a/api/http/handler/registries/registry_create.go +++ b/api/http/handler/registries/registry_create.go @@ -9,6 +9,8 @@ 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 { @@ -40,8 +42,9 @@ func (payload *registryCreatePayload) 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") } - if payload.Type != portainer.QuayRegistry && payload.Type != portainer.AzureRegistry && payload.Type != portainer.CustomRegistry && payload.Type != portainer.GitlabRegistry { - return errors.New("Invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry) or 4 (Gitlab registry)") + + if payload.Type != portainer.QuayRegistry && payload.Type != portainer.AzureRegistry && payload.Type != portainer.CustomRegistry && payload.Type != portainer.GitlabRegistry && payload.Type != portainer.DockerHubRegistry { + return errors.New("Invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry), 4 (Gitlab registry) or 5 (DockerHub registry)") } return nil } @@ -60,23 +63,40 @@ func (payload *registryCreatePayload) Validate(r *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, - 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, + Authentication: payload.Authentication, + Username: payload.Username, + Password: payload.Password, + Gitlab: payload.Gitlab, + Quay: payload.Quay, + RegistryAccesses: portainer.RegistryAccesses{}, + } + + 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) @@ -84,6 +104,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_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..a32bf10f9 100644 --- a/api/http/handler/registries/registry_inspect.go +++ b/api/http/handler/registries/registry_inspect.go @@ -5,7 +5,8 @@ import ( portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" - "github.com/portainer/portainer/api/http/errors" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" @@ -27,6 +28,11 @@ import ( // @failure 500 "Server error" // @router /registries/{id} [get] func (handler *Handler) registryInspect(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} + } + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} @@ -39,11 +45,24 @@ 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} + // check user access for registry + if !securityContext.IsAdmin { + 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.RetrieveNumericQueryParameter(r, "endpointId", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} + } + + if !security.AuthorizedRegistryAccess(registry, user, securityContext.UserMemberships, portainer.EndpointID(endpointID)) { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} + } } - hideFields(registry) + hideAccesses := !securityContext.IsAdmin + hideFields(registry, hideAccesses) 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 85540d72d..7c379018f 100644 --- a/api/http/handler/registries/registry_update.go +++ b/api/http/handler/registries/registry_update.go @@ -9,6 +9,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 registryUpdatePayload struct { @@ -21,10 +23,9 @@ type registryUpdatePayload struct { // 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"` - UserAccessPolicies portainer.UserAccessPolicies - TeamAccessPolicies portainer.TeamAccessPolicies - Quay *portainer.QuayRegistryData + Password *string `example:"registry_password"` + RegistryAccesses *portainer.RegistryAccesses + Quay *portainer.QuayRegistryData } func (payload *registryUpdatePayload) Validate(r *http.Request) error { @@ -48,17 +49,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} @@ -66,27 +69,22 @@ 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 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 +101,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 +144,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/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go index 2c78ee0f9..cb979adc4 100644 --- a/api/http/handler/stacks/create_compose_stack.go +++ b/api/http/handler/stacks/create_compose_stack.go @@ -299,7 +299,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 @@ -311,26 +310,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, @@ -375,7 +368,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 e34df227e..555d3feb9 100644 --- a/api/http/handler/stacks/create_swarm_stack.go +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -309,7 +309,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 @@ -322,26 +321,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, @@ -376,7 +369,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 c3383035c..b9afaad75 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/* @@ -28,7 +28,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 } @@ -50,7 +50,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 } @@ -64,7 +64,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 } @@ -76,7 +76,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 } @@ -88,7 +88,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) @@ -96,14 +96,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 803913ac5..24f0b4ba1 100644 --- a/api/http/proxy/factory/docker/transport.go +++ b/api/http/proxy/factory/docker/transport.go @@ -15,7 +15,7 @@ import ( "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" ) @@ -394,13 +394,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 { @@ -430,7 +430,7 @@ func (transport *Transport) restrictedResourceOperation(request *http.Request, r } if !securitySettings.AllowVolumeBrowserForRegularUsers { - return responseutils.WriteAccessDeniedResponse() + return utils.WriteAccessDeniedResponse() } } @@ -461,12 +461,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() } } @@ -530,7 +530,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 } @@ -549,7 +549,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) { @@ -612,7 +612,7 @@ func (transport *Transport) administratorOperation(request *http.Request) (*http } if tokenData.Role != portainer.AdministratorRole { - return responseutils.WriteAccessDeniedResponse() + return utils.WriteAccessDeniedResponse() } return transport.executeDockerRequest(request) @@ -625,15 +625,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 { @@ -641,7 +641,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 c1bd3d993..1bd08272e 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..031af4e17 --- /dev/null +++ b/api/http/proxy/factory/kubernetes/agent_transport.go @@ -0,0 +1,57 @@ +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 := transport.prepareRoundTrip(request) + 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.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..a7dafd1ca --- /dev/null +++ b/api/http/proxy/factory/kubernetes/edge_transport.go @@ -0,0 +1,54 @@ +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 := transport.prepareRoundTrip(request) + 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.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..fd8be0661 --- /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, false) +} diff --git a/api/http/proxy/factory/kubernetes/secrets.go b/api/http/proxy/factory/kubernetes/secrets.go new file mode 100644 index 000000000..5442da501 --- /dev/null +++ b/api/http/proxy/factory/kubernetes/secrets.go @@ -0,0 +1,158 @@ +package kubernetes + +import ( + "net/http" + "path" + + "github.com/portainer/portainer/api/http/proxy/factory/utils" + "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, true) + } +} + +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, false) +} + +func (transport *baseTransport) proxySecretListOperation(request *http.Request) (*http.Response, error) { + response, err := transport.executeKubernetesRequest(request, false) + if err != nil { + return nil, err + } + + body, err := utils.GetResponseAsJSONObject(response) + if err != nil { + return nil, err + } + + if _, ok := body["items"]; !ok { + utils.RewriteResponse(response, body, response.StatusCode) + return response, nil + } + + items, ok := body["items"].([]interface{}) + + if !ok { + 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, false) + if err != nil { + return nil, err + } + + 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 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 +} + +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, false) +} + +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, false) +} diff --git a/api/http/proxy/factory/kubernetes/transport.go b/api/http/proxy/factory/kubernetes/transport.go index 377c7a3dc..e70d8f9c4 100644 --- a/api/http/proxy/factory/kubernetes/transport.go +++ b/api/http/proxy/factory/kubernetes/transport.go @@ -2,153 +2,104 @@ package kubernetes import ( "bytes" - "crypto/tls" "encoding/json" "fmt" "io/ioutil" "log" "net/http" + "regexp" "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, true) + case strings.HasPrefix(requestPath, "/namespaces"): + return transport.proxyNamespacedRequest(request, requestPath) + default: + return transport.executeKubernetesRequest(request, true) + } +} + +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, true) + } +} + +func (transport *baseTransport) executeKubernetesRequest(request *http.Request, shouldLog bool) (*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,6 +119,10 @@ 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") @@ -197,3 +152,5 @@ func decorateAgentDockerHubRequest(r *http.Request, dataStore portainer.DataStor 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..0c0eb2d26 --- /dev/null +++ b/api/http/proxy/factory/utils/json.go @@ -0,0 +1,81 @@ +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 +} + +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..d15b8c231 100644 --- a/api/http/security/authorization.go +++ b/api/http/security/authorization.go @@ -1,7 +1,7 @@ package security import ( - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" ) // AuthorizedResourceControlAccess checks whether the user can alter an existing resource control. @@ -95,9 +95,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 +106,21 @@ 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. +// AuthorizedRegistryAccess ensure that the NON ADMIN 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 { + + 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..9c32fa116 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. 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 4e0e8c775..1bb9f3b05 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" @@ -109,9 +108,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 @@ -157,6 +153,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 @@ -215,7 +212,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 31916cd31..ea526785d 100644 --- a/api/internal/authorization/authorizations.go +++ b/api/internal/authorization/authorizations.go @@ -1,6 +1,6 @@ package authorization -import "github.com/portainer/portainer/api" +import portainer "github.com/portainer/portainer/api" // Service represents a service used to // update authorizations associated to a user or team. @@ -136,6 +136,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/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/registries.go b/api/kubernetes/cli/registries.go new file mode 100644 index 000000000..bbfc84009 --- /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 { + 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 0f6a72568..4874eb194 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -517,8 +517,12 @@ 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 == 28 + UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"` + TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"` // Deprecated fields // Deprecated in DBVersion == 18 @@ -526,6 +530,14 @@ type ( 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 @@ -1006,7 +1018,6 @@ type ( CheckCurrentEdition() error BackupTo(w io.Writer) error - DockerHub() DockerHubService CustomTemplate() CustomTemplateService EdgeGroup() EdgeGroupService EdgeJob() EdgeJobService @@ -1037,12 +1048,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) @@ -1154,6 +1159,9 @@ type ( SetupUserServiceAccount(userID int, teamIDs []int) error GetServiceAccountBearerToken(userID int) (string, error) StartExecProcess(namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer) 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 @@ -1250,7 +1258,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 @@ -1475,6 +1483,8 @@ const ( CustomRegistry // GitlabRegistry represents a gitlab registry GitlabRegistry + // DockerHubRegistry represents a dockerhub registry + DockerHubRegistry ) const (