diff --git a/pkg/cli/environment.go b/pkg/cli/environment.go index b0463d01f..037699495 100644 --- a/pkg/cli/environment.go +++ b/pkg/cli/environment.go @@ -24,10 +24,12 @@ These dependencies are expressed as interfaces so that alternate implementations package cli import ( + "log/slog" "net/http" "os" "strconv" "strings" + "sync" "github.com/spf13/pflag" "k8s.io/cli-runtime/pkg/genericclioptions" @@ -118,6 +120,16 @@ func New() *EnvSettings { } env.Debug, _ = strconv.ParseBool(os.Getenv("HELM_DEBUG")) + // One session ID per settings instance: a CLI invocation creates a single + // EnvSettings, so every Kubernetes client built while running one command + // shares the same session and all of its API requests can be correlated + // in the Kubernetes audit log. SDK clients performing distinct operations + // with separate settings get distinct sessions. Logged once at debug + // level so a user can quote it to their cluster admin, who will see the + // same value in the helm-session request header. + sessionID := kubeenv.NewSessionID() + var logSessionOnce sync.Once + // bind to kubernetes config flags config := &genericclioptions.ConfigFlags{ Namespace: &env.namespace, @@ -133,8 +145,16 @@ func New() *EnvSettings { WrapConfigFn: func(config *rest.Config) *rest.Config { config.Burst = env.BurstLimit config.QPS = env.QPS + logSessionOnce.Do(func() { + slog.Debug("created Kubernetes client session", "helm-session", sessionID) + }) config.Wrap(func(rt http.RoundTripper) http.RoundTripper { - return &kubeenv.RetryingRoundTripper{Wrapped: rt} + return &kubeenv.SessionRoundTripper{ + // Session wrapper stays outside the retry wrapper so + // retries keep the same session ID. + Wrapped: &kubeenv.RetryingRoundTripper{Wrapped: rt}, + SessionID: sessionID, + } }) config.UserAgent = version.GetUserAgent() return config diff --git a/pkg/cli/environment_test.go b/pkg/cli/environment_test.go index fc71038f8..408649812 100644 --- a/pkg/cli/environment_test.go +++ b/pkg/cli/environment_test.go @@ -17,6 +17,7 @@ limitations under the License. package cli import ( + "net/http" "os" "path/filepath" "strings" @@ -25,8 +26,10 @@ import ( "github.com/spf13/pflag" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "k8s.io/client-go/rest" "helm.sh/helm/v4/internal/version" + "helm.sh/helm/v4/pkg/kubeenv" ) func TestSetNamespace(t *testing.T) { @@ -223,6 +226,69 @@ func TestUserAgentHeaderInK8sRESTClientConfig(t *testing.T) { cleanup := resetEnv() t.Cleanup(cleanup) + t.Setenv("KUBECONFIG", writeTestKubeconfig(t)) + + settings := New() + restConfig, err := settings.RESTClientGetter().ToRESTConfig() + require.NoError(t, err) + + expectedUserAgent := version.GetUserAgent() + assert.Equal(t, expectedUserAgent, restConfig.UserAgent) +} + +func TestSessionIDStableAcrossClientBuilds(t *testing.T) { + cleanup := resetEnv() + t.Cleanup(cleanup) + + t.Setenv("KUBECONFIG", writeTestKubeconfig(t)) + + settings := New() + + // A single command (e.g. helm upgrade) builds several Kubernetes clients; + // every one of them must carry the same session ID. + var ids []string + for i := 0; i < 5; i++ { + restConfig, err := settings.RESTClientGetter().ToRESTConfig() + require.NoError(t, err) + ids = append(ids, sessionIDFromRESTConfig(t, restConfig)) + } + require.NotEmpty(t, ids[0], "session ID must be set on the request") + for _, id := range ids[1:] { + assert.Equal(t, ids[0], id, "all clients built from one settings must share a session") + } + + // A separate settings instance (another command invocation, or an SDK + // client performing a distinct operation) gets a distinct session. + other := New() + otherConfig, err := other.RESTClientGetter().ToRESTConfig() + require.NoError(t, err) + assert.NotEqual(t, ids[0], sessionIDFromRESTConfig(t, otherConfig), + "separate settings must not share a session") +} + +// sessionIDFromRESTConfig applies the config's transport wrappers to a +// capturing RoundTripper and returns the helm-session header that would be +// sent on a real request. +func sessionIDFromRESTConfig(t *testing.T, restConfig *rest.Config) string { + t.Helper() + var got http.Header + capture := roundTripperFunc(func(req *http.Request) (*http.Response, error) { + got = req.Header + return &http.Response{StatusCode: http.StatusOK}, nil + }) + req, err := http.NewRequest(http.MethodGet, "https://127.0.0.1:6443/version", nil) + require.NoError(t, err) + _, err = restConfig.WrapTransport(capture).RoundTrip(req) + require.NoError(t, err) + return got.Get(kubeenv.SessionHeader) +} + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +func writeTestKubeconfig(t *testing.T) string { + t.Helper() kubeconfigPath := filepath.Join(t.TempDir(), "config") kubeconfig := `apiVersion: v1 clusters: @@ -243,14 +309,7 @@ users: token: test-token ` require.NoError(t, os.WriteFile(kubeconfigPath, []byte(kubeconfig), 0o600), "failed to create test kubeconfig") - t.Setenv("KUBECONFIG", kubeconfigPath) - - settings := New() - restConfig, err := settings.RESTClientGetter().ToRESTConfig() - require.NoError(t, err) - - expectedUserAgent := version.GetUserAgent() - assert.Equal(t, expectedUserAgent, restConfig.UserAgent) + return kubeconfigPath } func resetEnv() func() { diff --git a/pkg/kubeenv/session.go b/pkg/kubeenv/session.go new file mode 100644 index 000000000..0305e7bd2 --- /dev/null +++ b/pkg/kubeenv/session.go @@ -0,0 +1,58 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package kubeenv + +import ( + "crypto/rand" + "encoding/hex" + "net/http" + "strconv" + "time" +) + +// SessionHeader is the HTTP header Helm sends on Kubernetes API requests so +// requests from a single command execution can be correlated for auditing. +const SessionHeader = "helm-session" + +// SessionRoundTripper sets the SessionHeader header carrying SessionID on +// every request sent through the wrapped [http.RoundTripper]. +type SessionRoundTripper struct { + Wrapped http.RoundTripper + SessionID string +} + +// RoundTrip implements [http.RoundTripper]. +func (rt *SessionRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + // Clone the request: a RoundTripper must not mutate the request it is given. + r := req.Clone(req.Context()) + r.Header.Set(SessionHeader, rt.SessionID) + return rt.Wrapped.RoundTrip(r) +} + +// NewSessionID generates a fresh 128-bit session identifier. Callers should +// generate one ID per logical operation: the Helm CLI creates a single +// [cli.EnvSettings] per command invocation, so every Kubernetes client built +// while running one command shares a session, while separate settings (e.g. +// SDK clients performing distinct operations) get distinct sessions. +func NewSessionID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err == nil { + return hex.EncodeToString(b[:]) + } + // crypto/rand effectively never fails; fall back to a timestamp-based value. + return "fallback-" + strconv.FormatInt(time.Now().UnixNano(), 16) +} diff --git a/pkg/kubeenv/session_test.go b/pkg/kubeenv/session_test.go new file mode 100644 index 000000000..0ffbefed0 --- /dev/null +++ b/pkg/kubeenv/session_test.go @@ -0,0 +1,93 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package kubeenv + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// captureRoundTripper records the request it receives and returns a canned response. +type captureRoundTripper struct { + got *http.Request +} + +func (c *captureRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + c.got = req + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{}}, nil +} + +func TestSessionRoundTripper(t *testing.T) { + tests := []struct { + name string + sessionID string + }{ + {name: "sets the session header", sessionID: "test-session-id"}, + {name: "sets the generated session ID", sessionID: NewSessionID()}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + capture := &captureRoundTripper{} + rt := &SessionRoundTripper{Wrapped: capture, SessionID: tt.sessionID} + + req, err := http.NewRequest(http.MethodGet, "https://example.com/api", nil) + require.NoError(t, err) + // A pre-existing header with the same name must be replaced, not duplicated. + req.Header.Set(SessionHeader, "stale") + + _, err = rt.RoundTrip(req) + require.NoError(t, err) + require.NotNil(t, capture.got) + + assert.Equal(t, tt.sessionID, capture.got.Header.Get(SessionHeader)) + assert.Len(t, capture.got.Header.Values(SessionHeader), 1) + // The caller's request must not be mutated. + assert.Equal(t, "stale", req.Header.Get(SessionHeader)) + }) + } +} + +func TestSessionRoundTripper_StableAcrossRequests(t *testing.T) { + capture := &captureRoundTripper{} + rt := &SessionRoundTripper{Wrapped: capture, SessionID: NewSessionID()} + + var first string + for range 3 { + req, err := http.NewRequest(http.MethodGet, "https://example.com/api", nil) + require.NoError(t, err) + _, err = rt.RoundTrip(req) + require.NoError(t, err) + got := capture.got.Header.Get(SessionHeader) + require.NotEmpty(t, got) + if first == "" { + first = got + } else { + assert.Equal(t, first, got, "session ID must be stable across requests") + } + } +} + +func TestNewSessionID_UniqueAndNonEmpty(t *testing.T) { + id1 := NewSessionID() + id2 := NewSessionID() + assert.NotEmpty(t, id1) + assert.NotEqual(t, id1, id2, "NewSessionID must return a fresh ID per call") +}