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") +}