Scope helm-session ID to the CLI invocation

Generate the session ID once per EnvSettings instead of once per
Kubernetes client build. A single command (e.g. helm upgrade) builds
several clients, so per-build IDs produced a different session per
client and defeated request correlation. One settings instance now
means one session, while separate settings (distinct SDK operations)
still get distinct sessions.

Signed-off-by: Aniket <aniketkomailkro@gmail.com>
pull/32707/head
Aniket 1 day ago
parent 951c63544d
commit 2f65e55e8c

@ -29,6 +29,7 @@ import (
"os"
"strconv"
"strings"
"sync"
"github.com/spf13/pflag"
"k8s.io/cli-runtime/pkg/genericclioptions"
@ -119,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,
@ -134,14 +145,9 @@ 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)
logSessionOnce.Do(func() {
slog.Debug("created Kubernetes client session", "helm-session", sessionID)
})
config.Wrap(func(rt http.RoundTripper) http.RoundTripper {
return &kubeenv.SessionRoundTripper{
// Session wrapper stays outside the retry wrapper so

@ -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() {

@ -44,10 +44,10 @@ func (rt *SessionRoundTripper) RoundTrip(req *http.Request) (*http.Response, err
}
// 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.
// 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 {

Loading…
Cancel
Save