Skip to content

Commit 9faea50

Browse files
feat(pam): gateway auth for kubernetes (#186)
* feat: add gateway-mediated kubernetes authentication for PAM Add impersonation support to the PAM Kubernetes proxy. When auth method is gateway-kubernetes-auth, the gateway reads its own pod token, sets Impersonate-User/Group headers, auto-discovers the K8s API from env vars, and uses the pod CA cert for TLS. * fix: address review feedback on gateway k8s auth - Treat empty authMethod as service-account-token for backwards compat - Add empty namespace/SA name guard before constructing impersonation headers - Use net.JoinHostPort for IPv6-safe URL construction - Sanitize error messages sent to kubectl client (use static strings) - Write HTTP 500 before returning on websocket auth failure - Log warning when KUBERNETES_SERVICE_HOST env vars are missing * fix: address second round of review feedback - Add system:authenticated to Impersonate-Group headers (required for K8s API discovery endpoints) - Fail fast when KUBERNETES_SERVICE_HOST env vars are missing instead of logging warning and continuing with broken state - Check AppendCertsFromPEM return value before overriding TLS config - Use _, _ for discarded write error in websocket path * fix: address third round of review feedback - Strip client-supplied Impersonate-* headers before injecting auth to prevent privilege escalation - Fail fast at session setup when namespace/SA name are empty instead of failing per-request - Return error instead of falling back when pod CA cert can't be read or parsed (fallback TLS config has InsecureSkipVerify: true) * fix: address fourth round of review feedback - Add defer sessionLogger.Close() in HandlePAMProxy to prevent file descriptor leak on early return paths - Strip Impersonate-Uid header alongside User/Group/Extra-* - Fail fast at session setup when namespace/SA name are empty * fix(pam): prevent kubernetes session from dying after websocket exec The local Kubernetes proxy used WaitForDisconnect which treats any gateway-side connection close as a session-level disconnect, shutting down the entire proxy. This works for persistent protocols (SSH, DB) but not Kubernetes, where each kubectl command is a separate connection and the gateway closing after handling a request is normal. Replace with a simple per-connection wait that lets the proxy stay alive for subsequent kubectl commands. * fix(pam): remove stale cluster entry from kubeconfig on session end gracefulShutdown deleted config.Contexts twice instead of also deleting config.Clusters, leaving the http://localhost:<port> cluster entry orphaned in kubeconfig after each session. --------- Co-authored-by: saif <11242541+saifsmailbox98@users.noreply.github.com>
1 parent fedaa27 commit 9faea50

5 files changed

Lines changed: 120 additions & 10 deletions

File tree

packages/api/model.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -887,6 +887,8 @@ type PAMSessionCredentials struct {
887887
Certificate string `json:"certificate,omitempty"`
888888
Url string `json:"url,omitempty"`
889889
ServiceAccountToken string `json:"serviceAccountToken,omitempty"`
890+
ServiceAccountName string `json:"serviceAccountName,omitempty"`
891+
Namespace string `json:"namespace,omitempty"`
890892
}
891893

892894
type MFASessionStatus string

packages/pam/handlers/kubernetes/proxy.go

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ import (
1111
"net"
1212
"net/http"
1313
"net/url"
14+
"os"
1415
"strings"
1516
"sync"
1617
"time"
1718

1819
"github.com/Infisical/infisical-merge/packages/pam/session"
20+
"github.com/Infisical/infisical-merge/packages/util"
1921
"github.com/google/uuid"
2022
"github.com/rs/zerolog/log"
2123
)
@@ -24,6 +26,8 @@ type KubernetesProxyConfig struct {
2426
TargetApiServer string
2527
AuthMethod string
2628
InjectServiceAccountToken string
29+
ImpersonateNamespace string
30+
ImpersonateServiceAccount string
2731
TLSConfig *tls.Config
2832
SessionID string
2933
SessionLogger session.SessionLogger
@@ -40,6 +44,47 @@ func NewKubernetesProxy(config KubernetesProxyConfig) *KubernetesProxy {
4044
return &KubernetesProxy{config: config}
4145
}
4246

47+
// injectAuthHeaders sets the appropriate auth headers based on the configured auth method.
48+
// For service-account-token: injects the stored Bearer token.
49+
// For gateway-kubernetes-auth: reads the gateway pod's own token (fresh each call) and sets
50+
// Impersonate-User/Group headers to act as the target service account.
51+
func (p *KubernetesProxy) injectAuthHeaders(headers http.Header) error {
52+
// Strip any client-supplied impersonation headers to prevent privilege escalation
53+
headers.Del("Impersonate-User")
54+
headers.Del("Impersonate-Group")
55+
headers.Del("Impersonate-Uid")
56+
for key := range headers {
57+
if strings.HasPrefix(strings.ToLower(key), "impersonate-extra-") {
58+
headers.Del(key)
59+
}
60+
}
61+
62+
switch p.config.AuthMethod {
63+
case "service-account-token", "":
64+
headers.Set("Authorization", fmt.Sprintf("Bearer %s", p.config.InjectServiceAccountToken))
65+
case "gateway-kubernetes-auth":
66+
if p.config.ImpersonateNamespace == "" || p.config.ImpersonateServiceAccount == "" {
67+
return fmt.Errorf("gateway-kubernetes-auth requires non-empty namespace and service account name")
68+
}
69+
// Read fresh on each request — K8s auto-rotates projected volume tokens
70+
token, err := os.ReadFile(util.KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH)
71+
if err != nil {
72+
return fmt.Errorf("gateway not running in K8s cluster, unable to read pod service account token: %w", err)
73+
}
74+
headers.Set("Authorization", fmt.Sprintf("Bearer %s", strings.TrimSpace(string(token))))
75+
76+
saUser := fmt.Sprintf("system:serviceaccount:%s:%s",
77+
p.config.ImpersonateNamespace, p.config.ImpersonateServiceAccount)
78+
headers.Set("Impersonate-User", saUser)
79+
headers.Set("Impersonate-Group", "system:serviceaccounts")
80+
headers.Add("Impersonate-Group", fmt.Sprintf("system:serviceaccounts:%s", p.config.ImpersonateNamespace))
81+
headers.Add("Impersonate-Group", "system:authenticated")
82+
default:
83+
return fmt.Errorf("unsupported Kubernetes auth method: %s", p.config.AuthMethod)
84+
}
85+
return nil
86+
}
87+
4388
func buildHttpInternalServerError(message string) string {
4489
return fmt.Sprintf("HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\n\r\n{\"message\": \"gateway: %s\"}", message)
4590
}
@@ -165,7 +210,14 @@ func (p *KubernetesProxy) HandleConnection(ctx context.Context, clientConn net.C
165210
continue // Continue to next request
166211
}
167212
proxyReq.Header = req.Header.Clone()
168-
proxyReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", p.config.InjectServiceAccountToken))
213+
if err := p.injectAuthHeaders(proxyReq.Header); err != nil {
214+
l.Error().Err(err).Msg("Failed to inject auth headers")
215+
_, err = clientConn.Write([]byte(buildHttpInternalServerError("failed to configure auth headers")))
216+
if err != nil {
217+
return err
218+
}
219+
continue
220+
}
169221

170222
resp, err := selfServerClient.Do(proxyReq)
171223
if err != nil {
@@ -255,8 +307,11 @@ func (p *KubernetesProxy) forwardWebsocketConnection(
255307
sb.WriteString(fmt.Sprintf("%s %s HTTP/1.1\r\n", req.Method, newUrl.RequestURI()))
256308
headers := req.Header.Clone()
257309
headers.Set("Host", newUrl.Host)
258-
// Inject the auth header
259-
headers.Set("Authorization", fmt.Sprintf("Bearer %s", p.config.InjectServiceAccountToken))
310+
if err := p.injectAuthHeaders(headers); err != nil {
311+
l.Error().Err(err).Msg("Failed to inject auth headers for websocket")
312+
_, _ = clientConn.Write([]byte(buildHttpInternalServerError("failed to configure auth headers")))
313+
return err
314+
}
260315
for key, values := range headers {
261316
for _, value := range values {
262317
sb.WriteString(fmt.Sprintf("%s: %s\r\n", key, value))

packages/pam/local/kubernetes-proxy.go

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ func (p *KubernetesProxyServer) gracefulShutdown() {
191191

192192
delete(config.Contexts, p.kubeConfigClusterName)
193193
delete(config.AuthInfos, p.kubeConfigClusterName)
194-
delete(config.Contexts, p.kubeConfigClusterName)
194+
delete(config.Clusters, p.kubeConfigClusterName)
195195
if p.kubeConfigOriginalContext != "" {
196196
config.CurrentContext = p.kubeConfigOriginalContext
197197
}
@@ -304,9 +304,14 @@ func (p *KubernetesProxyServer) handleConnection(clientConn net.Conn) {
304304
connCtx, connCancel := context.WithCancel(p.ctx)
305305
defer connCancel()
306306

307-
gatewayErrCh, clientErrCh := p.NewDisconnectChannels()
307+
// For Kubernetes, each kubectl command opens a separate connection.
308+
// Unlike persistent protocols (SSH, databases), the gateway closing after
309+
// handling a request is normal — not a session-level disconnect.
310+
// So we just wait for either side to finish and return, without triggering
311+
// HandleGatewayDisconnect which would shut down the entire proxy.
312+
done := make(chan struct{}, 2)
308313

309-
// Gateway → Client: if this side closes first, the gateway dropped the connection
314+
// Gateway → Client
310315
go func() {
311316
defer connCancel()
312317
_, err := io.Copy(clientConn, gatewayConn)
@@ -317,10 +322,10 @@ func (p *KubernetesProxyServer) handleConnection(clientConn net.Conn) {
317322
log.Debug().Err(err).Msg("Gateway to client copy ended")
318323
}
319324
}
320-
gatewayErrCh <- err
325+
done <- struct{}{}
321326
}()
322327

323-
// Client → Gateway: if this side closes first, the client disconnected normally
328+
// Client → Gateway
324329
go func() {
325330
defer connCancel()
326331
_, err := io.Copy(gatewayConn, clientConn)
@@ -331,10 +336,15 @@ func (p *KubernetesProxyServer) handleConnection(clientConn net.Conn) {
331336
log.Debug().Err(err).Msg("Client to gateway copy ended")
332337
}
333338
}
334-
clientErrCh <- err
339+
done <- struct{}{}
335340
}()
336341

337-
p.WaitForDisconnect(gatewayErrCh, clientErrCh, connCtx)
342+
// Wait for either side to finish — this is a per-connection close, not a session close
343+
select {
344+
case <-done:
345+
case <-connCtx.Done():
346+
log.Info().Msg("Connection cancelled by context")
347+
}
338348

339349
log.Info().Msgf("Connection closed for client: %s", clientConn.RemoteAddr().String())
340350
}

packages/pam/pam-proxy.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import (
66
"crypto/x509"
77
"encoding/json"
88
"fmt"
9+
"net"
910
"net/url"
11+
"os"
1012
"regexp"
1113
"time"
1214

@@ -19,6 +21,7 @@ import (
1921
"github.com/Infisical/infisical-merge/packages/pam/handlers/redis"
2022
"github.com/Infisical/infisical-merge/packages/pam/handlers/ssh"
2123
"github.com/Infisical/infisical-merge/packages/pam/session"
24+
"github.com/Infisical/infisical-merge/packages/util"
2225
"github.com/go-resty/resty/v2"
2326
"github.com/rs/zerolog/log"
2427
)
@@ -189,6 +192,11 @@ func HandlePAMProxy(ctx context.Context, conn *tls.Conn, pamConfig *GatewayPAMCo
189192
if err != nil {
190193
return fmt.Errorf("failed to create session logger: %w", err)
191194
}
195+
defer func() {
196+
if err := sessionLogger.Close(); err != nil {
197+
log.Error().Err(err).Str("sessionId", pamConfig.SessionId).Msg("Failed to close session logger")
198+
}
199+
}()
192200
pamConfig.SessionUploader.RegisterSession(pamConfig.SessionId)
193201

194202
serverName := credentials.Host
@@ -335,10 +343,41 @@ func HandlePAMProxy(ctx context.Context, conn *tls.Conn, pamConfig *GatewayPAMCo
335343
SessionID: pamConfig.SessionId,
336344
SessionLogger: sessionLogger,
337345
}
346+
347+
// For gateway-kubernetes-auth, override target URL and TLS with pod's in-cluster credentials
348+
if credentials.AuthMethod == "gateway-kubernetes-auth" {
349+
kubernetesConfig.ImpersonateNamespace = credentials.Namespace
350+
kubernetesConfig.ImpersonateServiceAccount = credentials.ServiceAccountName
351+
if credentials.Namespace == "" || credentials.ServiceAccountName == "" {
352+
return fmt.Errorf("gateway-kubernetes-auth requires non-empty namespace and service account name")
353+
}
354+
355+
// Auto-discover K8s API URL from env vars
356+
host, port := os.Getenv(util.KUBERNETES_SERVICE_HOST_ENV_NAME), os.Getenv(util.KUBERNETES_SERVICE_PORT_HTTPS_ENV_NAME)
357+
if host == "" || port == "" {
358+
return fmt.Errorf("gateway-kubernetes-auth requires KUBERNETES_SERVICE_HOST and KUBERNETES_SERVICE_PORT_HTTPS to be set; gateway must run inside a Kubernetes pod")
359+
}
360+
kubernetesConfig.TargetApiServer = fmt.Sprintf("https://%s", net.JoinHostPort(host, port))
361+
362+
// Use pod's in-cluster CA cert with strict TLS (ignore resource SSL settings)
363+
caCert, err := os.ReadFile(util.KUBERNETES_SERVICE_ACCOUNT_CA_CERT_PATH)
364+
if err != nil {
365+
return fmt.Errorf("gateway-kubernetes-auth: failed to read pod CA cert for strict TLS: %w", err)
366+
}
367+
caCertPool := x509.NewCertPool()
368+
if !caCertPool.AppendCertsFromPEM(caCert) {
369+
return fmt.Errorf("gateway-kubernetes-auth: pod CA cert PEM is invalid or empty; cannot establish strict TLS")
370+
}
371+
kubernetesConfig.TLSConfig = &tls.Config{
372+
RootCAs: caCertPool,
373+
}
374+
}
375+
338376
proxy := kubernetes.NewKubernetesProxy(kubernetesConfig)
339377
log.Info().
340378
Str("sessionId", pamConfig.SessionId).
341379
Str("target", kubernetesConfig.TargetApiServer).
380+
Str("authMethod", credentials.AuthMethod).
342381
Msg("Starting Kubernetes PAM proxy")
343382
return proxy.HandleConnection(ctx, conn)
344383
case session.ResourceTypeMongodb:

packages/pam/session/credentials.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ type PAMCredentials struct {
2525
SSLCertificate string
2626
Url string
2727
ServiceAccountToken string
28+
ServiceAccountName string
29+
Namespace string
2830
PolicyRules *api.PAMPolicyRules
2931
}
3032

@@ -101,6 +103,8 @@ func (cm *CredentialsManager) GetPAMSessionCredentials(sessionId string, expiryT
101103
SSLCertificate: response.Credentials.SSLCertificate,
102104
Url: response.Credentials.Url,
103105
ServiceAccountToken: response.Credentials.ServiceAccountToken,
106+
ServiceAccountName: response.Credentials.ServiceAccountName,
107+
Namespace: response.Credentials.Namespace,
104108
PolicyRules: response.PolicyRules,
105109
}
106110

0 commit comments

Comments
 (0)