From 2697d6c5d77ea5cac51aa13d89f1ce3181346710 Mon Sep 17 00:00:00 2001 From: Ali <83188384+testA113@users.noreply.github.com> Date: Sun, 13 Jul 2025 10:37:43 +1200 Subject: [PATCH] feat(oci): oci helm support [r8s-361] (#787) --- .../test_data/output_24_to_latest.json | 4 + api/http/handler/registries/handler.go | 126 +-- .../registries/registry_access_test.go | 89 +++ .../handler/registries/registry_inspect.go | 17 +- api/http/handler/webhooks/webhook_create.go | 2 +- api/http/handler/webhooks/webhook_update.go | 2 +- api/http/proxy/factory/docker/registry.go | 5 +- api/http/proxy/factory/github/client.go | 108 +++ api/http/proxy/factory/gitlab/client.go | 130 +++ api/http/proxy/factory/gitlab/transport.go | 34 - api/internal/registryutils/access/access.go | 52 +- api/internal/registryutils/ecr_reg_token.go | 23 + api/kubernetes/cli/namespace.go | 6 + api/portainer.go | 9 + app/kubernetes/react/components/index.ts | 1 + .../kube-registry-access-view.html | 1 + app/kubernetes/views/deploy/deploy.html | 17 +- .../views/deploy/deployController.js | 12 +- app/portainer/react/components/index.ts | 12 + app/react/components/ExternalLink.tsx | 12 +- app/react/components/FallbackImage.tsx | 2 +- .../form-components/PortainerSelect.tsx | 52 +- .../NamespaceSelector/NamespaceSelector.tsx | 26 + .../RegistryAccessView/NamespacesSelector.tsx | 21 +- .../ChartActions/ChartActions.tsx | 10 +- .../ChartActions/UpgradeButton.test.tsx | 6 +- .../ChartActions/UpgradeButton.tsx | 15 +- .../HelmApplicationView.test.tsx | 78 +- .../HelmApplicationView.tsx | 49 +- .../HelmTemplates/HelmInstallForm.test.tsx | 1 + .../helm/HelmTemplates/HelmInstallForm.tsx | 31 +- .../HelmTemplates/HelmInstallInnerForm.tsx | 63 +- .../helm/HelmTemplates/HelmTemplates.tsx | 103 ++- .../HelmTemplates/HelmTemplatesList.test.tsx | 68 +- .../helm/HelmTemplates/HelmTemplatesList.tsx | 109 +-- .../HelmTemplatesSelectedItem.tsx | 2 +- .../kubernetes/helm/HelmTemplates/types.ts | 1 + .../components/HelmRegistrySelect.test.tsx | 242 ++++++ .../helm/components/HelmRegistrySelect.tsx | 156 ++++ .../helm/queries/useHelmChartList.ts | 57 +- .../helm/queries/useHelmChartValues.ts | 19 +- .../helm/queries/useHelmRegistries.ts | 43 - .../helm/queries/useHelmRepoVersions.ts | 35 +- .../helm/queries/useHelmRepositories.ts | 84 ++ app/react/kubernetes/helm/types.ts | 13 +- .../AccessControlPanelDetails.tsx | 6 +- .../HelmRepositoryDatatable.tsx | 32 +- .../environments/queries/query-keys.ts | 9 +- .../queries/useEnvironmentRegistries.ts | 19 +- .../registries/CreateView/options.tsx | 54 +- .../portainer/registries/queries/build-url.ts | 12 +- .../registries/queries/useRegistries.ts | 2 + .../portainer/registries/utils/constants.tsx | 35 + .../KubeSettingsPanel/HelmSection.tsx | 38 +- go.mod | 4 +- pkg/libhelm/cache/cache.go | 126 +++ pkg/libhelm/cache/manager.go | 81 ++ pkg/libhelm/options/chart_reference.go | 38 + pkg/libhelm/options/chart_reference_test.go | 100 +++ pkg/libhelm/options/install_options.go | 7 +- pkg/libhelm/options/search_repo_options.go | 7 +- pkg/libhelm/options/show_options.go | 6 +- pkg/libhelm/release/release.go | 8 + pkg/libhelm/sdk/chartsources.go | 297 +++++++ pkg/libhelm/sdk/chartsources_test.go | 752 ++++++++++++++++++ pkg/libhelm/sdk/common.go | 208 ++++- pkg/libhelm/sdk/get.go | 3 +- pkg/libhelm/sdk/install.go | 48 +- pkg/libhelm/sdk/search_repo.go | 434 ++++++---- pkg/libhelm/sdk/show.go | 96 +-- pkg/libhelm/sdk/show_test.go | 4 +- pkg/libhelm/sdk/upgrade.go | 51 +- pkg/libhelm/sdk/values.go | 4 +- pkg/liboras/generic_listrepo_client.go | 47 ++ pkg/liboras/github_listrepo_client.go | 57 ++ pkg/liboras/gitlab_listrepo_client.go | 47 ++ pkg/liboras/listrepo_client.go | 39 + pkg/liboras/registry.go | 79 ++ pkg/liboras/registry_test.go | 252 ++++++ pkg/liboras/repository.go | 126 +++ 80 files changed, 4264 insertions(+), 812 deletions(-) create mode 100644 api/http/handler/registries/registry_access_test.go create mode 100644 api/http/proxy/factory/github/client.go create mode 100644 api/http/proxy/factory/gitlab/client.go delete mode 100644 api/http/proxy/factory/gitlab/transport.go create mode 100644 app/react/kubernetes/helm/components/HelmRegistrySelect.test.tsx create mode 100644 app/react/kubernetes/helm/components/HelmRegistrySelect.tsx delete mode 100644 app/react/kubernetes/helm/queries/useHelmRegistries.ts create mode 100644 app/react/kubernetes/helm/queries/useHelmRepositories.ts create mode 100644 app/react/portainer/registries/utils/constants.tsx create mode 100644 pkg/libhelm/cache/cache.go create mode 100644 pkg/libhelm/cache/manager.go create mode 100644 pkg/libhelm/options/chart_reference.go create mode 100644 pkg/libhelm/options/chart_reference_test.go create mode 100644 pkg/libhelm/sdk/chartsources.go create mode 100644 pkg/libhelm/sdk/chartsources_test.go create mode 100644 pkg/liboras/generic_listrepo_client.go create mode 100644 pkg/liboras/github_listrepo_client.go create mode 100644 pkg/liboras/gitlab_listrepo_client.go create mode 100644 pkg/liboras/listrepo_client.go create mode 100644 pkg/liboras/registry.go create mode 100644 pkg/liboras/registry_test.go create mode 100644 pkg/liboras/repository.go diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json index dc50e6788..8bdc55983 100644 --- a/api/datastore/test_data/output_24_to_latest.json +++ b/api/datastore/test_data/output_24_to_latest.json @@ -121,6 +121,10 @@ "Ecr": { "Region": "" }, + "Github": { + "OrganisationName": "", + "UseOrganisation": false + }, "Gitlab": { "InstanceURL": "", "ProjectId": 0, diff --git a/api/http/handler/registries/handler.go b/api/http/handler/registries/handler.go index dee14885e..026039833 100644 --- a/api/http/handler/registries/handler.go +++ b/api/http/handler/registries/handler.go @@ -5,10 +5,10 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/security" - "github.com/portainer/portainer/api/internal/endpointutils" - "github.com/portainer/portainer/api/kubernetes" + "github.com/portainer/portainer/api/internal/registryutils/access" "github.com/portainer/portainer/api/kubernetes/cli" "github.com/portainer/portainer/api/pendingactions" httperror "github.com/portainer/portainer/pkg/libhttp/error" @@ -17,6 +17,7 @@ import ( "github.com/gorilla/mux" "github.com/pkg/errors" + "github.com/rs/zerolog/log" ) func hideFields(registry *portainer.Registry, hideAccesses bool) { @@ -56,17 +57,20 @@ func newHandler(bouncer security.BouncerService) *Handler { func (handler *Handler) initRouter(bouncer accessGuard) { adminRouter := handler.NewRoute().Subrouter() adminRouter.Use(bouncer.AdminAccess) - - authenticatedRouter := handler.NewRoute().Subrouter() - authenticatedRouter.Use(bouncer.AuthenticatedAccess) - adminRouter.Handle("/registries", httperror.LoggerHandler(handler.registryList)).Methods(http.MethodGet) adminRouter.Handle("/registries", httperror.LoggerHandler(handler.registryCreate)).Methods(http.MethodPost) adminRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryUpdate)).Methods(http.MethodPut) adminRouter.Handle("/registries/{id}/configure", httperror.LoggerHandler(handler.registryConfigure)).Methods(http.MethodPost) adminRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryDelete)).Methods(http.MethodDelete) - authenticatedRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryInspect)).Methods(http.MethodGet) + // Use registry-specific access bouncer for inspect and repositories endpoints + registryAccessRouter := handler.NewRoute().Subrouter() + registryAccessRouter.Use(bouncer.AuthenticatedAccess, handler.RegistryAccess) + registryAccessRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryInspect)).Methods(http.MethodGet) + + // Keep the gitlab proxy on the regular authenticated router as it doesn't require specific registry access + authenticatedRouter := handler.NewRoute().Subrouter() + authenticatedRouter.Use(bouncer.AuthenticatedAccess) authenticatedRouter.PathPrefix("/registries/proxies/gitlab").Handler(httperror.LoggerHandler(handler.proxyRequestsToGitlabAPIWithoutRegistry)) } @@ -88,9 +92,7 @@ func (handler *Handler) registriesHaveSameURLAndCredentials(r1, r2 *portainer.Re } // this function validates that -// // 1. user has the appropriate authorizations to perform the request -// // 2. user has a direct or indirect access to the registry func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portainer.Registry) (hasAccess bool, isAdmin bool, err error) { securityContext, err := security.RetrieveRestrictedRequestContext(r) @@ -98,11 +100,6 @@ func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portain return false, false, err } - user, err := handler.DataStore.User().Read(securityContext.UserID) - if err != nil { - return false, false, err - } - // Portainer admins always have access to everything if securityContext.IsAdmin { return true, true, nil @@ -128,47 +125,68 @@ func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portain return false, false, err } - memberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID) + // Use the enhanced registry access utility function that includes namespace validation + _, err = access.GetAccessibleRegistry( + handler.DataStore, + handler.K8sClientFactory, + securityContext.UserID, + endpointId, + registry.ID, + ) if err != nil { - return false, false, nil + return false, false, nil // No access } - // validate access for kubernetes namespaces (leverage registry.RegistryAccesses[endpointId].Namespaces) - if endpointutils.IsKubernetesEndpoint(endpoint) { - kcl, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint) - if err != nil { - return false, false, errors.Wrap(err, "unable to retrieve kubernetes client to validate registry access") - } - accessPolicies, err := kcl.GetNamespaceAccessPolicies() - if err != nil { - return false, false, errors.Wrap(err, "unable to retrieve environment's namespaces policies to validate registry access") - } - - authorizedNamespaces := registry.RegistryAccesses[endpointId].Namespaces - - for _, namespace := range authorizedNamespaces { - // when the default namespace is authorized to use a registry, all users have the ability to use it - // unless the default namespace is restricted: in this case continue to search for other potential accesses authorizations - if namespace == kubernetes.DefaultNamespace && !endpoint.Kubernetes.Configuration.RestrictDefaultNamespace { - return true, false, nil - } - - namespacePolicy := accessPolicies[namespace] - if security.AuthorizedAccess(user.ID, memberships, namespacePolicy.UserAccessPolicies, namespacePolicy.TeamAccessPolicies) { - return true, false, nil - } - } - return false, false, nil - } - - // validate access for docker environments - // leverage registry.RegistryAccesses[endpointId].UserAccessPolicies (direct access) - // and registry.RegistryAccesses[endpointId].TeamAccessPolicies (indirect access via his teams) - if security.AuthorizedRegistryAccess(registry, user, memberships, endpoint.ID) { - return true, false, nil - } - - // when user has no access via their role, direct grant or indirect grant - // then they don't have access to the registry - return false, false, nil + return true, false, nil +} + +// RegistryAccess defines a security check for registry-specific API endpoints. +// Authentication is required to access these endpoints. +// The user must have direct or indirect access to the specific registry being requested. +// This bouncer validates registry access using the userHasRegistryAccess logic. +func (handler *Handler) RegistryAccess(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // First ensure the user is authenticated + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + httperror.WriteError(w, http.StatusUnauthorized, "Authentication required", httperrors.ErrUnauthorized) + return + } + + // Extract registry ID from the route + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + httperror.WriteError(w, http.StatusBadRequest, "Invalid registry identifier route variable", err) + return + } + + // Get the registry from the database + registry, err := handler.DataStore.Registry().Read(portainer.RegistryID(registryID)) + if handler.DataStore.IsErrObjectNotFound(err) { + httperror.WriteError(w, http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err) + return + } else if err != nil { + httperror.WriteError(w, http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err) + return + } + + // Check if user has access to this registry + hasAccess, _, err := handler.userHasRegistryAccess(r, registry) + if err != nil { + httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve info from request context", err) + return + } + if !hasAccess { + log.Debug(). + Int("registry_id", registryID). + Str("registry_name", registry.Name). + Int("user_id", int(tokenData.ID)). + Str("context", "RegistryAccessBouncer"). + Msg("User access denied to registry") + httperror.WriteError(w, http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied) + return + } + + next.ServeHTTP(w, r) + }) } diff --git a/api/http/handler/registries/registry_access_test.go b/api/http/handler/registries/registry_access_test.go new file mode 100644 index 000000000..8231f4d66 --- /dev/null +++ b/api/http/handler/registries/registry_access_test.go @@ -0,0 +1,89 @@ +package registries + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/testhelpers" + + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" +) + +func Test_RegistryAccess_RequiresAuthentication(t *testing.T) { + _, store := datastore.MustNewTestStore(t, true, true) + registry := &portainer.Registry{ + ID: 1, + Name: "test-registry", + URL: "https://registry.test.com", + } + err := store.Registry().Create(registry) + assert.NoError(t, err) + handler := &Handler{ + DataStore: store, + } + req := httptest.NewRequest(http.MethodGet, "/registries/1", nil) + req = mux.SetURLVars(req, map[string]string{"id": "1"}) + rr := httptest.NewRecorder() + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + bouncer := handler.RegistryAccess(testHandler) + bouncer.ServeHTTP(rr, req) + assert.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func Test_RegistryAccess_InvalidRegistryID(t *testing.T) { + _, store := datastore.MustNewTestStore(t, true, true) + user := &portainer.User{ID: 1, Username: "test", Role: portainer.StandardUserRole} + err := store.User().Create(user) + assert.NoError(t, err) + + handler := &Handler{ + DataStore: store, + } + req := httptest.NewRequest(http.MethodGet, "/registries/invalid", nil) + req = mux.SetURLVars(req, map[string]string{"id": "invalid"}) + tokenData := &portainer.TokenData{ID: 1, Role: portainer.StandardUserRole} + req = req.WithContext(security.StoreTokenData(req, tokenData)) + + rr := httptest.NewRecorder() + + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + bouncer := handler.RegistryAccess(testHandler) + bouncer.ServeHTTP(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) +} + +func Test_RegistryAccess_RegistryNotFound(t *testing.T) { + _, store := datastore.MustNewTestStore(t, true, true) + user := &portainer.User{ID: 1, Username: "test", Role: portainer.StandardUserRole} + err := store.User().Create(user) + assert.NoError(t, err) + + handler := &Handler{ + DataStore: store, + requestBouncer: testhelpers.NewTestRequestBouncer(), + } + req := httptest.NewRequest(http.MethodGet, "/registries/999", nil) + req = mux.SetURLVars(req, map[string]string{"id": "999"}) + tokenData := &portainer.TokenData{ID: 1, Role: portainer.StandardUserRole} + req = req.WithContext(security.StoreTokenData(req, tokenData)) + + rr := httptest.NewRecorder() + + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + bouncer := handler.RegistryAccess(testHandler) + bouncer.ServeHTTP(rr, req) + assert.Equal(t, http.StatusNotFound, rr.Code) +} diff --git a/api/http/handler/registries/registry_inspect.go b/api/http/handler/registries/registry_inspect.go index a1f0bd9c5..f606a953e 100644 --- a/api/http/handler/registries/registry_inspect.go +++ b/api/http/handler/registries/registry_inspect.go @@ -4,10 +4,12 @@ import ( "net/http" portainer "github.com/portainer/portainer/api" - httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/rs/zerolog/log" ) // @id RegistryInspect @@ -31,6 +33,11 @@ func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request) return httperror.BadRequest("Invalid registry identifier route variable", err) } + log.Debug(). + Int("registry_id", registryID). + Str("context", "RegistryInspectHandler"). + Msg("Starting registry inspection") + registry, err := handler.DataStore.Registry().Read(portainer.RegistryID(registryID)) if handler.DataStore.IsErrObjectNotFound(err) { return httperror.NotFound("Unable to find a registry with the specified identifier inside the database", err) @@ -38,14 +45,12 @@ func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request) return httperror.InternalServerError("Unable to find a registry with the specified identifier inside the database", err) } - hasAccess, isAdmin, err := handler.userHasRegistryAccess(r, registry) + // Check if user is admin to determine if we should hide sensitive fields + securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { return httperror.InternalServerError("Unable to retrieve info from request context", err) } - if !hasAccess { - return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied) - } - hideFields(registry, !isAdmin) + hideFields(registry, !securityContext.IsAdmin) return response.JSON(w, registry) } diff --git a/api/http/handler/webhooks/webhook_create.go b/api/http/handler/webhooks/webhook_create.go index b69e93db3..d7edde333 100644 --- a/api/http/handler/webhooks/webhook_create.go +++ b/api/http/handler/webhooks/webhook_create.go @@ -80,7 +80,7 @@ func (handler *Handler) webhookCreate(w http.ResponseWriter, r *http.Request) *h return httperror.InternalServerError("Unable to retrieve user authentication token", err) } - _, err = access.GetAccessibleRegistry(handler.DataStore, tokenData.ID, endpointID, payload.RegistryID) + _, err = access.GetAccessibleRegistry(handler.DataStore, nil, tokenData.ID, endpointID, payload.RegistryID) if err != nil { return httperror.Forbidden("Permission deny to access registry", err) } diff --git a/api/http/handler/webhooks/webhook_update.go b/api/http/handler/webhooks/webhook_update.go index 7a026fcd7..94133c49a 100644 --- a/api/http/handler/webhooks/webhook_update.go +++ b/api/http/handler/webhooks/webhook_update.go @@ -69,7 +69,7 @@ func (handler *Handler) webhookUpdate(w http.ResponseWriter, r *http.Request) *h return httperror.InternalServerError("Unable to retrieve user authentication token", err) } - _, err = access.GetAccessibleRegistry(handler.DataStore, tokenData.ID, webhook.EndpointID, payload.RegistryID) + _, err = access.GetAccessibleRegistry(handler.DataStore, nil, tokenData.ID, webhook.EndpointID, payload.RegistryID) if err != nil { return httperror.Forbidden("Permission deny to access registry", err) } diff --git a/api/http/proxy/factory/docker/registry.go b/api/http/proxy/factory/docker/registry.go index ecf7935f1..7036853c7 100644 --- a/api/http/proxy/factory/docker/registry.go +++ b/api/http/proxy/factory/docker/registry.go @@ -55,12 +55,13 @@ func createRegistryAuthenticationHeader( return } - if err = registryutils.EnsureRegTokenValid(dataStore, matchingRegistry); err != nil { + if err = registryutils.PrepareRegistryCredentials(dataStore, matchingRegistry); err != nil { return } authenticationHeader.Serveraddress = matchingRegistry.URL - authenticationHeader.Username, authenticationHeader.Password, err = registryutils.GetRegEffectiveCredential(matchingRegistry) + authenticationHeader.Username = matchingRegistry.Username + authenticationHeader.Password = matchingRegistry.Password return } diff --git a/api/http/proxy/factory/github/client.go b/api/http/proxy/factory/github/client.go new file mode 100644 index 000000000..74dcfb994 --- /dev/null +++ b/api/http/proxy/factory/github/client.go @@ -0,0 +1,108 @@ +package github + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/segmentio/encoding/json" + "oras.land/oras-go/v2/registry/remote/retry" +) + +const GitHubAPIHost = "https://api.github.com" + +// Package represents a GitHub container package +type Package struct { + Name string `json:"name"` + Owner struct { + Login string `json:"login"` + } `json:"owner"` +} + +// Client represents a GitHub API client +type Client struct { + httpClient *http.Client + baseURL string +} + +// NewClient creates a new GitHub API client +func NewClient(token string) *Client { + return &Client{ + httpClient: NewHTTPClient(token), + baseURL: GitHubAPIHost, + } +} + +// GetContainerPackages fetches container packages for the configured namespace +// It's a small http client wrapper instead of using the github client because listing repositories is the only known operation that isn't directly supported by oras +func (c *Client) GetContainerPackages(ctx context.Context, useOrganisation bool, organisationName string) ([]string, error) { + // Determine the namespace (user or organisation) for the request + namespace := "user" + if useOrganisation { + namespace = "orgs/" + organisationName + } + + // Build the full URL for listing container packages + url := fmt.Sprintf("%s/%s/packages?package_type=container", c.baseURL, namespace) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, resp.Status) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var packages []Package + if err := json.Unmarshal(body, &packages); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + // Extract repository names in the form "owner/name" + repositories := make([]string, len(packages)) + for i, pkg := range packages { + repositories[i] = fmt.Sprintf("%s/%s", strings.ToLower(pkg.Owner.Login), strings.ToLower(pkg.Name)) + } + + return repositories, nil +} + +// NewHTTPClient creates a new HTTP client configured for GitHub API requests +func NewHTTPClient(token string) *http.Client { + return &http.Client{ + Transport: &tokenTransport{ + token: token, + transport: retry.NewTransport(&http.Transport{}), // Use ORAS retry transport for consistent rate limiting and error handling + }, + Timeout: 1 * time.Minute, + } +} + +// tokenTransport automatically adds the Bearer token header to requests +type tokenTransport struct { + token string + transport http.RoundTripper +} + +func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if t.token != "" { + req.Header.Set("Authorization", "Bearer "+t.token) + req.Header.Set("Accept", "application/vnd.github+json") + } + return t.transport.RoundTrip(req) +} diff --git a/api/http/proxy/factory/gitlab/client.go b/api/http/proxy/factory/gitlab/client.go new file mode 100644 index 000000000..13d07e18b --- /dev/null +++ b/api/http/proxy/factory/gitlab/client.go @@ -0,0 +1,130 @@ +package gitlab + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/segmentio/encoding/json" + "oras.land/oras-go/v2/registry/remote/retry" +) + +// Repository represents a GitLab registry repository +type Repository struct { + ID int `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + ProjectID int `json:"project_id"` + Location string `json:"location"` + CreatedAt string `json:"created_at"` + Status string `json:"status"` +} + +// Client represents a GitLab API client +type Client struct { + httpClient *http.Client + baseURL string +} + +// NewClient creates a new GitLab API client +// it currently is an http client because only GetRegistryRepositoryNames is needed (oras supports other commands). +// if we need to support other commands, consider using the gitlab client library. +func NewClient(baseURL, token string) *Client { + return &Client{ + httpClient: NewHTTPClient(token), + baseURL: baseURL, + } +} + +// GetRegistryRepositoryNames fetches registry repository names for a given project. +// It's a small http client wrapper instead of using the gitlab client library because listing repositories is the only known operation that isn't directly supported by oras +func (c *Client) GetRegistryRepositoryNames(ctx context.Context, projectID int) ([]string, error) { + url := fmt.Sprintf("%s/api/v4/projects/%d/registry/repositories", c.baseURL, projectID) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitLab API returned status %d: %s", resp.StatusCode, resp.Status) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var repositories []Repository + if err := json.Unmarshal(body, &repositories); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + // Extract repository names + names := make([]string, len(repositories)) + for i, repo := range repositories { + // the full path is required for further repo operations + names[i] = repo.Path + } + + return names, nil +} + +type Transport struct { + httpTransport *http.Transport +} + +// NewTransport returns a pointer to a new instance of Transport that implements the HTTP Transport +// interface for proxying requests to the Gitlab API. +func NewTransport() *Transport { + return &Transport{ + httpTransport: &http.Transport{}, + } +} + +// RoundTrip is the implementation of the http.RoundTripper interface +func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) { + token := request.Header.Get("Private-Token") + if token == "" { + return nil, errors.New("no gitlab token provided") + } + + r, err := http.NewRequest(request.Method, request.URL.String(), request.Body) + if err != nil { + return nil, err + } + + r.Header.Set("Private-Token", token) + return transport.httpTransport.RoundTrip(r) +} + +// NewHTTPClient creates a new HTTP client configured for GitLab API requests +func NewHTTPClient(token string) *http.Client { + return &http.Client{ + Transport: &tokenTransport{ + token: token, + transport: retry.NewTransport(&http.Transport{}), // Use ORAS retry transport for consistent rate limiting and error handling + }, + Timeout: 1 * time.Minute, + } +} + +// tokenTransport automatically adds the Private-Token header to requests +type tokenTransport struct { + token string + transport http.RoundTripper +} + +func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Set("Private-Token", t.token) + return t.transport.RoundTrip(req) +} diff --git a/api/http/proxy/factory/gitlab/transport.go b/api/http/proxy/factory/gitlab/transport.go deleted file mode 100644 index 7e1804c45..000000000 --- a/api/http/proxy/factory/gitlab/transport.go +++ /dev/null @@ -1,34 +0,0 @@ -package gitlab - -import ( - "errors" - "net/http" -) - -type Transport struct { - httpTransport *http.Transport -} - -// NewTransport returns a pointer to a new instance of Transport that implements the HTTP Transport -// interface for proxying requests to the Gitlab API. -func NewTransport() *Transport { - return &Transport{ - httpTransport: &http.Transport{}, - } -} - -// RoundTrip is the implementation of the http.RoundTripper interface -func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) { - token := request.Header.Get("Private-Token") - if token == "" { - return nil, errors.New("no gitlab token provided") - } - - r, err := http.NewRequest(request.Method, request.URL.String(), request.Body) - if err != nil { - return nil, err - } - - r.Header.Set("Private-Token", token) - return transport.httpTransport.RoundTrip(r) -} diff --git a/api/internal/registryutils/access/access.go b/api/internal/registryutils/access/access.go index 0d14cba39..bfa5181c0 100644 --- a/api/internal/registryutils/access/access.go +++ b/api/internal/registryutils/access/access.go @@ -2,40 +2,82 @@ package access import ( "errors" + "fmt" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/kubernetes" + "github.com/portainer/portainer/api/kubernetes/cli" ) func hasPermission( dataStore dataservices.DataStore, + k8sClientFactory *cli.ClientFactory, userID portainer.UserID, endpointID portainer.EndpointID, registry *portainer.Registry, ) (hasPermission bool, err error) { user, err := dataStore.User().Read(userID) if err != nil { - return + return false, err } if user.Role == portainer.AdministratorRole { - return true, err + return true, nil + } + + endpoint, err := dataStore.Endpoint().Endpoint(endpointID) + if err != nil { + return false, err } teamMemberships, err := dataStore.TeamMembership().TeamMembershipsByUserID(userID) if err != nil { - return + return false, err } + // validate access for kubernetes namespaces (leverage registry.RegistryAccesses[endpointId].Namespaces) + if endpointutils.IsKubernetesEndpoint(endpoint) && k8sClientFactory != nil { + kcl, err := k8sClientFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + return false, fmt.Errorf("unable to retrieve kubernetes client to validate registry access: %w", err) + } + accessPolicies, err := kcl.GetNamespaceAccessPolicies() + if err != nil { + return false, fmt.Errorf("unable to retrieve environment's namespaces policies to validate registry access: %w", err) + } + + authorizedNamespaces := registry.RegistryAccesses[endpointID].Namespaces + + for _, namespace := range authorizedNamespaces { + // when the default namespace is authorized to use a registry, all users have the ability to use it + // unless the default namespace is restricted: in this case continue to search for other potential accesses authorizations + if namespace == kubernetes.DefaultNamespace && !endpoint.Kubernetes.Configuration.RestrictDefaultNamespace { + return true, nil + } + + namespacePolicy := accessPolicies[namespace] + if security.AuthorizedAccess(user.ID, teamMemberships, namespacePolicy.UserAccessPolicies, namespacePolicy.TeamAccessPolicies) { + return true, nil + } + } + return false, nil + } + + // validate access for docker environments + // leverage registry.RegistryAccesses[endpointId].UserAccessPolicies (direct access) + // and registry.RegistryAccesses[endpointId].TeamAccessPolicies (indirect access via his teams) hasPermission = security.AuthorizedRegistryAccess(registry, user, teamMemberships, endpointID) - return + return hasPermission, nil } // GetAccessibleRegistry get the registry if the user has permission func GetAccessibleRegistry( dataStore dataservices.DataStore, + k8sClientFactory *cli.ClientFactory, userID portainer.UserID, endpointID portainer.EndpointID, registryID portainer.RegistryID, @@ -46,7 +88,7 @@ func GetAccessibleRegistry( return } - hasPermission, err := hasPermission(dataStore, userID, endpointID, registry) + hasPermission, err := hasPermission(dataStore, k8sClientFactory, userID, endpointID, registry) if err != nil { return } diff --git a/api/internal/registryutils/ecr_reg_token.go b/api/internal/registryutils/ecr_reg_token.go index cbcceb982..6e9a754bf 100644 --- a/api/internal/registryutils/ecr_reg_token.go +++ b/api/internal/registryutils/ecr_reg_token.go @@ -62,3 +62,26 @@ func GetRegEffectiveCredential(registry *portainer.Registry) (username, password return } + +// PrepareRegistryCredentials consolidates the common pattern of ensuring valid ECR token +// and setting effective credentials on the registry when authentication is enabled. +// This function modifies the registry in-place by setting Username and Password to the effective values. +func PrepareRegistryCredentials(tx dataservices.DataStoreTx, registry *portainer.Registry) error { + if !registry.Authentication { + return nil + } + + if err := EnsureRegTokenValid(tx, registry); err != nil { + return err + } + + username, password, err := GetRegEffectiveCredential(registry) + if err != nil { + return err + } + + registry.Username = username + registry.Password = password + + return nil +} diff --git a/api/kubernetes/cli/namespace.go b/api/kubernetes/cli/namespace.go index bb29680b5..560b91e75 100644 --- a/api/kubernetes/cli/namespace.go +++ b/api/kubernetes/cli/namespace.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "sort" "strconv" "time" @@ -437,5 +438,10 @@ func (kcl *KubeClient) ConvertNamespaceMapToSlice(namespaces map[string]portaine namespaceSlice = append(namespaceSlice, namespace) } + // Sort namespaces by name + sort.Slice(namespaceSlice, func(i, j int) bool { + return namespaceSlice[i].Name < namespaceSlice[j].Name + }) + return namespaceSlice } diff --git a/api/portainer.go b/api/portainer.go index 0dc235550..e2bac623b 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -603,6 +603,12 @@ type ( ProjectPath string `json:"ProjectPath"` } + // GithubRegistryData represents data required for Github registry to work + GithubRegistryData struct { + UseOrganisation bool `json:"UseOrganisation"` + OrganisationName string `json:"OrganisationName"` + } + HelmUserRepositoryID int // HelmUserRepositories stores a Helm repository URL for the given user @@ -823,6 +829,7 @@ type ( Password string `json:"Password,omitempty" example:"registry_password"` ManagementConfiguration *RegistryManagementConfiguration `json:"ManagementConfiguration"` Gitlab GitlabRegistryData `json:"Gitlab"` + Github GithubRegistryData `json:"Github"` Quay QuayRegistryData `json:"Quay"` Ecr EcrData `json:"Ecr"` RegistryAccesses RegistryAccesses `json:"RegistryAccesses"` @@ -1972,6 +1979,8 @@ const ( DockerHubRegistry // EcrRegistry represents an ECR registry EcrRegistry + // Github container registry + GithubRegistry ) const ( diff --git a/app/kubernetes/react/components/index.ts b/app/kubernetes/react/components/index.ts index 27aa04444..cfb103823 100644 --- a/app/kubernetes/react/components/index.ts +++ b/app/kubernetes/react/components/index.ts @@ -92,6 +92,7 @@ export const ngModule = angular 'onChange', 'placeholder', 'value', + 'allowSelectAll', ]) ) .component( diff --git a/app/kubernetes/registries/kube-registry-access-view/kube-registry-access-view.html b/app/kubernetes/registries/kube-registry-access-view/kube-registry-access-view.html index 11184ae0f..5c5e68255 100644 --- a/app/kubernetes/registries/kube-registry-access-view/kube-registry-access-view.html +++ b/app/kubernetes/registries/kube-registry-access-view/kube-registry-access-view.html @@ -19,6 +19,7 @@ namespaces="$ctrl.resourcePools" placeholder="'Select one or more namespaces'" on-change="($ctrl.onChangeResourcePools)" + allow-select-all="true" >
diff --git a/app/kubernetes/views/deploy/deploy.html b/app/kubernetes/views/deploy/deploy.html index d57d0caa7..60e7b0144 100644 --- a/app/kubernetes/views/deploy/deploy.html +++ b/app/kubernetes/views/deploy/deploy.html @@ -40,17 +40,15 @@
- +
- + is-disabled="ctrl.formValues.namespace_toggle && ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM || ctrl.state.isNamespaceInputDisabled" + value="ctrl.formValues.Namespace" + on-change="(ctrl.onChangeNamespace)" + options="ctrl.namespaceOptions" + > Namespaces specified in the manifest will be used @@ -186,7 +184,6 @@
-
Selected Helm chart
diff --git a/app/kubernetes/views/deploy/deployController.js b/app/kubernetes/views/deploy/deployController.js index 89f416ac3..b44d3d7bb 100644 --- a/app/kubernetes/views/deploy/deployController.js +++ b/app/kubernetes/views/deploy/deployController.js @@ -101,9 +101,10 @@ class KubernetesDeployController { this.onChangeNamespace = this.onChangeNamespace.bind(this); } - onChangeNamespace() { + onChangeNamespace(namespaceName) { return this.$async(async () => { - const applications = await this.KubernetesApplicationService.get(this.formValues.Namespace); + this.formValues.Namespace = namespaceName; + const applications = await this.KubernetesApplicationService.get(namespaceName); const stacks = _.map(applications, (item) => item.StackName).filter((item) => item !== ''); this.stacks = _.uniq(stacks); }); @@ -371,6 +372,10 @@ class KubernetesDeployController { if (this.namespaces.length > 0) { this.formValues.Namespace = this.namespaces[0].Name; } + this.namespaceOptions = _.map(namespaces, (namespace) => ({ + label: namespace.Name, + value: namespace.Name, + })); } catch (err) { this.Notifications.error('Failure', err, 'Unable to load namespaces data'); } @@ -404,7 +409,8 @@ class KubernetesDeployController { } } - this.onChangeNamespace(); + this.onChangeNamespace(this.formValues.Namespace); + this.state.viewReady = true; this.$window.onbeforeunload = () => { diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts index 4b1c03608..9b2f7325d 100644 --- a/app/portainer/react/components/index.ts +++ b/app/portainer/react/components/index.ts @@ -9,6 +9,7 @@ import { withFormValidation } from '@/react-tools/withFormValidation'; import { GroupAssociationTable } from '@/react/portainer/environments/environment-groups/components/GroupAssociationTable'; import { AssociatedEnvironmentsSelector } from '@/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector'; import { withControlledInput } from '@/react-tools/withControlledInput'; +import { NamespacePortainerSelect } from '@/react/kubernetes/applications/components/NamespaceSelector/NamespaceSelector'; import { EnvironmentVariablesFieldset, @@ -199,11 +200,22 @@ export const ngModule = angular 'onChange', 'options', 'isMulti', + 'filterOption', 'isClearable', 'components', 'isLoading', 'noOptionsMessage', 'aria-label', + 'loadingMessage', + ]) + ) + .component( + 'namespacePortainerSelect', + r2a(NamespacePortainerSelect, [ + 'value', + 'onChange', + 'isDisabled', + 'options', ]) ) .component( diff --git a/app/react/components/ExternalLink.tsx b/app/react/components/ExternalLink.tsx index ef16dcb66..1bd839cad 100644 --- a/app/react/components/ExternalLink.tsx +++ b/app/react/components/ExternalLink.tsx @@ -1,20 +1,20 @@ -import { ExternalLink as ExternalLinkIcon } from 'lucide-react'; +import { ArrowUpRight } from 'lucide-react'; import { PropsWithChildren } from 'react'; import clsx from 'clsx'; import { AutomationTestingProps } from '@/types'; -import { Icon } from '@@/Icon'; - interface Props { to: string; className?: string; + showIcon?: boolean; } export function ExternalLink({ to, className, children, + showIcon = true, 'data-cy': dataCy, }: PropsWithChildren) { return ( @@ -23,10 +23,10 @@ export function ExternalLink({ target="_blank" rel="noreferrer" data-cy={dataCy} - className={clsx('inline-flex items-center gap-1', className)} + className={clsx('inline-flex align-baseline', className)} > - - {children} + {children} + {showIcon && } ); } diff --git a/app/react/components/FallbackImage.tsx b/app/react/components/FallbackImage.tsx index ee6956f24..eaa4f1272 100644 --- a/app/react/components/FallbackImage.tsx +++ b/app/react/components/FallbackImage.tsx @@ -27,5 +27,5 @@ export function FallbackImage({ src, fallbackIcon, alt, className }: Props) { } // fallback icon if there is an error loading the image - return <>{fallbackIcon}; + return
{fallbackIcon}
; } diff --git a/app/react/components/form-components/PortainerSelect.tsx b/app/react/components/form-components/PortainerSelect.tsx index 9ddf234da..6800d0013 100644 --- a/app/react/components/form-components/PortainerSelect.tsx +++ b/app/react/components/form-components/PortainerSelect.tsx @@ -5,15 +5,25 @@ import { } from 'react-select'; import _ from 'lodash'; import { AriaAttributes } from 'react'; +import { FilterOptionOption } from 'react-select/dist/declarations/src/filters'; import { AutomationTestingProps } from '@/types'; -import { Select as ReactSelect } from '@@/form-components/ReactSelect'; +import { + Creatable, + Select as ReactSelect, +} from '@@/form-components/ReactSelect'; export interface Option { value: TValue; label: string; disabled?: boolean; + [key: string]: unknown; +} + +export interface GroupOption { + label: string; + options: Option[]; } type Options = OptionsOrGroups< @@ -21,7 +31,7 @@ type Options = OptionsOrGroups< GroupBase> >; -interface SharedProps +interface SharedProps extends AutomationTestingProps, Pick { name?: string; @@ -32,9 +42,14 @@ interface SharedProps bindToBody?: boolean; isLoading?: boolean; noOptionsMessage?: () => string; + loadingMessage?: () => string; + filterOption?: ( + option: FilterOptionOption>, + rawInput: string + ) => boolean; } -interface MultiProps extends SharedProps { +interface MultiProps extends SharedProps { value: readonly TValue[]; onChange(value: TValue[]): void; options: Options; @@ -44,9 +59,12 @@ interface MultiProps extends SharedProps { true, GroupBase> >; + formatCreateLabel?: (input: string) => string; + onCreateOption?: (input: string) => void; + isCreatable?: boolean; } -interface SingleProps extends SharedProps { +interface SingleProps extends SharedProps { value: TValue; onChange(value: TValue | null): void; options: Options; @@ -58,9 +76,13 @@ interface SingleProps extends SharedProps { >; } -type Props = MultiProps | SingleProps; +export type PortainerSelectProps = + | MultiProps + | SingleProps; -export function PortainerSelect(props: Props) { +export function PortainerSelect( + props: PortainerSelectProps +) { return isMultiProps(props) ? ( // eslint-disable-next-line react/jsx-props-no-spreading @@ -71,7 +93,7 @@ export function PortainerSelect(props: Props) { } function isMultiProps( - props: Props + props: PortainerSelectProps ): props is MultiProps { return 'isMulti' in props && !!props.isMulti; } @@ -87,9 +109,11 @@ export function SingleSelect({ placeholder, isClearable, bindToBody, + filterOption, components, isLoading, noOptionsMessage, + loadingMessage, isMulti, ...aria }: SingleProps) { @@ -116,9 +140,11 @@ export function SingleSelect({ placeholder={placeholder} isDisabled={disabled} menuPortalTarget={bindToBody ? document.body : undefined} + filterOption={filterOption} components={components} isLoading={isLoading} noOptionsMessage={noOptionsMessage} + loadingMessage={loadingMessage} // eslint-disable-next-line react/jsx-props-no-spreading {...aria} /> @@ -159,14 +185,20 @@ export function MultiSelect({ disabled, isClearable, bindToBody, + filterOption, components, isLoading, noOptionsMessage, + loadingMessage, + formatCreateLabel, + onCreateOption, + isCreatable, ...aria }: Omit, 'isMulti'>) { const selectedOptions = findSelectedOptions(options, value); + const SelectComponent = isCreatable ? Creatable : ReactSelect; return ( - ({ placeholder={placeholder} isDisabled={disabled} menuPortalTarget={bindToBody ? document.body : undefined} + filterOption={filterOption} components={components} isLoading={isLoading} noOptionsMessage={noOptionsMessage} + loadingMessage={loadingMessage} + formatCreateLabel={formatCreateLabel} + onCreateOption={onCreateOption} // eslint-disable-next-line react/jsx-props-no-spreading {...aria} /> diff --git a/app/react/kubernetes/applications/components/NamespaceSelector/NamespaceSelector.tsx b/app/react/kubernetes/applications/components/NamespaceSelector/NamespaceSelector.tsx index dd8cffaa2..63341301a 100644 --- a/app/react/kubernetes/applications/components/NamespaceSelector/NamespaceSelector.tsx +++ b/app/react/kubernetes/applications/components/NamespaceSelector/NamespaceSelector.tsx @@ -51,3 +51,29 @@ export function NamespaceSelector({ ); } + +/** NamespacePortainerSelect is exported for use by angular views, so that the data-cy attribute is set correctly */ +export function NamespacePortainerSelect({ + value, + onChange, + isDisabled, + options, +}: { + value: string; + onChange: (value: string) => void; + isDisabled: boolean; + options: { label: string; value: string }[]; +}) { + return ( + 'No namespaces found'} + placeholder="No namespaces found" // will only show when there are no options + inputId="namespace-selector" + data-cy="namespace-select" + /> + ); +} diff --git a/app/react/kubernetes/cluster/RegistryAccessView/NamespacesSelector.tsx b/app/react/kubernetes/cluster/RegistryAccessView/NamespacesSelector.tsx index 4c16ecaac..5c2da7344 100644 --- a/app/react/kubernetes/cluster/RegistryAccessView/NamespacesSelector.tsx +++ b/app/react/kubernetes/cluster/RegistryAccessView/NamespacesSelector.tsx @@ -1,4 +1,5 @@ import _ from 'lodash'; +import { useMemo } from 'react'; import { Select } from '@@/form-components/ReactSelect'; @@ -15,6 +16,7 @@ interface Props { dataCy: string; inputId?: string; placeholder?: string; + allowSelectAll?: boolean; } export function NamespacesSelector({ @@ -25,23 +27,34 @@ export function NamespacesSelector({ dataCy, inputId, placeholder, + allowSelectAll, }: Props) { + const options = useMemo(() => { + if (allowSelectAll) { + return [{ id: 'all', name: 'Select all' }, ...namespaces]; + } + return namespaces; + }, [namespaces, allowSelectAll]); return (