From d4de1d7604b10276cb75a83e80a184cbf0fb250b Mon Sep 17 00:00:00 2001 From: bernie-g Date: Thu, 2 Jul 2026 12:15:20 -0400 Subject: [PATCH 1/3] feat(pam): support requesting access from the CLI on the approval gate Also removes the dead StartRDPLocalProxy entry (superseded by startRDPProxy in the PAM revamp). Redis proxy and the legacy approval workflow are left untouched. --- packages/api/api.go | 21 ++++++ packages/api/model.go | 14 ++++ packages/pam/local/access.go | 53 ++++++++++++++ packages/pam/local/rdp-proxy.go | 122 -------------------------------- 4 files changed, 88 insertions(+), 122 deletions(-) diff --git a/packages/api/api.go b/packages/api/api.go index 325c1c2e..f0aa25d3 100644 --- a/packages/api/api.go +++ b/packages/api/api.go @@ -54,6 +54,7 @@ const ( operationCallAwsAuthLoginGateway = "CallAwsAuthLoginGateway" operationCallPAMAccess = "CallPAMAccess" operationCallPAMAccessApprovalRequest = "CallPAMAccessApprovalRequest" + operationCallPAMCreateAccessRequest = "CallPAMCreateAccessRequest" operationCallPAMSessionCredentials = "CallPAMSessionCredentials" operationCallGetPamSessionKey = "CallGetPamSessionKey" operationCallUploadPamSessionLog = "CallUploadPamSessionLog" @@ -1141,6 +1142,26 @@ func CallPAMAccessApprovalRequest(httpClient *resty.Client, request PAMAccessApp return pamAccessApprovalRequestResponse, nil } +func CallPAMCreateAccessRequest(httpClient *resty.Client, request PAMCreateAccessRequestBody) (PAMCreateAccessRequestResponse, error) { + var pamCreateAccessRequestResponse PAMCreateAccessRequestResponse + response, err := httpClient. + R(). + SetResult(&pamCreateAccessRequestResponse). + SetHeader("User-Agent", USER_AGENT). + SetBody(request). + Post(fmt.Sprintf("%v/v1/pam/access-requests", config.INFISICAL_URL)) + + if err != nil { + return PAMCreateAccessRequestResponse{}, NewGenericRequestError(operationCallPAMCreateAccessRequest, err) + } + + if response.IsError() { + return PAMCreateAccessRequestResponse{}, NewAPIErrorWithResponse(operationCallPAMCreateAccessRequest, response, nil) + } + + return pamCreateAccessRequestResponse, nil +} + func CallPAMSessionCredentials(httpClient *resty.Client, sessionId string) (PAMSessionCredentialsResponse, error) { var pamSessionCredentialsResponse PAMSessionCredentialsResponse response, err := httpClient. diff --git a/packages/api/model.go b/packages/api/model.go index 61d39dce..a4785cf9 100644 --- a/packages/api/model.go +++ b/packages/api/model.go @@ -900,6 +900,20 @@ type PAMAccessApprovalRequestResponse struct { } `json:"request"` } +type PAMCreateAccessRequestBody struct { + Path string `json:"path"` + Note string `json:"note,omitempty"` + Duration string `json:"duration"` +} + +type PAMCreateAccessRequestResponse struct { + Request struct { + ID string `json:"id"` + ProjectId string `json:"projectId"` + OrgId string `json:"organizationId"` + } `json:"request"` +} + type PAMPolicyRuleConfig struct { Patterns []string `json:"patterns"` } diff --git a/packages/pam/local/access.go b/packages/pam/local/access.go index 44c09cac..718e7ab1 100644 --- a/packages/pam/local/access.go +++ b/packages/pam/local/access.go @@ -2,6 +2,7 @@ package pam import ( "context" + "errors" "fmt" "os" "os/signal" @@ -12,6 +13,8 @@ import ( "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/util" "github.com/go-resty/resty/v2" + "github.com/manifoldco/promptui" + "github.com/mattn/go-isatty" "github.com/rs/zerolog/log" ) @@ -30,6 +33,8 @@ const ( AccountTypeWindowsAd = "windows-ad" ) +const approvalRequiredErrorName = "PAM_APPROVAL_REQUIRED" + // normalizePath ensures the path has a leading slash for display purposes. // Both "/folder/account" and "folder/account" are accepted as input. func normalizePath(path string) string { @@ -71,6 +76,9 @@ func StartPAMAccess(accessToken, path, reason, durationStr, targetHost string, p TargetHost: targetHost, }, true) if err != nil { + if handleApprovalRequired(httpClient, err, path, reason, durationStr) { + return + } util.HandleError(err, "Failed to create PAM session") return } @@ -99,6 +107,51 @@ func StartPAMAccess(accessToken, path, reason, durationStr, targetHost string, p } } +// handleApprovalRequired intercepts the PAM_APPROVAL_REQUIRED gate. In an interactive terminal it +// offers to submit an access request for the account; otherwise it prints guidance. Returns true when +// the error was an approval-required error (handled here), false to let normal error handling proceed. +func handleApprovalRequired(httpClient *resty.Client, err error, path, reason, durationStr string) bool { + var apiErr *api.APIError + if !errors.As(err, &apiErr) || apiErr.Name != approvalRequiredErrorName { + return false + } + + expired := strings.Contains(strings.ToLower(apiErr.ErrorMessage), "expired") + + if !isatty.IsTerminal(os.Stdin.Fd()) { + if expired { + log.Error().Msg("Your access grant for this account has expired. Request access again from the Infisical dashboard (PAM > My Access).") + } else { + log.Error().Msg("This account requires approval. Request access from the Infisical dashboard (PAM > My Access).") + } + return true + } + + if expired { + log.Info().Msg("Your previous access grant has expired.") + } else { + log.Info().Msg("This account requires approval before you can launch a session.") + } + + prompt := promptui.Prompt{Label: "Request access now?", IsConfirm: true} + if _, promptErr := prompt.Run(); promptErr != nil { + log.Info().Msg("No access request created.") + return true + } + + if _, reqErr := api.CallPAMCreateAccessRequest(httpClient, api.PAMCreateAccessRequestBody{ + Path: path, + Note: reason, + Duration: durationStr, + }); reqErr != nil { + util.HandleError(reqErr, "Failed to submit access request") + return true + } + + log.Info().Msg("Access request submitted. You'll be able to launch a session once it's approved.") + return true +} + // DatabaseDisplayConfig holds the display configuration for a database type type DatabaseDisplayConfig struct { TypeLabel string // e.g., "PostgreSQL", "MySQL", "SQL Server" diff --git a/packages/pam/local/rdp-proxy.go b/packages/pam/local/rdp-proxy.go index c8067a2c..53a73854 100644 --- a/packages/pam/local/rdp-proxy.go +++ b/packages/pam/local/rdp-proxy.go @@ -7,15 +7,11 @@ import ( "net" "os" "os/exec" - "os/signal" "path/filepath" "runtime" - "syscall" "time" - "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" ) @@ -27,124 +23,6 @@ type RDPProxyServer struct { rdpFilePath string } -// CLI entry point for `infisical pam rdp access`. -func StartRDPLocalProxy(accessToken string, accessParams PAMAccessParams, projectID string, durationStr string, port int, noLaunch bool) { - log.Info().Msgf("Starting RDP proxy for account: %s", accessParams.GetDisplayName()) - log.Info().Msgf("Session duration: %s", durationStr) - - httpClient := resty.New() - httpClient.SetAuthToken(accessToken) - httpClient.SetHeader("User-Agent", "infisical-cli") - - pamRequest := accessParams.ToAPIRequest(projectID, durationStr) - - pamResponse, err := CallPAMAccessWithMFA(httpClient, pamRequest, true) - if err != nil { - if HandleApprovalWorkflow(httpClient, err, projectID, accessParams, durationStr) { - return - } - util.HandleError(err, "Failed to access PAM account") - return - } - - // Verify this is a Windows resource - if pamResponse.ResourceType != session.ResourceTypeWindows { - util.HandleError(fmt.Errorf("account is not a Windows resource, got: %s", pamResponse.ResourceType), "Invalid resource type") - return - } - - log.Info().Msgf("RDP session created with ID: %s", pamResponse.SessionId) - - duration, err := time.ParseDuration(durationStr) - if err != nil { - util.HandleError(err, "Failed to parse duration") - return - } - - ctx, cancel := context.WithCancel(context.Background()) - - proxy := &RDPProxyServer{ - BaseProxyServer: BaseProxyServer{ - httpClient: httpClient, - relayHost: pamResponse.RelayHost, - relayClientCert: pamResponse.RelayClientCertificate, - relayClientKey: pamResponse.RelayClientPrivateKey, - relayServerCertChain: pamResponse.RelayServerCertificateChain, - gatewayClientCert: pamResponse.GatewayClientCertificate, - gatewayClientKey: pamResponse.GatewayClientPrivateKey, - gatewayServerCertChain: pamResponse.GatewayServerCertificateChain, - sessionExpiry: time.Now().Add(duration), - sessionId: pamResponse.SessionId, - resourceType: pamResponse.ResourceType, - ctx: ctx, - cancel: cancel, - shutdownCh: make(chan struct{}), - }, - } - - if err := proxy.ValidateResourceTypeSupported(); err != nil { - util.HandleError(err, "Gateway version outdated") - return - } - - if err := proxy.Start(port); err != nil { - util.HandleError(err, "Failed to start proxy server") - return - } - - username, ok := pamResponse.Metadata["username"] - if !ok { - util.HandleError(fmt.Errorf("PAM response metadata is missing 'username'"), "Failed to start proxy server") - return - } - - rdpFilePath, err := writeRDPFile(proxy.port, pamResponse.SessionId, username) - if err != nil { - log.Warn().Err(err).Msg("Failed to write .rdp file; proxy still running") - } else { - proxy.rdpFilePath = rdpFilePath - } - - log.Info().Msgf("RDP proxy server listening on port %d", proxy.port) - util.PrintfStderr("\n") - util.PrintfStderr("**********************************************************************\n") - util.PrintfStderr(" RDP Proxy Session Started! \n") - util.PrintfStderr("----------------------------------------------------------------------\n") - util.PrintfStderr("Resource: %s\n", accessParams.ResourceName) - util.PrintfStderr("Account: %s\n", accessParams.AccountName) - util.PrintfStderr("\n") - util.PrintfStderr("Connect your RDP client to:\n") - util.PrintfStderr(" 127.0.0.1:%d\n", proxy.port) - util.PrintfStderr("With credentials:\n") - util.PrintfStderr(" username: %s\n", username) - util.PrintfStderr(" password: (leave blank)\n") - if proxy.rdpFilePath != "" { - util.PrintfStderr("\n") - util.PrintfStderr("Generated .rdp file:\n") - util.PrintfStderr(" %s\n", proxy.rdpFilePath) - } - util.PrintfStderr("\n") - util.PrintfStderr("Press Ctrl+C to terminate the session.\n") - util.PrintfStderr("**********************************************************************\n") - util.PrintfStderr("\n") - - if !noLaunch && proxy.rdpFilePath != "" { - if err := launchRDPClient(proxy.rdpFilePath); err != nil { - log.Warn().Err(err).Msg("Failed to auto-launch RDP client; connect manually using the details above") - } - } - - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - go func() { - sig := <-sigChan - log.Info().Msgf("Received signal %v, initiating graceful shutdown...", sig) - proxy.gracefulShutdown() - }() - - proxy.Run() -} - // Start binds the loopback listener. Port 0 picks a random free port. func (p *RDPProxyServer) Start(port int) error { var err error From fb168cfa11fc8d4dcb30c2363db01d382aa297b7 Mon Sep 17 00:00:00 2001 From: bernie-g Date: Thu, 2 Jul 2026 14:45:02 -0400 Subject: [PATCH 2/3] fix(pam): align access request payload with API and fix non-TTY exit code - send justification as reason (API strips the unknown note key) - convert Go durations to milliseconds before sending; npm ms can't parse compound formats like 2h30m - exit non-zero when the approval gate is hit in a non-interactive terminal --- packages/api/model.go | 2 +- packages/pam/local/access.go | 20 +++++++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/api/model.go b/packages/api/model.go index a4785cf9..97307072 100644 --- a/packages/api/model.go +++ b/packages/api/model.go @@ -902,7 +902,7 @@ type PAMAccessApprovalRequestResponse struct { type PAMCreateAccessRequestBody struct { Path string `json:"path"` - Note string `json:"note,omitempty"` + Reason string `json:"reason,omitempty"` Duration string `json:"duration"` } diff --git a/packages/pam/local/access.go b/packages/pam/local/access.go index 718e7ab1..25b8623e 100644 --- a/packages/pam/local/access.go +++ b/packages/pam/local/access.go @@ -69,14 +69,23 @@ func StartPAMAccess(accessToken, path, reason, durationStr, targetHost string, p httpClient.SetAuthToken(accessToken) httpClient.SetHeader("User-Agent", api.USER_AGENT) + // The API parses durations with npm ms, which can't read Go compound formats like "2h30m", + // so send plain milliseconds and keep the Go format for local parsing/display + duration, err := time.ParseDuration(durationStr) + if err != nil { + util.HandleError(err, "Invalid duration format. Use formats like '1h', '30m', '2h30m'") + return + } + apiDurationStr := fmt.Sprintf("%dms", duration.Milliseconds()) + pamResponse, err := CallPAMAccessWithMFA(httpClient, api.PAMAccessRequest{ Path: path, - Duration: durationStr, + Duration: apiDurationStr, Reason: reason, TargetHost: targetHost, }, true) if err != nil { - if handleApprovalRequired(httpClient, err, path, reason, durationStr) { + if handleApprovalRequired(httpClient, err, path, reason, apiDurationStr) { return } util.HandleError(err, "Failed to create PAM session") @@ -118,11 +127,12 @@ func handleApprovalRequired(httpClient *resty.Client, err error, path, reason, d expired := strings.Contains(strings.ToLower(apiErr.ErrorMessage), "expired") + // Non-interactive callers (CI, scripts) must see a non-zero exit since no session was created if !isatty.IsTerminal(os.Stdin.Fd()) { if expired { - log.Error().Msg("Your access grant for this account has expired. Request access again from the Infisical dashboard (PAM > My Access).") + util.PrintErrorMessageAndExit("Your access grant for this account has expired. Request access again from the Infisical dashboard (PAM > My Access).") } else { - log.Error().Msg("This account requires approval. Request access from the Infisical dashboard (PAM > My Access).") + util.PrintErrorMessageAndExit("This account requires approval. Request access from the Infisical dashboard (PAM > My Access).") } return true } @@ -141,7 +151,7 @@ func handleApprovalRequired(httpClient *resty.Client, err error, path, reason, d if _, reqErr := api.CallPAMCreateAccessRequest(httpClient, api.PAMCreateAccessRequestBody{ Path: path, - Note: reason, + Reason: reason, Duration: durationStr, }); reqErr != nil { util.HandleError(reqErr, "Failed to submit access request") From b5e1e256df7bbcd83923b7823d0406d86c269bc1 Mon Sep 17 00:00:00 2001 From: bernie-g Date: Thu, 2 Jul 2026 20:02:38 -0400 Subject: [PATCH 3/3] fix(pam): match PAM_GRANT_EXPIRED by name and exit non-zero on Ctrl+C - detect an expired grant via the PAM_GRANT_EXPIRED error name instead of substring-matching the backend error message - treat Ctrl+C (promptui.ErrInterrupt) at the request prompt as a non-zero exit rather than a graceful decline --- packages/pam/local/access.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/pam/local/access.go b/packages/pam/local/access.go index 25b8623e..a6581783 100644 --- a/packages/pam/local/access.go +++ b/packages/pam/local/access.go @@ -34,6 +34,7 @@ const ( ) const approvalRequiredErrorName = "PAM_APPROVAL_REQUIRED" +const grantExpiredErrorName = "PAM_GRANT_EXPIRED" // normalizePath ensures the path has a leading slash for display purposes. // Both "/folder/account" and "folder/account" are accepted as input. @@ -121,11 +122,11 @@ func StartPAMAccess(accessToken, path, reason, durationStr, targetHost string, p // the error was an approval-required error (handled here), false to let normal error handling proceed. func handleApprovalRequired(httpClient *resty.Client, err error, path, reason, durationStr string) bool { var apiErr *api.APIError - if !errors.As(err, &apiErr) || apiErr.Name != approvalRequiredErrorName { + if !errors.As(err, &apiErr) || (apiErr.Name != approvalRequiredErrorName && apiErr.Name != grantExpiredErrorName) { return false } - expired := strings.Contains(strings.ToLower(apiErr.ErrorMessage), "expired") + expired := apiErr.Name == grantExpiredErrorName // Non-interactive callers (CI, scripts) must see a non-zero exit since no session was created if !isatty.IsTerminal(os.Stdin.Fd()) { @@ -145,6 +146,11 @@ func handleApprovalRequired(httpClient *resty.Client, err error, path, reason, d prompt := promptui.Prompt{Label: "Request access now?", IsConfirm: true} if _, promptErr := prompt.Run(); promptErr != nil { + // Ctrl+C (interrupt) must exit non-zero so scripts don't read it as success; declining with 'n' + // (ErrAbort) is a graceful choice and exits 0. + if errors.Is(promptErr, promptui.ErrInterrupt) { + util.PrintErrorMessageAndExit("Access request cancelled") + } log.Info().Msg("No access request created.") return true }