Compare commits

...

4 Commits

45 changed files with 1240 additions and 61 deletions
+10 -2
View File
@@ -87,8 +87,16 @@ func backupDatabaseAndFilesystem(gate *offlinegate.OfflineGate, datastore datase
func backupDb(backupDirPath string, datastore dataservices.DataStore) error {
dbFileName := datastore.Connection().GetDatabaseFileName()
_, err := datastore.Backup(filesystem.JoinPaths(backupDirPath, dbFileName))
return err
f, err := os.Create(filesystem.JoinPaths(backupDirPath, dbFileName))
if err != nil {
return fmt.Errorf("failed to create database backup file: %w", err)
}
defer logs.CloseAndLogErr(f)
return filesystem.RunWithTimeout(dbFileName, filesystem.BackupTimeout, func() error {
return datastore.BackupTo(f)
})
}
func encrypt(path string, passphrase string) (string, error) {
+25
View File
@@ -7,17 +7,30 @@ import (
"os"
"path/filepath"
"testing"
"testing/synctest"
"github.com/portainer/portainer/api/archive"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/offlinegate"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/pkg/fips"
"github.com/stretchr/testify/require"
)
type hangingDataStore struct {
dataservices.DataStore
block chan struct{}
}
func (d *hangingDataStore) BackupTo(w io.Writer) error {
<-d.block
return nil
}
func init() {
fips.InitFIPS(false)
}
@@ -128,6 +141,18 @@ func TestEncryptDecrypt_WrongPassword(t *testing.T) {
require.Error(t, err)
}
func Test_backupDb_timesOutWhenBackupToHangs(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
ds := &hangingDataStore{DataStore: testhelpers.NewDatastore(), block: make(chan struct{})}
err := backupDb(t.TempDir(), ds)
require.Error(t, err)
close(ds.block)
})
}
func TestCreateBackupArchive_NoPassword(t *testing.T) {
t.Parallel()
+3
View File
@@ -1,6 +1,8 @@
package dataservices
import (
"io"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/models"
)
@@ -49,6 +51,7 @@ type (
Rollback(force bool) error
CheckCurrentEdition() error
Backup(path string) (string, error)
BackupTo(w io.Writer) error
Export(filename string) (err error)
DataStoreTx
+2 -2
View File
@@ -10,7 +10,7 @@ import (
"github.com/docker/docker/client"
)
var windowsAbsolutePathPrefix = regexp.MustCompile(`^[A-Za-z]:[\\/]`)
var windowsAbsolutePathPrefix = regexp.MustCompile(`^([A-Za-z]:[\\/]|\\\\)`)
type MountDescriptor struct {
Type string
@@ -19,7 +19,7 @@ type MountDescriptor struct {
}
func IsBindMount(m MountDescriptor) bool {
if strings.EqualFold(m.Type, "bind") {
if strings.EqualFold(m.Type, "bind") || strings.EqualFold(m.Type, "npipe") {
return true
}
+10
View File
@@ -57,6 +57,10 @@ func TestIsBindMount(t *testing.T) {
// tmpfs without a device is not a bind mount
f(MountDescriptor{DriverOpts: map[string]string{"type": "tmpfs"}}, false)
// Windows named-pipe mount is bind-equivalent, case-insensitively
f(MountDescriptor{Type: "npipe"}, true)
f(MountDescriptor{Type: "NPipe"}, true)
}
func TestIsBindPath(t *testing.T) {
@@ -78,6 +82,12 @@ func TestIsBindPath(t *testing.T) {
// named volume, not a host path
f("myvolume:/data", false)
// Windows named-pipe UNC path
f(`\\.\pipe\docker_engine:\\.\pipe\docker_engine`, true)
// Windows network-share UNC path
f(`\\fileserver\share:/data`, true)
}
func newVolumeInspectClient(t *testing.T, handler http.HandlerFunc) *client.Client {
+46 -7
View File
@@ -2,14 +2,37 @@ package filesystem
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/portainer/portainer/api/logs"
"github.com/rs/zerolog/log"
)
const backupTimeoutEnvVar = "PORTAINER_BACKUP_TIMEOUT"
var BackupTimeout = resolveBackupTimeout(time.Hour)
func resolveBackupTimeout(defaultTimeout time.Duration) time.Duration {
val := os.Getenv(backupTimeoutEnvVar)
if val == "" {
return defaultTimeout
}
parsed, err := time.ParseDuration(val)
if err != nil {
log.Warn().Err(err).Str(backupTimeoutEnvVar, val).Msg("failed to parse " + backupTimeoutEnvVar + " variable")
return defaultTimeout
}
return parsed
}
// CopyPath copies file or directory defined by the path to the toDir path
func CopyPath(path string, toDir string) error {
info, err := os.Stat(path)
@@ -20,12 +43,28 @@ func CopyPath(path string, toDir string) error {
return err
}
if !info.IsDir() {
destination := JoinPaths(toDir, info.Name())
return copyFile(path, destination)
}
return RunWithTimeout(path, BackupTimeout, func() error {
if !info.IsDir() {
return copyFile(path, JoinPaths(toDir, info.Name()))
}
return CopyDir(path, toDir, true)
return CopyDir(path, toDir, true)
})
}
func RunWithTimeout(label string, timeout time.Duration, fn func() error) error {
done := make(chan error, 1)
go func() {
done <- fn()
}()
select {
case err := <-done:
return err
case <-time.After(timeout):
log.Error().Str("operation", label).Dur("timeout", timeout).Msg("timed out")
return fmt.Errorf("timed out running %s after %s", label, timeout)
}
}
// CopyDir copies contents of fromDir to toDir.
@@ -53,8 +92,8 @@ func CopyDir(fromDir, toDir string, keepParent bool) error {
return nil // skip directory creations
}
if info.Mode()&os.ModeSymlink != 0 { // entry is a symlink
return nil // don't copy symlinks
if !info.Mode().IsRegular() { // skip symlinks, FIFOs, sockets, devices
return nil
}
return copyFile(path, destination)
+42
View File
@@ -1,8 +1,11 @@
package filesystem
import (
"errors"
"os"
"testing"
"testing/synctest"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -92,6 +95,45 @@ func Test_CopyPath_shouldCopyDir(t *testing.T) {
assert.FileExists(t, JoinPaths(destination, "copy_test", "dir", "inner"))
}
func Test_RunWithTimeout_returnsUnderlyingResult(t *testing.T) {
t.Parallel()
require.NoError(t, RunWithTimeout("op", time.Hour, func() error { return nil }))
boom := errors.New("boom")
require.ErrorIs(t, RunWithTimeout("op", time.Hour, func() error { return boom }), boom)
}
func Test_RunWithTimeout_timesOutWhenFnNeverReturnsInTime(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
release := make(chan struct{})
err := RunWithTimeout("op", time.Hour, func() error {
<-release
return nil
})
require.Error(t, err)
close(release)
})
}
func Test_resolveBackupTimeout_returnsDefaultWhenUnset(t *testing.T) {
t.Setenv(backupTimeoutEnvVar, "")
assert.Equal(t, time.Hour, resolveBackupTimeout(time.Hour))
}
func Test_resolveBackupTimeout_usesParsedValue(t *testing.T) {
t.Setenv(backupTimeoutEnvVar, "30m")
assert.Equal(t, 30*time.Minute, resolveBackupTimeout(time.Hour))
}
func Test_resolveBackupTimeout_returnsDefaultWhenInvalid(t *testing.T) {
t.Setenv(backupTimeoutEnvVar, "not-a-duration")
assert.Equal(t, time.Hour, resolveBackupTimeout(time.Hour))
}
func TestCopyPathPanic(t *testing.T) {
t.Parallel()
dir := t.TempDir()
+30
View File
@@ -0,0 +1,30 @@
//go:build unix
package filesystem
import (
"os"
"syscall"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_CopyDir_shouldSkipNonRegularFiles(t *testing.T) {
t.Parallel()
source := t.TempDir()
destination := t.TempDir()
err := os.WriteFile(JoinPaths(source, "regular"), []byte("content"), 0600)
require.NoError(t, err)
err = syscall.Mkfifo(JoinPaths(source, "fifo"), 0600)
require.NoError(t, err)
err = CopyDir(source, destination, false)
require.NoError(t, err)
assert.FileExists(t, JoinPaths(destination, "regular"))
assert.NoFileExists(t, JoinPaths(destination, "fifo"))
}
@@ -10,6 +10,8 @@ type (
K8sConfiguration
}
// K8sSecret carries base64 encoded Data values, unlike K8sConfigMap whose values
// are plain text: a secret holds arbitrary bytes that a JSON string cannot represent.
K8sSecret struct {
K8sConfiguration
SecretType string `json:"SecretType"`
+47 -20
View File
@@ -34,7 +34,7 @@ type partialServiceSpec struct {
} `json:"TaskTemplate"`
}
func CheckServiceBodyRestrictions(request *http.Request, securitySettings *portainer.EndpointSecuritySettings, getClient func() (*client.Client, error)) error {
func CheckServiceBodyRestrictions(request *http.Request, securitySettings *portainer.EndpointSecuritySettings, getClient func(nodeName string) (*client.Client, error)) error {
defer logs.CloseAndLogErr(request.Body)
body, err := io.ReadAll(request.Body)
@@ -83,21 +83,13 @@ func CheckServiceBodyRestrictions(request *http.Request, securitySettings *porta
}
if len(referencedVolumes) > 0 {
cli, err := getClient()
isBind, err := anyClusterNodeHasBindMountVolume(request.Context(), getClient, referencedVolumes)
if err != nil {
return err
}
defer logs.CloseAndLogErr(cli)
for _, name := range referencedVolumes {
isBind, err := docker.InspectVolumeIsBindMount(request.Context(), cli, name)
if err != nil {
return err
}
if isBind {
return ErrBindMountsForbidden
}
if isBind {
return ErrBindMountsForbidden
}
}
}
@@ -107,6 +99,45 @@ func CheckServiceBodyRestrictions(request *http.Request, securitySettings *porta
return nil
}
func anyClusterNodeHasBindMountVolume(ctx context.Context, getClient func(nodeName string) (*client.Client, error), volumeNames []string) (bool, error) {
cli, err := getClient("")
if err != nil {
return false, err
}
defer logs.CloseAndLogErr(cli)
nodes, err := cli.NodeList(ctx, swarm.NodeListOptions{})
if err != nil {
return false, err
}
nodeClients := make([]*client.Client, 0, len(nodes))
for _, node := range nodes {
nodeClient, err := getClient(node.Description.Hostname)
if err != nil {
return false, err
}
defer logs.CloseAndLogErr(nodeClient)
nodeClients = append(nodeClients, nodeClient)
}
for _, name := range volumeNames {
for _, nodeClient := range nodeClients {
isBind, err := docker.InspectVolumeIsBindMount(ctx, nodeClient, name)
if err != nil {
return false, err
}
if isBind {
return true, nil
}
}
}
return false, nil
}
func getInheritedResourceControlFromServiceLabels(dockerClient *client.Client, endpointID portainer.EndpointID, serviceID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
service, _, err := dockerClient.ServiceInspectWithRaw(context.Background(), serviceID, swarm.ServiceInspectOptions{})
if err != nil {
@@ -193,10 +224,8 @@ func (transport *Transport) decorateServiceCreationOperation(request *http.Reque
return nil, err
}
getClient := func() (*client.Client, error) {
agentTargetHeader := request.Header.Get(portainer.PortainerAgentTargetHeader)
return transport.dockerClientFactory.CreateClient(transport.endpoint, agentTargetHeader, nil)
getClient := func(nodeName string) (*client.Client, error) {
return transport.dockerClientFactory.CreateClient(transport.endpoint, nodeName, nil)
}
if err := CheckServiceBodyRestrictions(request, securitySettings, getClient); err != nil {
@@ -228,10 +257,8 @@ func (transport *Transport) decorateServiceUpdateOperation(request *http.Request
return nil, err
}
getClient := func() (*client.Client, error) {
agentTargetHeader := request.Header.Get(portainer.PortainerAgentTargetHeader)
return transport.dockerClientFactory.CreateClient(transport.endpoint, agentTargetHeader, nil)
getClient := func(nodeName string) (*client.Client, error) {
return transport.dockerClientFactory.CreateClient(transport.endpoint, nodeName, nil)
}
if err := CheckServiceBodyRestrictions(request, securitySettings, getClient); err != nil {
@@ -15,7 +15,9 @@ import (
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/api/types/volume"
"github.com/docker/docker/client"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -47,6 +49,20 @@ func newServiceCreationFixtures(t *testing.T) *serviceCreationFixtures {
return
}
if r.Method == http.MethodGet && path.Base(r.URL.Path) == "nodes" {
data, err := json.Marshal([]swarm.Node{{ID: "single-node", Description: swarm.NodeDescription{Hostname: "single-node"}}})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(data)
return
}
if r.Method == http.MethodGet {
if vol, ok := f.volumes[path.Base(r.URL.Path)]; ok {
w.Header().Set("Content-Type", "application/json")
@@ -727,3 +743,85 @@ func TestDecorateServiceCreationOperation_BindMountRestrictions(t *testing.T) {
"normalvol": {Name: "normalvol", Driver: "local"},
}, false)
}
// TestCheckServiceBodyRestrictions_BindVolumeOnOtherClusterNode ensures a bind-backed volume is
// caught even when it only exists on a cluster node other than the one the default client reaches.
// A local-driver volume is scoped to the node it was created on: checking a single node cannot
// rule out a same-named bind-backed volume on a different node that the Swarm scheduler could
// still place the task on.
func TestCheckServiceBodyRestrictions_BindVolumeOnOtherClusterNode(t *testing.T) {
t.Parallel()
newNodeServer := func(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
return srv
}
nodeAServer := newNodeServer(t, func(w http.ResponseWriter, r *http.Request) {
if path.Base(r.URL.Path) == "nodes" {
data, err := json.Marshal([]swarm.Node{
{ID: "node-a", Description: swarm.NodeDescription{Hostname: "node-a"}},
{ID: "node-b", Description: swarm.NodeDescription{Hostname: "node-b"}},
})
if !assert.NoError(t, err) {
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(data)
return
}
http.NotFound(w, r)
})
nodeBServer := newNodeServer(t, func(w http.ResponseWriter, r *http.Request) {
if path.Base(r.URL.Path) == "evilvol" {
vol := volume.Volume{Name: "evilvol", Driver: "local", Options: map[string]string{"type": "none", "o": "bind", "device": "/etc"}}
w.Header().Set("Content-Type", "application/json")
assert.NoError(t, json.NewEncoder(w).Encode(vol))
return
}
http.NotFound(w, r)
})
newClientFor := func(url string) *client.Client {
cli, err := client.NewClientWithOpts(client.WithHost(url), client.WithHTTPClient(http.DefaultClient))
require.NoError(t, err)
return cli
}
getClient := func(nodeName string) (*client.Client, error) {
if nodeName == "node-b" {
return newClientFor(nodeBServer.URL), nil
}
return newClientFor(nodeAServer.URL), nil
}
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: "evilvol"}},
},
},
}
data, err := json.Marshal(spec)
require.NoError(t, err)
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "http://unused/services/create", bytes.NewReader(data))
require.NoError(t, err)
err = CheckServiceBodyRestrictions(req, &restrictiveSettings, getClient)
require.ErrorIs(t, err, ErrBindMountsForbidden)
}
+2
View File
@@ -1,6 +1,7 @@
package testhelpers
import (
"io"
"time"
portainer "github.com/portainer/portainer/api"
@@ -45,6 +46,7 @@ type testDatastore struct {
}
func (d *testDatastore) Backup(path string) (string, error) { return "", nil }
func (d *testDatastore) BackupTo(w io.Writer) error { return nil }
func (d *testDatastore) Open() (bool, error) { return false, nil }
func (d *testDatastore) Init() error { return nil }
func (d *testDatastore) Close() error { return nil }
+22 -5
View File
@@ -2,8 +2,10 @@ package cli
import (
"context"
"encoding/base64"
"errors"
"fmt"
"maps"
"time"
models "github.com/portainer/portainer/api/http/models/kubernetes"
@@ -150,6 +152,20 @@ func (kcl *KubeClient) DeleteSecret(namespace, name string) error {
return kcl.cli.CoreV1().Secrets(namespace).Delete(context.Background(), name, metav1.DeleteOptions{})
}
// secretAnnotations returns the secret's annotations without the one kubectl writes on
// apply, whose value is the whole object including its data. Leaving it in would hand
// the data to every caller withData is meant to withhold it from.
func secretAnnotations(secret *corev1.Secret) map[string]string {
if _, hasLastApplied := secret.Annotations[lastAppliedConfigAnnotation]; !hasLastApplied {
return secret.Annotations
}
annotations := maps.Clone(secret.Annotations)
delete(annotations, lastAppliedConfigAnnotation)
return annotations
}
// parseSecret parses a k8s Secret object into a K8sSecret struct.
// for get operation, withData will be set to true.
// otherwise, only metadata will be parsed.
@@ -160,7 +176,7 @@ func parseSecret(secret *corev1.Secret, withData bool) models.K8sSecret {
Name: secret.Name,
Namespace: secret.Namespace,
CreationDate: secret.CreationTimestamp.Time.UTC().Format(time.RFC3339),
Annotations: secret.Annotations,
Annotations: secretAnnotations(secret),
Labels: secret.Labels,
ConfigurationOwner: secret.Labels[labelPortainerKubeConfigOwner],
ConfigurationOwnerId: secret.Labels[labelPortainerKubeConfigOwnerId],
@@ -169,10 +185,11 @@ func parseSecret(secret *corev1.Secret, withData bool) models.K8sSecret {
}
if withData {
secretData := secret.Data
secretDataMap := make(map[string]string, len(secretData))
for key, value := range secretData {
secretDataMap[key] = string(value)
secretDataMap := make(map[string]string, len(secret.Data))
for key, value := range secret.Data {
// a secret holds arbitrary bytes and a JSON string must be valid UTF-8, so
// values go over the wire base64 encoded, as the Kubernetes API does
secretDataMap[key] = base64.StdEncoding.EncodeToString(value)
}
result.Data = secretDataMap
+41
View File
@@ -150,3 +150,44 @@ func Test_SetSecretsIsUsed_SAWithEmptyImagePullSecrets(t *testing.T) {
require.NoError(t, err)
assert.False(t, secrets[0].IsUsed)
}
// The kubectl apply annotation holds the whole object, data included, so leaving it in
// a response would defeat the withData check that withholds secret values.
func Test_parseSecret_StripsTheKubectlApplyAnnotation(t *testing.T) {
t.Parallel()
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "applied",
Namespace: "default",
Annotations: map[string]string{
lastAppliedConfigAnnotation: `{"apiVersion":"v1","data":{"password":"c3VwZXJzZWNyZXQ="},"kind":"Secret"}`,
"portainer.io/registry.id": "3",
},
},
Data: map[string][]byte{"password": []byte("supersecret")},
}
for _, withData := range []bool{false, true} {
result := parseSecret(secret, withData)
assert.NotContains(t, result.Annotations, lastAppliedConfigAnnotation)
assert.Equal(t, "3", result.Annotations["portainer.io/registry.id"])
}
assert.Contains(t, secret.Annotations, lastAppliedConfigAnnotation, "the live secret must not be mutated")
}
func Test_parseSecret_KeepsAnnotationsWhenThereIsNothingToStrip(t *testing.T) {
t.Parallel()
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "plain",
Namespace: "default",
Annotations: map[string]string{"portainer.io/registry.id": "3"},
},
}
assert.Equal(t, secret.Annotations, parseSecret(secret, false).Annotations)
}
+2 -2
View File
@@ -49,7 +49,7 @@ func IsValidStackFile(config StackFileValidationConfig) error {
}
if config.DockerClient == nil {
continue
return fmt.Errorf("volume %q: unable to verify bind-mount status without a Docker client", volumeKey)
}
isBind, err := docker.InspectVolumeIsBindMount(context.Background(), config.DockerClient, volumeConfig.Name)
@@ -66,7 +66,7 @@ func IsValidStackFile(config StackFileValidationConfig) error {
for _, service := range composeConfig.Services {
if !config.SecuritySettings.AllowBindMountsForRegularUsers {
for _, volume := range service.Volumes {
if strings.EqualFold(volume.Type, "bind") {
if docker.IsBindMount(docker.MountDescriptor{Type: volume.Type}) {
return errors.New("bind-mount disabled for non administrator users")
}
}
+15 -2
View File
@@ -443,6 +443,19 @@ services:
target: /container/path
`), "", nil, forbidden)
// service-level volume "type: npipe" (Windows named pipe) is bind-equivalent
f([]byte(`
version: "3"
services:
api:
image: nginx
volumes:
- type: npipe
source: \\.\pipe\docker_engine
target: \\.\pipe\docker_engine
`), "", nil, forbidden)
// an external volume that is actually bind-backed is rejected
f([]byte(`
version: "3"
@@ -480,7 +493,7 @@ volumes:
Options: map[string]string{"type": "none", "o": "bind", "device": "/etc"},
}), forbidden)
// with no Docker client, the existing-volume check is skipped and the stack is valid
// with no Docker client, the existing-volume check fails closed rather than being skipped
f([]byte(`
version: "3"
@@ -492,7 +505,7 @@ services:
volumes:
data: {}
`), "mystack", nil, "")
`), "mystack", nil, "unable to verify bind-mount status without a Docker client")
// an explicit "name:" override resolves to the overridden Docker-side name
f([]byte(`
+1 -1
View File
@@ -242,7 +242,7 @@ angular
var group = {
name: 'portainer.groups.group',
url: '/:id',
url: '/:id?tab',
views: {
'content@': {
component: 'environmentGroupEditView',
@@ -19,7 +19,7 @@ export function HeaderContainer({ id, children }: PropsWithChildren<Props>) {
<Context.Provider value>
<div
id={id}
className="row !mb-[5px] min-h-[60px] !rounded-none !border-0 !border-b !border-solid !border-b-[var(--border-widget)] bg-[var(--bg-widget-color)] !shadow-none"
className="row !mb-[15px] min-h-[60px] !rounded-none !border-0 !border-b !border-solid !border-b-[var(--border-widget)] bg-[var(--bg-widget-color)] !shadow-none"
>
<div id="loadingbar-placeholder" />
<div className="col-xs-12">
@@ -157,4 +157,17 @@ describe('AccessDatatable', () => {
expect(screen.getByText(/logout and login/i)).toBeVisible();
});
});
describe('isUpdatingAccess prop', () => {
it('should show "Removing..." text on remove button when isUpdatingAccess is true', () => {
const mockAccess = createMockAccess();
renderComponent({
isLoading: false,
isUpdatingAccess: true,
dataset: [mockAccess],
});
expect(screen.getByText('Removing...')).toBeVisible();
});
});
});
@@ -72,7 +72,11 @@ export function AccessDatatable({
isRowSelectable={({ original: item }) => !inheritFrom || !item.Inherited}
renderTableActions={(selectedItems) => (
<>
<RemoveAccessButton items={selectedItems} onClick={onRemove} />
<RemoveAccessButton
items={selectedItems}
onClick={onRemove}
isLoading={isUpdatingAccess}
/>
{isBE && isUpdateEnabled && (
<LoadingButton
@@ -3,7 +3,7 @@ import { OptionProps, components, MultiValueGenericProps } from 'react-select';
import { Select } from '@@/form-components/ReactSelect';
type Option = { Type: 'user' | 'team'; Id: number; Name: string };
export type Option = { Type: 'user' | 'team'; Id: number; Name: string };
interface Props {
value: Option[];
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
import { Box } from 'lucide-react';
import { Box, UsersRound } from 'lucide-react';
import { useRouter } from '@uirouter/react';
import { notifySuccess } from '@/portainer/services/notifications';
@@ -15,6 +15,7 @@ import { useGroup } from '../queries/useGroup';
import { useDeleteEnvironmentGroupMutation } from '../queries/useDeleteEnvironmentGroupMutation';
import { EnvironmentsTab } from './tabs/EnvironmentsTab';
import { AccessTab } from './tabs/AccessTab';
import { GroupHeader } from './GroupHeader';
export function EditGroupView() {
@@ -60,6 +61,12 @@ export function EditGroupView() {
),
selectedTabParam: 'environments',
},
{
name: 'Access',
icon: UsersRound,
widget: <AccessTab />,
selectedTabParam: 'access',
},
],
[addEnvsDrawerOpen]
);
@@ -0,0 +1,179 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { server } from '@/setup-tests/server';
import { suppressConsoleLogs } from '@/setup-tests/suppress-console';
import { EnvironmentGroup } from '../../types';
import { AccessTab } from './AccessTab';
vi.mock('@/react/hooks/useIdParam', () => ({
useIdParam: () => 2,
}));
vi.mock('@/portainer/services/notifications', () => ({
notifyError: vi.fn(),
notifySuccess: vi.fn(),
}));
const mockGroup: EnvironmentGroup = {
Id: 2,
Name: 'Test Group',
Description: '',
TagIds: [],
};
function setupMocks({
group = mockGroup,
users = [] as Array<{ Id: number; Username: string; Role: number }>,
teams = [] as Array<{ Id: number; Name: string }>,
onUpdate = vi.fn(),
holdUpdate,
}: {
group?: EnvironmentGroup;
users?: Array<{ Id: number; Username: string; Role: number }>;
teams?: Array<{ Id: number; Name: string }>;
onUpdate?: (body: unknown) => void;
holdUpdate?: Promise<void>;
} = {}) {
server.use(
http.get('/api/endpoint_groups/2', () => HttpResponse.json(group)),
http.get('/api/users', () => HttpResponse.json(users)),
http.get('/api/teams', () => HttpResponse.json(teams)),
http.put('/api/endpoint_groups/2', async ({ request }) => {
onUpdate(await request.json());
await holdUpdate;
return HttpResponse.json(group);
})
);
return onUpdate;
}
function renderAccessTab() {
const Wrapped = withTestQueryProvider(
withTestRouter(withUserProvider(AccessTab))
);
return render(<Wrapped />);
}
describe('AccessTab', () => {
test('shows the authorized users and teams, and hides the rest', async () => {
setupMocks({
group: {
...mockGroup,
UserAccessPolicies: { 5: { RoleId: 3 } },
TeamAccessPolicies: { 3: { RoleId: 3 } },
},
users: [
{ Id: 5, Username: 'authorized-user', Role: 2 },
{ Id: 6, Username: 'other-user', Role: 2 },
],
teams: [
{ Id: 3, Name: 'authorized-team' },
{ Id: 4, Name: 'other-team' },
],
});
renderAccessTab();
await waitFor(() => {
expect(screen.getByText('authorized-user')).toBeVisible();
});
expect(screen.getByText('authorized-team')).toBeVisible();
expect(screen.queryByText('other-user')).not.toBeInTheDocument();
expect(screen.queryByText('other-team')).not.toBeInTheDocument();
});
test('assigns a selected user to the group', async () => {
const onUpdate = setupMocks({
group: { ...mockGroup, TeamAccessPolicies: { 3: { RoleId: 3 } } },
users: [{ Id: 6, Username: 'new-user', Role: 2 }],
teams: [{ Id: 3, Name: 'authorized-team' }],
});
renderAccessTab();
const selector = await screen.findByLabelText(
'Select user(s) and/or team(s)'
);
await userEvent.click(selector);
await userEvent.click(await screen.findByText('new-user'));
await userEvent.click(
screen.getByRole('button', { name: /Create access/ })
);
await waitFor(() => {
expect(onUpdate).toHaveBeenCalledWith({
UserAccessPolicies: { 6: { RoleId: 3 } },
TeamAccessPolicies: { 3: { RoleId: 3 } },
});
});
// the selector only clears once the mutation succeeds
await waitFor(() => {
expect(
screen.queryByRole('button', { name: 'Remove new-user' })
).not.toBeInTheDocument();
});
});
test('creating access leaves the remove button idle', async () => {
// never resolves, so the create request stays in flight while we assert
setupMocks({
group: { ...mockGroup, UserAccessPolicies: { 5: { RoleId: 3 } } },
users: [
{ Id: 5, Username: 'authorized-user', Role: 2 },
{ Id: 6, Username: 'new-user', Role: 2 },
],
holdUpdate: new Promise<void>(() => {}),
});
renderAccessTab();
const selector = await screen.findByLabelText(
'Select user(s) and/or team(s)'
);
await userEvent.click(selector);
await userEvent.click(await screen.findByText('new-user'));
await userEvent.click(
screen.getByRole('button', { name: /Create access/ })
);
await screen.findByText('Creating access...');
expect(screen.queryByText('Removing...')).not.toBeInTheDocument();
});
test('keeps the selection when creating access fails', async () => {
const restoreConsole = suppressConsoleLogs();
setupMocks({ users: [{ Id: 6, Username: 'new-user', Role: 2 }] });
server.use(
http.put(
'/api/endpoint_groups/2',
() => new HttpResponse(null, { status: 500 })
)
);
renderAccessTab();
const selector = await screen.findByLabelText(
'Select user(s) and/or team(s)'
);
await userEvent.click(selector);
await userEvent.click(await screen.findByText('new-user'));
await userEvent.click(
screen.getByRole('button', { name: /Create access/ })
);
// once the request settles the selection is still there to retry with
await waitFor(() => {
expect(screen.queryByText('Creating access...')).not.toBeInTheDocument();
});
expect(
screen.getByRole('button', { name: 'Remove new-user' })
).toBeVisible();
restoreConsole();
});
});
@@ -0,0 +1,125 @@
import {
PortainerTeamAccessPolicies,
PortainerUserAccessPolicies,
} from '@api/types.gen';
import { notifySuccess } from '@/portainer/services/notifications';
import { useIdParam } from '@/react/hooks/useIdParam';
import { AccessDatatable } from '@/react/portainer/access-control/AccessManagement/AccessDatatable/AccessDatatable';
import { Access } from '@/react/portainer/access-control/AccessManagement/AccessDatatable/types';
import { Option } from '@/react/portainer/access-control/AccessManagement/PorAccessManagementUsersSelector';
import { useGroup } from '../../queries/useGroup';
import { useGroupAccesses } from '../../queries/useGroupAccesses';
import { useUpdateGroupAccessMutation } from '../../queries/useUpdateGroupAccessMutation';
import { CreateAccessWidget } from './CreateAccessWidget';
export function AccessTab() {
const groupId = useIdParam();
const groupQuery = useGroup(groupId);
const group = groupQuery.data;
const { availableUsersAndTeams, authorizedUsersAndTeams, isLoading } =
useGroupAccesses(group);
const createMutation = useUpdateGroupAccessMutation();
const datatableMutation = useUpdateGroupAccessMutation();
return (
<>
<div className="m-4">
<CreateAccessWidget
availableUsersAndTeams={availableUsersAndTeams as Array<Option>}
isLoading={isLoading || groupQuery.isLoading}
isUpdating={createMutation.isLoading}
onSubmit={handleCreate}
/>
</div>
<AccessDatatable
tableKey="access_group"
dataset={authorizedUsersAndTeams}
onRemove={handleRemove}
onUpdate={handleUpdate}
showWarning
showRoles
isUpdateEnabled
isUpdatingAccess={datatableMutation.isLoading}
isLoading={isLoading || groupQuery.isLoading}
/>
</>
);
function handleCreate(
usersAndTeams: Array<Option>,
roleId: number,
onSuccess: () => void
) {
updatePolicies(
createMutation,
usersAndTeams.map((access) => ({ ...access, Role: { Id: roleId } })),
'set',
'Access successfully updated',
onSuccess
);
}
function handleUpdate(
updatedUsers: Array<Access>,
updatedTeams: Array<Access>
) {
updatePolicies(
datatableMutation,
[...updatedUsers, ...updatedTeams],
'set',
'Access successfully updated'
);
}
function handleRemove(accesses: Array<Access>) {
updatePolicies(
datatableMutation,
accesses,
'delete',
'Access successfully removed'
);
}
function updatePolicies(
mutation: ReturnType<typeof useUpdateGroupAccessMutation>,
accesses: Array<{ Id: number; Type: string; Role?: { Id: number } }>,
action: 'set' | 'delete',
successMessage: string,
onSuccess?: () => void
) {
if (!group) {
return;
}
const userAccessPolicies: PortainerUserAccessPolicies = {
...group.UserAccessPolicies,
};
const teamAccessPolicies: PortainerTeamAccessPolicies = {
...group.TeamAccessPolicies,
};
accesses.forEach((access) => {
const policies =
access.Type === 'user' ? userAccessPolicies : teamAccessPolicies;
if (action === 'delete') {
delete policies[access.Id];
} else {
policies[access.Id] = { RoleId: access.Role?.Id ?? 0 };
}
});
mutation.mutate(
{ id: group.Id, userAccessPolicies, teamAccessPolicies },
{
onSuccess: () => {
notifySuccess('Success', successMessage);
onSuccess?.();
},
}
);
}
}
@@ -0,0 +1,130 @@
import { useState } from 'react';
import { UserPlus, Plus } from 'lucide-react';
import { RoleTypes } from '@/portainer/rbac/models/role';
import { RoleService } from '@/portainer/rbac/services/role.service';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.service';
import {
Option,
PorAccessManagementUsersSelector,
} from '@/react/portainer/access-control/AccessManagement/PorAccessManagementUsersSelector';
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
import { TextTip } from '@@/Tip/TextTip';
import { LoadingButton } from '@@/buttons';
import { FormControl } from '@@/form-components/FormControl';
import { PortainerSelect } from '@@/form-components/PortainerSelect';
import { BEFeatureIndicator } from '@@/BEFeatureIndicator';
interface Props {
availableUsersAndTeams: Array<Option>;
isLoading: boolean;
isUpdating: boolean;
onSubmit(
usersAndTeams: Array<Option>,
roleId: number,
onSuccess: () => void
): void;
}
export function CreateAccessWidget({
availableUsersAndTeams,
isLoading,
isUpdating,
onSubmit,
}: Props) {
const rolesLimitedToBE = isLimitedToBE(FeatureId.RBAC_ROLES);
const [selectedUsersAndTeams, setSelectedUsersAndTeams] = useState<
Array<Option>
>([]);
const [selectedRoleId, setSelectedRoleId] = useState<number>(
RoleTypes.STANDARD
);
const roleOptions = RoleService()
.roles()
.map((role) => ({
label: getRoleLabel(role.ID, role.Name),
value: role.ID as number,
disabled: isRoleLimited(role.ID),
}));
return (
<Widget aria-label="Create access">
<WidgetTitle icon={UserPlus} title="Create access" />
<WidgetBody>
<TextTip className="mb-4" childrenWrapperClassName="text-warning">
Adding user access will require the affected user(s) to logout and
login for the changes to be taken into account.
</TextTip>
<form className="form-horizontal" onSubmit={handleSubmit}>
<PorAccessManagementUsersSelector
options={availableUsersAndTeams}
value={selectedUsersAndTeams}
onChange={(value) => setSelectedUsersAndTeams([...value])}
isLoading={isLoading}
/>
<FormControl label="Role" inputId="role-selector">
<div className="flex items-center gap-2">
<div className="flex-1">
<PortainerSelect
inputId="role-selector"
value={selectedRoleId}
onChange={(roleId) =>
setSelectedRoleId(roleId ?? RoleTypes.STANDARD)
}
options={roleOptions}
data-cy="access-management-role-select"
/>
</div>
<BEFeatureIndicator
featureId={FeatureId.RBAC_ROLES}
className="shrink-0"
/>
</div>
</FormControl>
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
type="submit"
className="!ml-0"
disabled={selectedUsersAndTeams.length === 0}
isLoading={isUpdating}
loadingText="Creating access..."
icon={Plus}
data-cy="access-createAccess"
>
Create access
</LoadingButton>
</div>
</div>
</form>
</WidgetBody>
</Widget>
);
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
onSubmit(selectedUsersAndTeams, selectedRoleId, () =>
setSelectedUsersAndTeams([])
);
}
function isRoleLimited(roleId: number) {
return rolesLimitedToBE && roleId !== RoleTypes.STANDARD;
}
function getRoleLabel(roleId: number, roleName: string) {
if (!rolesLimitedToBE) {
return roleName;
}
return isRoleLimited(roleId)
? `${roleName} (Business Feature)`
: `${roleName} (Default)`;
}
}
@@ -0,0 +1,48 @@
import _ from 'lodash';
import { useMemo } from 'react';
import {
TeamAccessViewModel,
UserAccessViewModel,
} from '@/portainer/models/access';
import { useUsers } from '@/portainer/users/queries';
import { useTeams } from '@/react/portainer/users/teams/queries/useTeams';
import { Access } from '@/react/portainer/access-control/AccessManagement/AccessDatatable/types';
import { EnvironmentGroup } from '../types';
/** Splits the users and teams between those already authorized on the group and those still available to be added. */
export function useGroupAccesses(group?: EnvironmentGroup) {
const usersQuery = useUsers(false, 0, !!group, (users) =>
users.map((user) => new UserAccessViewModel(user))
);
const teamsQuery = useTeams(false, 0, {
enabled: !!group,
select: (teams) => teams.map((team) => new TeamAccessViewModel(team)),
});
const userPolicies = group?.UserAccessPolicies;
const teamPolicies = group?.TeamAccessPolicies;
const users = usersQuery.data;
const teams = teamsQuery.data;
const accesses = useMemo(() => {
const [authorized, available] = _.partition(
[...(users || []), ...(teams || [])] as Array<Access>,
(access) =>
access.Type === 'user'
? !!userPolicies?.[access.Id]
: !!teamPolicies?.[access.Id]
);
return {
authorizedUsersAndTeams: authorized,
availableUsersAndTeams: _.orderBy(available, 'Name', 'asc'),
};
}, [users, teams, userPolicies, teamPolicies]);
return {
...accesses,
isLoading: usersQuery.isLoading || teamsQuery.isLoading,
};
}
@@ -0,0 +1,43 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
PortainerTeamAccessPolicies,
PortainerUserAccessPolicies,
} from '@api/types.gen';
import { endpointGroupUpdate } from '@api/sdk.gen';
import { withError } from '@/react-tools/react-query';
import { EnvironmentGroupId } from '../../types';
import { queryKeys } from './query-keys';
interface UpdateGroupAccessPayload {
id: EnvironmentGroupId;
userAccessPolicies: PortainerUserAccessPolicies;
teamAccessPolicies: PortainerTeamAccessPolicies;
}
async function updateGroupAccess({
id,
userAccessPolicies,
teamAccessPolicies,
}: UpdateGroupAccessPayload) {
await endpointGroupUpdate({
path: { id },
body: {
UserAccessPolicies: userAccessPolicies,
TeamAccessPolicies: teamAccessPolicies,
},
});
}
export function useUpdateGroupAccessMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateGroupAccess,
onSuccess: () => queryClient.invalidateQueries(queryKeys.base()),
...withError('Unable to update group access'),
});
}
+1
View File
@@ -7,6 +7,7 @@ type GetOptions struct {
ShowResources bool
Revision int
KubernetesClusterAccess *KubernetesClusterAccess
ReleaseStorage ReleaseStorage
Env []string
}
+1
View File
@@ -4,6 +4,7 @@ type HistoryOptions struct {
Name string
Namespace string
KubernetesClusterAccess *KubernetesClusterAccess
ReleaseStorage ReleaseStorage
Env []string
}
+1
View File
@@ -6,6 +6,7 @@ type ListOptions struct {
Selector string
Namespace string
KubernetesClusterAccess *KubernetesClusterAccess
ReleaseStorage ReleaseStorage
Env []string
}
+17
View File
@@ -0,0 +1,17 @@
package options
import corev1 "k8s.io/client-go/kubernetes/typed/core/v1"
// ReleaseStorage supplies the Kubernetes Secret client backing the Helm release
// storage driver. It is offered only on the read options, because release records
// are Portainer's own bookkeeping rather than a resource the caller asked for: a
// caller with no Kubernetes access to Secrets can still be told which releases
// exist. Everything else the action touches keeps using KubernetesClusterAccess,
// so live cluster reads and every write still obey Kubernetes RBAC.
//
// Nil, the default, means "use KubernetesClusterAccess" and is what Community
// Edition passes. Whoever supplies a client takes on scoping the results and
// withholding release content, since the storage driver enforces neither.
//
// A kubernetes.Interface satisfies this through its CoreV1() method.
type ReleaseStorage = corev1.SecretsGetter
+23
View File
@@ -54,6 +54,29 @@ type Release struct {
Values Values `json:"values,omitzero"`
}
// RedactSensitive removes everything about a release that is rendered from the chart
// and can therefore carry Kubernetes Secret data: the manifest, the hooks, both value
// sets and the rendered notes. A rendered chart routinely contains Secret objects with
// real values, and values files routinely contain credentials.
//
// Info.Resources survives. It is reduced to each object's metadata, kind and status
// before it reaches here, so it identifies what the release owns without disclosing
// anything the resource APIs would not.
func (r *Release) RedactSensitive() {
if r == nil {
return
}
r.Manifest = ""
r.Hooks = nil
r.Config = nil
r.Values = Values{}
if r.Info != nil {
r.Info.Notes = ""
}
}
type Values struct {
UserSuppliedValues string `json:"userSuppliedValues,omitempty"`
ComputedValues string `json:"computedValues,omitempty"`
+125
View File
@@ -0,0 +1,125 @@
package release
import (
"reflect"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// clearedByRedaction and keptByRedaction together must name every field of Release.
// A new field is a deliberate choice between the two, and TestRedactSensitive_CoversEveryField
// fails until that choice is made.
var (
clearedByRedaction = []string{"Manifest", "Hooks", "Config", "Values"}
keptByRedaction = []string{"Name", "Info", "Chart", "AppVersion", "Version", "Namespace", "Labels", "ChartReference", "StackID"}
)
func populatedRelease() *Release {
return &Release{
Name: "demo",
Namespace: "portainer",
Version: 3,
AppVersion: "1.2.3",
Manifest: "apiVersion: v1\nkind: Secret\ndata:\n password: c3VwZXJzZWNyZXQ=\n",
Config: map[string]any{"password": "supersecret"},
Hooks: []*Hook{{Name: "pre-install", Manifest: "kind: Secret"}},
Values: Values{
UserSuppliedValues: "password: supersecret",
ComputedValues: "password: supersecret",
},
Labels: map[string]string{"owner": "helm"},
ChartReference: ChartReference{RepoURL: "https://charts.example.com"},
StackID: 7,
Chart: Chart{Values: map[string]any{"password": "default"}},
Info: &Info{
Status: "deployed",
Description: "Install complete",
FirstDeployed: time.Now(),
LastDeployed: time.Now(),
Notes: "Your password is supersecret",
Resources: []*unstructured.Unstructured{{Object: map[string]any{"kind": "Deployment"}}},
},
}
}
func TestRedactSensitive(t *testing.T) {
t.Parallel()
r := populatedRelease()
r.RedactSensitive()
t.Run("clears everything that can carry secret data", func(t *testing.T) {
assert.Empty(t, r.Manifest, "the rendered manifest can contain Secret objects")
assert.Empty(t, r.Hooks, "hook manifests can contain Secret objects")
assert.Empty(t, r.Config, "extra values can contain credentials")
assert.Equal(t, Values{}, r.Values, "user-supplied and computed values can contain credentials")
assert.Empty(t, r.Info.Notes, "notes are templated and can interpolate values")
})
t.Run("keeps the resource list, which carries no secret data", func(t *testing.T) {
assert.Len(t, r.Info.Resources, 1, "resources are reduced to metadata, kind and status before they reach redaction")
})
t.Run("keeps the metadata needed to identify the release", func(t *testing.T) {
assert.Equal(t, "demo", r.Name)
assert.Equal(t, "portainer", r.Namespace)
assert.Equal(t, 3, r.Version)
assert.Equal(t, "1.2.3", r.AppVersion)
assert.Equal(t, 7, r.StackID)
assert.Equal(t, "https://charts.example.com", r.ChartReference.RepoURL)
assert.EqualValues(t, "deployed", r.Info.Status)
assert.Equal(t, "Install complete", r.Info.Description)
})
}
func TestRedactSensitive_NilInfoAndNilReceiver(t *testing.T) {
t.Parallel()
assert.NotPanics(t, func() { (*Release)(nil).RedactSensitive() })
r := &Release{Name: "demo"}
assert.NotPanics(t, func() { r.RedactSensitive() })
}
// TestRedactSensitive_CoversEveryField fails when a field is added to Release without
// deciding whether redaction should clear it. Without this, a new field carrying secret
// data would ship through the redacted response unnoticed.
func TestRedactSensitive_CoversEveryField(t *testing.T) {
t.Parallel()
classified := make(map[string]bool, len(clearedByRedaction)+len(keptByRedaction))
for _, name := range append(append([]string{}, clearedByRedaction...), keptByRedaction...) {
classified[name] = true
}
releaseType := reflect.TypeFor[Release]()
for field := range releaseType.Fields() {
assert.True(t, classified[field.Name], "Release.%s is neither cleared nor kept by RedactSensitive; add it to clearedByRedaction or keptByRedaction", field.Name)
}
assert.Len(t, classified, releaseType.NumField(), "clearedByRedaction/keptByRedaction name a field that no longer exists on Release")
}
// TestRedactSensitive_ClearsEveryFieldItClaimsTo guards the other direction: a field listed
// as cleared must actually come back zero.
func TestRedactSensitive_ClearsEveryFieldItClaimsTo(t *testing.T) {
t.Parallel()
r := populatedRelease()
before := reflect.ValueOf(*r)
for _, name := range clearedByRedaction {
require.False(t, before.FieldByName(name).IsZero(), "the fixture must populate %s for this test to mean anything", name)
}
r.RedactSensitive()
after := reflect.ValueOf(*r)
for _, name := range clearedByRedaction {
assert.True(t, after.FieldByName(name).IsZero(), "RedactSensitive left Release.%s populated", name)
}
}
+33 -3
View File
@@ -7,6 +7,8 @@ import (
slogzerolog "github.com/samber/slog-zerolog/v2"
"helm.sh/helm/v4/pkg/action"
"helm.sh/helm/v4/pkg/cli"
"helm.sh/helm/v4/pkg/storage"
"helm.sh/helm/v4/pkg/storage/driver"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/client-go/discovery"
"k8s.io/client-go/discovery/cached/memory"
@@ -38,7 +40,11 @@ func namespaceOrDefault(namespace string) string {
// storage cluster-wide, which is how `helm list --all-namespaces` sees
// releases in every namespace (action.List.AllNamespaces alone does not widen
// the storage scope).
func (hspm *HelmSDKPackageManager) initActionConfig(actionConfig *action.Configuration, namespace string, k8sAccess *options.KubernetesClusterAccess) error {
//
// releaseStorage, when non-nil, backs the release storage driver instead of the
// client derived from k8sAccess. Only the read paths offer it; see
// options.ReleaseStorage.
func (hspm *HelmSDKPackageManager) initActionConfig(actionConfig *action.Configuration, namespace string, k8sAccess *options.KubernetesClusterAccess, releaseStorage options.ReleaseStorage) error {
// Setup logging for Helm SDK using zerolog
logger := log.With().Str("context", "HelmClient").Logger()
logOptions := slogzerolog.Option{
@@ -50,7 +56,11 @@ func (hspm *HelmSDKPackageManager) initActionConfig(actionConfig *action.Configu
// Use default kubeconfig
settings := cli.New()
clientGetter := settings.RESTClientGetter()
return actionConfig.Init(clientGetter, namespace, "secret")
if err := actionConfig.Init(clientGetter, namespace, "secret"); err != nil {
return err
}
return useReleaseStorage(actionConfig, namespace, releaseStorage)
}
// Create client config
@@ -70,7 +80,27 @@ func (hspm *HelmSDKPackageManager) initActionConfig(actionConfig *action.Configu
return err
}
return actionConfig.Init(clientGetter, namespace, "secret")
if err := actionConfig.Init(clientGetter, namespace, "secret"); err != nil {
return err
}
return useReleaseStorage(actionConfig, namespace, releaseStorage)
}
// useReleaseStorage repoints the release storage driver at the supplied client,
// leaving actionConfig.KubeClient on the caller's own credentials so live cluster
// access still goes through Kubernetes RBAC. A nil client leaves the storage as
// actionConfig.Init built it.
func useReleaseStorage(actionConfig *action.Configuration, namespace string, releaseStorage options.ReleaseStorage) error {
if releaseStorage == nil {
return nil
}
secretsDriver := driver.NewSecrets(releaseStorage.Secrets(namespace))
secretsDriver.SetLogger(actionConfig.Logger().Handler())
actionConfig.Releases = storage.Init(secretsDriver)
return nil
}
// generateConfigAPI generates a new kubeconfig configuration
+63 -3
View File
@@ -1,6 +1,7 @@
package sdk
import (
"fmt"
"testing"
"github.com/portainer/portainer/pkg/libhelm/options"
@@ -8,6 +9,11 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"helm.sh/helm/v4/pkg/action"
sdkrelease "helm.sh/helm/v4/pkg/release"
releasecommon "helm.sh/helm/v4/pkg/release/common"
releasev1 "helm.sh/helm/v4/pkg/release/v1"
"helm.sh/helm/v4/pkg/storage/driver"
"k8s.io/client-go/kubernetes/fake"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/tools/clientcmd/api"
)
@@ -18,7 +24,7 @@ func Test_InitActionConfig(t *testing.T) {
t.Run("with nil k8sAccess should use default kubeconfig", func(t *testing.T) {
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, "default", nil)
err := hspm.initActionConfig(actionConfig, "default", nil, nil)
// The function should not fail by design, even when not running in a k8s environment
require.NoError(t, err, "should not return error when not in k8s environment")
@@ -32,7 +38,7 @@ func Test_InitActionConfig(t *testing.T) {
}
// The function should not fail by design
err := hspm.initActionConfig(actionConfig, "default", k8sAccess)
err := hspm.initActionConfig(actionConfig, "default", k8sAccess, nil)
require.NoError(t, err, "should not return error when using in-memory config")
})
@@ -45,9 +51,63 @@ func Test_InitActionConfig(t *testing.T) {
}
// The function should not fail by design
err := hspm.initActionConfig(actionConfig, "default", k8sAccess)
err := hspm.initActionConfig(actionConfig, "default", k8sAccess, nil)
require.NoError(t, err, "should not return error when using in-memory config with CA")
})
t.Run("with release storage the driver reads through the supplied client", func(t *testing.T) {
storageClient := fake.NewClientset()
seedRelease(t, storageClient, "portainer", "demo")
actionConfig := new(action.Configuration)
k8sAccess := &options.KubernetesClusterAccess{
ClusterServerURL: "https://kubernetes.default.svc",
AuthToken: "test-token",
}
require.NoError(t, hspm.initActionConfig(actionConfig, "portainer", k8sAccess, storageClient.CoreV1()))
releases, err := actionConfig.Releases.List(func(sdkrelease.Releaser) bool { return true })
require.NoError(t, err, "the storage driver should read through the supplied client")
require.Len(t, releases, 1)
found, err := releaserToV1Release(releases[0])
require.NoError(t, err)
assert.Equal(t, "demo", found.Name)
})
t.Run("without release storage the driver cannot see the supplied client's releases", func(t *testing.T) {
storageClient := fake.NewClientset()
seedRelease(t, storageClient, "portainer", "demo")
actionConfig := new(action.Configuration)
k8sAccess := &options.KubernetesClusterAccess{
ClusterServerURL: "https://kubernetes.default.svc",
AuthToken: "test-token",
}
require.NoError(t, hspm.initActionConfig(actionConfig, "portainer", k8sAccess, nil))
// The caller's credentials point at an unreachable cluster, so this either errors
// or comes back empty. What matters is that the seeded release is not visible.
releases, err := actionConfig.Releases.List(func(sdkrelease.Releaser) bool { return true })
if err == nil {
assert.Empty(t, releases)
}
})
}
// seedRelease writes a Helm release record through the secret driver itself, so the
// stored encoding is whatever the driver expects to read back.
func seedRelease(t *testing.T, client *fake.Clientset, namespace, name string) {
t.Helper()
seeded := driver.NewSecrets(client.CoreV1().Secrets(namespace))
err := seeded.Create(fmt.Sprintf("sh.helm.release.v1.%s.v1", name), &releasev1.Release{
Name: name,
Namespace: namespace,
Version: 1,
Info: &releasev1.Info{Status: releasecommon.StatusDeployed},
})
require.NoError(t, err)
}
func Test_ClientConfigGetter(t *testing.T) {
+1 -1
View File
@@ -20,7 +20,7 @@ func (hspm *HelmSDKPackageManager) Get(getOptions options.GetOptions) (*release.
Msg("Get Helm release")
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(getOptions.Namespace), getOptions.KubernetesClusterAccess)
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(getOptions.Namespace), getOptions.KubernetesClusterAccess, getOptions.ReleaseStorage)
if err != nil {
log.Error().
+1 -1
View File
@@ -20,7 +20,7 @@ func (hspm *HelmSDKPackageManager) GetHistory(historyOptions options.HistoryOpti
Msg("Get Helm history")
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(historyOptions.Namespace), historyOptions.KubernetesClusterAccess)
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(historyOptions.Namespace), historyOptions.KubernetesClusterAccess, historyOptions.ReleaseStorage)
if err != nil {
log.Error().
+1 -1
View File
@@ -37,7 +37,7 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) (
// Initialize action configuration with kubernetes config
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(installOpts.Namespace), installOpts.KubernetesClusterAccess)
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(installOpts.Namespace), installOpts.KubernetesClusterAccess, nil)
if err != nil {
// error is already logged in initActionConfig
return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release installation")
+1 -1
View File
@@ -28,7 +28,7 @@ func (hspm *HelmSDKPackageManager) List(listOpts options.ListOptions) ([]release
// cluster-wide so the list covers every namespace, mirroring how the Helm
// CLI implements `helm list --all-namespaces`.
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, listOpts.Namespace, listOpts.KubernetesClusterAccess)
err := hspm.initActionConfig(actionConfig, listOpts.Namespace, listOpts.KubernetesClusterAccess, listOpts.ReleaseStorage)
if err != nil {
// error is already logged in initActionConfig
return nil, errors.Wrap(err, "failed to initialize helm configuration")
+1 -1
View File
@@ -14,7 +14,7 @@ import (
func (hspm *HelmSDKPackageManager) doesReleaseExist(releaseName, namespace string, clusterAccess *options.KubernetesClusterAccess) (bool, error) {
// Initialize action configuration
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(namespace), clusterAccess)
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(namespace), clusterAccess, nil)
if err != nil {
// error is already logged in initActionConfig
return false, fmt.Errorf("failed to initialize helm configuration: %w", err)
+1 -1
View File
@@ -30,7 +30,7 @@ func (hspm *HelmSDKPackageManager) Rollback(rollbackOpts options.RollbackOptions
// Initialize action configuration with kubernetes config
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(rollbackOpts.Namespace), rollbackOpts.KubernetesClusterAccess)
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(rollbackOpts.Namespace), rollbackOpts.KubernetesClusterAccess, nil)
if err != nil {
return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release rollback")
}
+2 -2
View File
@@ -28,7 +28,7 @@ func (hspm *HelmSDKPackageManager) Uninstall(uninstallOpts options.UninstallOpti
// Initialize action configuration with kubernetes config
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(uninstallOpts.Namespace), uninstallOpts.KubernetesClusterAccess)
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(uninstallOpts.Namespace), uninstallOpts.KubernetesClusterAccess, nil)
if err != nil {
// error is already logged in initActionConfig
return errors.Wrap(err, "failed to initialize helm configuration")
@@ -103,7 +103,7 @@ func (hspm *HelmSDKPackageManager) ForceRemoveRelease(uninstallOpts options.Unin
Msg("Force-removing release history (skipping resource deletion)")
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(uninstallOpts.Namespace), uninstallOpts.KubernetesClusterAccess)
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(uninstallOpts.Namespace), uninstallOpts.KubernetesClusterAccess, nil)
if err != nil {
return errors.Wrap(err, "failed to initialize helm configuration for force-remove")
}
+1 -1
View File
@@ -61,7 +61,7 @@ func (hspm *HelmSDKPackageManager) Upgrade(upgradeOpts options.InstallOptions) (
// Initialize action configuration with kubernetes config
actionConfig := new(action.Configuration)
err = hspm.initActionConfig(actionConfig, namespaceOrDefault(upgradeOpts.Namespace), upgradeOpts.KubernetesClusterAccess)
err = hspm.initActionConfig(actionConfig, namespaceOrDefault(upgradeOpts.Namespace), upgradeOpts.KubernetesClusterAccess, nil)
if err != nil {
// error is already logged in initActionConfig
return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release upgrade")
+1 -1
View File
@@ -84,7 +84,7 @@ func (hspm *HelmSDKPackageManager) getValues(getOpts options.GetOptions) (releas
Msg("Getting values")
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(getOpts.Namespace), getOpts.KubernetesClusterAccess)
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(getOpts.Namespace), getOpts.KubernetesClusterAccess, getOpts.ReleaseStorage)
if err != nil {
log.Error().
Str("context", "HelmClient").
+15
View File
@@ -50,10 +50,25 @@ func newMockReleaseElement(installOpts options.InstallOptions) *release.ReleaseE
}
}
// MockReleaseSecretValue appears in every content field of a mock release, so a test can
// assert that redaction left none of it in a response.
const MockReleaseSecretValue = "supersecret"
func newMockRelease(re *release.ReleaseElement) *release.Release {
return &release.Release{
Name: re.Name,
Namespace: re.Namespace,
Manifest: "apiVersion: v1\nkind: Secret\ndata:\n password: " + MockReleaseSecretValue + "\n",
Config: map[string]any{"password": MockReleaseSecretValue},
Hooks: []*release.Hook{{Name: "pre-install", Manifest: "password: " + MockReleaseSecretValue}},
Values: release.Values{
UserSuppliedValues: "password: " + MockReleaseSecretValue,
ComputedValues: "password: " + MockReleaseSecretValue,
},
Info: &release.Info{
Status: release.Status(re.Status),
Notes: "Your password is " + MockReleaseSecretValue,
},
}
}