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 <aniketkomailkro@gmail.com>
pull/32707/head
Aniket 3 days ago
parent 8a9993eff0
commit 951c63544d

@ -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

@ -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)
}

@ -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")
}
Loading…
Cancel
Save