From ae157565225546140c05b4e2a900262280208dbc Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Tue, 14 Apr 2026 05:45:49 +0530 Subject: [PATCH 1/2] 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. --- packages/api/model.go | 2 + packages/pam/handlers/kubernetes/proxy.go | 46 +++++++++++++++++++++-- packages/pam/pam-proxy.go | 29 ++++++++++++++ packages/pam/session/credentials.go | 4 ++ 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/packages/api/model.go b/packages/api/model.go index 7e1adf8e..f5235af4 100644 --- a/packages/api/model.go +++ b/packages/api/model.go @@ -864,6 +864,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 diff --git a/packages/pam/handlers/kubernetes/proxy.go b/packages/pam/handlers/kubernetes/proxy.go index 39b52825..bba376ed 100644 --- a/packages/pam/handlers/kubernetes/proxy.go +++ b/packages/pam/handlers/kubernetes/proxy.go @@ -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" ) @@ -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 @@ -40,6 +44,33 @@ 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 { + switch p.config.AuthMethod { + case "service-account-token": + headers.Set("Authorization", fmt.Sprintf("Bearer %s", p.config.InjectServiceAccountToken)) + case "gateway-kubernetes-auth": + // 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)) + default: + return fmt.Errorf("unsupported Kubernetes auth method: %s", p.config.AuthMethod) + } + return nil +} + 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) } @@ -165,7 +196,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(err.Error()))) + if err != nil { + return err + } + continue + } resp, err := selfServerClient.Do(proxyReq) if err != nil { @@ -255,8 +293,10 @@ 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") + return err + } for key, values := range headers { for _, value := range values { sb.WriteString(fmt.Sprintf("%s: %s\r\n", key, value)) diff --git a/packages/pam/pam-proxy.go b/packages/pam/pam-proxy.go index b4cf2650..d78a5d85 100644 --- a/packages/pam/pam-proxy.go +++ b/packages/pam/pam-proxy.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "net/url" + "os" "time" "github.com/Infisical/infisical-merge/packages/pam/handlers" @@ -17,6 +18,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" ) @@ -297,10 +299,37 @@ 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 + + // Auto-discover K8s API URL from env vars + k8sHost := util.KUBERNETES_SERVICE_HOST_ENV_NAME + k8sPort := util.KUBERNETES_SERVICE_PORT_HTTPS_ENV_NAME + if host, port := os.Getenv(k8sHost), os.Getenv(k8sPort); host != "" && port != "" { + kubernetesConfig.TargetApiServer = fmt.Sprintf("https://%s:%s", 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 { + log.Warn().Err(err).Msg("Failed to read pod CA cert, falling back to resource TLS config") + } else { + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + kubernetesConfig.TLSConfig = &tls.Config{ + RootCAs: caCertPool, + } + } + } + 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: diff --git a/packages/pam/session/credentials.go b/packages/pam/session/credentials.go index 1e2901b1..2ac6da2b 100644 --- a/packages/pam/session/credentials.go +++ b/packages/pam/session/credentials.go @@ -25,6 +25,8 @@ type PAMCredentials struct { SSLCertificate string Url string ServiceAccountToken string + ServiceAccountName string + Namespace string } type cachedCredentials struct { @@ -100,6 +102,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, } cm.cacheMutex.Lock() From b550439858cb43e75a11e9996725bf89d6b4e4f0 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:46:05 +0530 Subject: [PATCH 2/2] 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 --- packages/pam/handlers/kubernetes/proxy.go | 8 ++++++-- packages/pam/pam-proxy.go | 11 +++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/pam/handlers/kubernetes/proxy.go b/packages/pam/handlers/kubernetes/proxy.go index bba376ed..0b6b16c9 100644 --- a/packages/pam/handlers/kubernetes/proxy.go +++ b/packages/pam/handlers/kubernetes/proxy.go @@ -50,9 +50,12 @@ func NewKubernetesProxy(config KubernetesProxyConfig) *KubernetesProxy { // Impersonate-User/Group headers to act as the target service account. func (p *KubernetesProxy) injectAuthHeaders(headers http.Header) error { switch p.config.AuthMethod { - case "service-account-token": + 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 { @@ -198,7 +201,7 @@ func (p *KubernetesProxy) HandleConnection(ctx context.Context, clientConn net.C proxyReq.Header = req.Header.Clone() if err := p.injectAuthHeaders(proxyReq.Header); err != nil { l.Error().Err(err).Msg("Failed to inject auth headers") - _, err = clientConn.Write([]byte(buildHttpInternalServerError(err.Error()))) + _, err = clientConn.Write([]byte(buildHttpInternalServerError("failed to configure auth headers"))) if err != nil { return err } @@ -295,6 +298,7 @@ func (p *KubernetesProxy) forwardWebsocketConnection( headers.Set("Host", newUrl.Host) 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 } for key, values := range headers { diff --git a/packages/pam/pam-proxy.go b/packages/pam/pam-proxy.go index d78a5d85..b7c57512 100644 --- a/packages/pam/pam-proxy.go +++ b/packages/pam/pam-proxy.go @@ -6,6 +6,7 @@ import ( "crypto/x509" "encoding/json" "fmt" + "net" "net/url" "os" "time" @@ -306,10 +307,12 @@ func HandlePAMProxy(ctx context.Context, conn *tls.Conn, pamConfig *GatewayPAMCo kubernetesConfig.ImpersonateServiceAccount = credentials.ServiceAccountName // Auto-discover K8s API URL from env vars - k8sHost := util.KUBERNETES_SERVICE_HOST_ENV_NAME - k8sPort := util.KUBERNETES_SERVICE_PORT_HTTPS_ENV_NAME - if host, port := os.Getenv(k8sHost), os.Getenv(k8sPort); host != "" && port != "" { - kubernetesConfig.TargetApiServer = fmt.Sprintf("https://%s:%s", host, port) + if host, port := os.Getenv(util.KUBERNETES_SERVICE_HOST_ENV_NAME), os.Getenv(util.KUBERNETES_SERVICE_PORT_HTTPS_ENV_NAME); host != "" && port != "" { + kubernetesConfig.TargetApiServer = fmt.Sprintf("https://%s", net.JoinHostPort(host, port)) + } else { + log.Warn(). + Str("sessionId", pamConfig.SessionId). + Msg("KUBERNETES_SERVICE_HOST or KUBERNETES_SERVICE_PORT_HTTPS not set; gateway-kubernetes-auth requires the gateway to run inside a K8s pod") } // Use pod's in-cluster CA cert with strict TLS (ignore resource SSL settings)