From 951c63544dd98bf9988e8e66b6a85d6ab52c3316 Mon Sep 17 00:00:00 2001 From: Aniket Date: Wed, 30 Sep 2026 01:22:21 -0700 Subject: [PATCH] Add helm-session header to Kubernetes API requests Every Helm CLI invocation now stamps a helm-session header carrying a 128-bit session ID on its Kubernetes API requests, so administrators can correlate all requests from a single command execution for auditing. The ID is generated once per Kubernetes client build (not once per process), so SDK clients building a client per operation get distinct sessions. It is logged at debug level so a user can quote it to their cluster admin, who will see the same value in the audit log. The wrapper sits outside the retry wrapper so retried requests keep the same session ID. Scoped to Kubernetes API requests; chart and registry downloads are untouched. Signed-off-by: Aniket --- pkg/cli/environment.go | 16 ++++++- pkg/kubeenv/session.go | 58 +++++++++++++++++++++++ pkg/kubeenv/session_test.go | 93 +++++++++++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 pkg/kubeenv/session.go create mode 100644 pkg/kubeenv/session_test.go diff --git a/pkg/cli/environment.go b/pkg/cli/environment.go index b0463d01f..6e09091f1 100644 --- a/pkg/cli/environment.go +++ b/pkg/cli/environment.go @@ -24,6 +24,7 @@ These dependencies are expressed as interfaces so that alternate implementations package cli import ( + "log/slog" "net/http" "os" "strconv" @@ -133,8 +134,21 @@ func New() *EnvSettings { WrapConfigFn: func(config *rest.Config) *rest.Config { config.Burst = env.BurstLimit config.QPS = env.QPS + // One session ID per client build: a CLI invocation builds one + // client, so all requests from a single command execution share a + // session, while SDK clients building a client per operation get + // distinct sessions. Logged 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 in the Kubernetes audit log. + sessionID := kubeenv.NewSessionID() + 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/kubeenv/session.go b/pkg/kubeenv/session.go new file mode 100644 index 000000000..8bef7c1aa --- /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 Kubernetes client they build: a Helm CLI invocation +// builds one client, so all requests from a single command execution share a +// session, while SDK clients building a client per operation get distinct +// sessions instead of sharing a process-global one. +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") +}