Skip to content
2 changes: 2 additions & 0 deletions packages/api/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -887,6 +887,8 @@ type PAMSessionCredentials struct {
Certificate string `json:"certificate,omitempty"`
Url string `json:"url,omitempty"`
ServiceAccountToken string `json:"serviceAccountToken,omitempty"`
ServiceAccountName string `json:"serviceAccountName,omitempty"`
Namespace string `json:"namespace,omitempty"`
}

type MFASessionStatus string
Expand Down
61 changes: 58 additions & 3 deletions packages/pam/handlers/kubernetes/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ import (
"net"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"

"github.com/Infisical/infisical-merge/packages/pam/session"
"github.com/Infisical/infisical-merge/packages/util"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
)
Expand All @@ -24,6 +26,8 @@ type KubernetesProxyConfig struct {
TargetApiServer string
AuthMethod string
InjectServiceAccountToken string
ImpersonateNamespace string
ImpersonateServiceAccount string
TLSConfig *tls.Config
SessionID string
SessionLogger session.SessionLogger
Expand All @@ -40,6 +44,47 @@ func NewKubernetesProxy(config KubernetesProxyConfig) *KubernetesProxy {
return &KubernetesProxy{config: config}
}

// injectAuthHeaders sets the appropriate auth headers based on the configured auth method.
// For service-account-token: injects the stored Bearer token.
// For gateway-kubernetes-auth: reads the gateway pod's own token (fresh each call) and sets
// Impersonate-User/Group headers to act as the target service account.
func (p *KubernetesProxy) injectAuthHeaders(headers http.Header) error {
// Strip any client-supplied impersonation headers to prevent privilege escalation
headers.Del("Impersonate-User")
headers.Del("Impersonate-Group")
headers.Del("Impersonate-Uid")
for key := range headers {
if strings.HasPrefix(strings.ToLower(key), "impersonate-extra-") {
headers.Del(key)
}
}

Comment thread
claude[bot] marked this conversation as resolved.
switch p.config.AuthMethod {
case "service-account-token", "":
headers.Set("Authorization", fmt.Sprintf("Bearer %s", p.config.InjectServiceAccountToken))
case "gateway-kubernetes-auth":
if p.config.ImpersonateNamespace == "" || p.config.ImpersonateServiceAccount == "" {
return fmt.Errorf("gateway-kubernetes-auth requires non-empty namespace and service account name")
}
// Read fresh on each request — K8s auto-rotates projected volume tokens
token, err := os.ReadFile(util.KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH)
if err != nil {
return fmt.Errorf("gateway not running in K8s cluster, unable to read pod service account token: %w", err)
}
headers.Set("Authorization", fmt.Sprintf("Bearer %s", strings.TrimSpace(string(token))))

saUser := fmt.Sprintf("system:serviceaccount:%s:%s",
p.config.ImpersonateNamespace, p.config.ImpersonateServiceAccount)
headers.Set("Impersonate-User", saUser)
headers.Set("Impersonate-Group", "system:serviceaccounts")
headers.Add("Impersonate-Group", fmt.Sprintf("system:serviceaccounts:%s", p.config.ImpersonateNamespace))
headers.Add("Impersonate-Group", "system:authenticated")
default:
Comment thread
saifsmailbox98 marked this conversation as resolved.
return fmt.Errorf("unsupported Kubernetes auth method: %s", p.config.AuthMethod)
}
return nil
}
Comment thread
claude[bot] marked this conversation as resolved.

func buildHttpInternalServerError(message string) string {
return fmt.Sprintf("HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\n\r\n{\"message\": \"gateway: %s\"}", message)
}
Expand Down Expand Up @@ -165,7 +210,14 @@ func (p *KubernetesProxy) HandleConnection(ctx context.Context, clientConn net.C
continue // Continue to next request
}
proxyReq.Header = req.Header.Clone()
proxyReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", p.config.InjectServiceAccountToken))
if err := p.injectAuthHeaders(proxyReq.Header); err != nil {
l.Error().Err(err).Msg("Failed to inject auth headers")
_, err = clientConn.Write([]byte(buildHttpInternalServerError("failed to configure auth headers")))
if err != nil {
return err
}
continue
}

resp, err := selfServerClient.Do(proxyReq)
if err != nil {
Expand Down Expand Up @@ -255,8 +307,11 @@ func (p *KubernetesProxy) forwardWebsocketConnection(
sb.WriteString(fmt.Sprintf("%s %s HTTP/1.1\r\n", req.Method, newUrl.RequestURI()))
headers := req.Header.Clone()
headers.Set("Host", newUrl.Host)
// Inject the auth header
headers.Set("Authorization", fmt.Sprintf("Bearer %s", p.config.InjectServiceAccountToken))
if err := p.injectAuthHeaders(headers); err != nil {
l.Error().Err(err).Msg("Failed to inject auth headers for websocket")
_, _ = clientConn.Write([]byte(buildHttpInternalServerError("failed to configure auth headers")))
return err
}
Comment thread
saifsmailbox98 marked this conversation as resolved.
for key, values := range headers {
for _, value := range values {
sb.WriteString(fmt.Sprintf("%s: %s\r\n", key, value))
Expand Down
24 changes: 17 additions & 7 deletions packages/pam/local/kubernetes-proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ func (p *KubernetesProxyServer) gracefulShutdown() {

delete(config.Contexts, p.kubeConfigClusterName)
delete(config.AuthInfos, p.kubeConfigClusterName)
delete(config.Contexts, p.kubeConfigClusterName)
delete(config.Clusters, p.kubeConfigClusterName)
if p.kubeConfigOriginalContext != "" {
config.CurrentContext = p.kubeConfigOriginalContext
}
Expand Down Expand Up @@ -304,9 +304,14 @@ func (p *KubernetesProxyServer) handleConnection(clientConn net.Conn) {
connCtx, connCancel := context.WithCancel(p.ctx)
defer connCancel()

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

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

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

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

log.Info().Msgf("Connection closed for client: %s", clientConn.RemoteAddr().String())
}
39 changes: 39 additions & 0 deletions packages/pam/pam-proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (
"crypto/x509"
"encoding/json"
"fmt"
"net"
"net/url"
"os"
"regexp"
"time"

Expand All @@ -19,6 +21,7 @@ import (
"github.com/Infisical/infisical-merge/packages/pam/handlers/redis"
"github.com/Infisical/infisical-merge/packages/pam/handlers/ssh"
"github.com/Infisical/infisical-merge/packages/pam/session"
"github.com/Infisical/infisical-merge/packages/util"
"github.com/go-resty/resty/v2"
"github.com/rs/zerolog/log"
)
Expand Down Expand Up @@ -189,6 +192,11 @@ func HandlePAMProxy(ctx context.Context, conn *tls.Conn, pamConfig *GatewayPAMCo
if err != nil {
return fmt.Errorf("failed to create session logger: %w", err)
}
defer func() {
if err := sessionLogger.Close(); err != nil {
log.Error().Err(err).Str("sessionId", pamConfig.SessionId).Msg("Failed to close session logger")
}
}()
pamConfig.SessionUploader.RegisterSession(pamConfig.SessionId)
Comment thread
saifsmailbox98 marked this conversation as resolved.

serverName := credentials.Host
Expand Down Expand Up @@ -335,10 +343,41 @@ func HandlePAMProxy(ctx context.Context, conn *tls.Conn, pamConfig *GatewayPAMCo
SessionID: pamConfig.SessionId,
SessionLogger: sessionLogger,
}

// For gateway-kubernetes-auth, override target URL and TLS with pod's in-cluster credentials
if credentials.AuthMethod == "gateway-kubernetes-auth" {
kubernetesConfig.ImpersonateNamespace = credentials.Namespace
kubernetesConfig.ImpersonateServiceAccount = credentials.ServiceAccountName
if credentials.Namespace == "" || credentials.ServiceAccountName == "" {
return fmt.Errorf("gateway-kubernetes-auth requires non-empty namespace and service account name")
}

Comment thread
claude[bot] marked this conversation as resolved.
// Auto-discover K8s API URL from env vars
host, port := os.Getenv(util.KUBERNETES_SERVICE_HOST_ENV_NAME), os.Getenv(util.KUBERNETES_SERVICE_PORT_HTTPS_ENV_NAME)
if host == "" || port == "" {
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")
}
kubernetesConfig.TargetApiServer = fmt.Sprintf("https://%s", net.JoinHostPort(host, port))

// Use pod's in-cluster CA cert with strict TLS (ignore resource SSL settings)
caCert, err := os.ReadFile(util.KUBERNETES_SERVICE_ACCOUNT_CA_CERT_PATH)
if err != nil {
return fmt.Errorf("gateway-kubernetes-auth: failed to read pod CA cert for strict TLS: %w", err)
}
caCertPool := x509.NewCertPool()
if !caCertPool.AppendCertsFromPEM(caCert) {
return fmt.Errorf("gateway-kubernetes-auth: pod CA cert PEM is invalid or empty; cannot establish strict TLS")
}
kubernetesConfig.TLSConfig = &tls.Config{
RootCAs: caCertPool,
}
Comment thread
claude[bot] marked this conversation as resolved.
}
Comment thread
claude[bot] marked this conversation as resolved.

proxy := kubernetes.NewKubernetesProxy(kubernetesConfig)
log.Info().
Str("sessionId", pamConfig.SessionId).
Str("target", kubernetesConfig.TargetApiServer).
Str("authMethod", credentials.AuthMethod).
Msg("Starting Kubernetes PAM proxy")
return proxy.HandleConnection(ctx, conn)
case session.ResourceTypeMongodb:
Expand Down
4 changes: 4 additions & 0 deletions packages/pam/session/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ type PAMCredentials struct {
SSLCertificate string
Url string
ServiceAccountToken string
ServiceAccountName string
Namespace string
PolicyRules *api.PAMPolicyRules
}

Expand Down Expand Up @@ -101,6 +103,8 @@ func (cm *CredentialsManager) GetPAMSessionCredentials(sessionId string, expiryT
SSLCertificate: response.Credentials.SSLCertificate,
Url: response.Credentials.Url,
ServiceAccountToken: response.Credentials.ServiceAccountToken,
ServiceAccountName: response.Credentials.ServiceAccountName,
Namespace: response.Credentials.Namespace,
PolicyRules: response.PolicyRules,
}

Expand Down
Loading