mirror of https://github.com/helm/helm
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
parent
8a9993eff0
commit
951c63544d
@ -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…
Reference in new issue