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..97307072 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"` + Reason string `json:"reason,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..a6581783 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,9 @@ const ( AccountTypeWindowsAd = "windows-ad" ) +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. func normalizePath(path string) string { @@ -64,13 +70,25 @@ 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, apiDurationStr) { + return + } util.HandleError(err, "Failed to create PAM session") return } @@ -99,6 +117,57 @@ 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 && apiErr.Name != grantExpiredErrorName) { + return false + } + + 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()) { + if expired { + util.PrintErrorMessageAndExit("Your access grant for this account has expired. Request access again from the Infisical dashboard (PAM > My Access).") + } else { + util.PrintErrorMessageAndExit("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 { + // 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 + } + + if _, reqErr := api.CallPAMCreateAccessRequest(httpClient, api.PAMCreateAccessRequestBody{ + Path: path, + Reason: 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