Skip to content
Merged
21 changes: 21 additions & 0 deletions packages/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const (
operationCallAwsAuthLoginGateway = "CallAwsAuthLoginGateway"
operationCallPAMAccess = "CallPAMAccess"
operationCallPAMAccessApprovalRequest = "CallPAMAccessApprovalRequest"
operationCallPAMCreateAccessRequest = "CallPAMCreateAccessRequest"
operationCallPAMSessionCredentials = "CallPAMSessionCredentials"
operationCallGetPamSessionKey = "CallGetPamSessionKey"
operationCallUploadPamSessionLog = "CallUploadPamSessionLog"
Expand Down Expand Up @@ -1137,6 +1138,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.
Expand Down
14 changes: 14 additions & 0 deletions packages/api/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -901,6 +901,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"`
}
Expand Down
129 changes: 128 additions & 1 deletion packages/pam/local/access.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package pam

import (
"context"
"errors"
"fmt"
"os"
"os/signal"
Expand All @@ -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"
)

Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -99,6 +117,115 @@ 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

// Reason requiredness follows the account template's Require Reason policy, surfaced in the
// approval-gate error details; the dashboard and API enforce the same rule.
requireReason := false
hasPendingRequest := false
hasApprovalPolicy := true
if details, ok := apiErr.Details.(map[string]any); ok {
if v, ok := details["requireReason"].(bool); ok {
requireReason = v
}
if v, ok := details["hasPendingRequest"].(bool); ok {
hasPendingRequest = v
}
if v, ok := details["hasApprovalPolicy"].(bool); ok {
hasApprovalPolicy = v
}
}

// Requesting would only hit a no-approvers error, so explain the misconfiguration instead
if !hasApprovalPolicy {
util.PrintErrorMessageAndExit("This account requires approval, but its folder has no approvers configured yet. Ask a folder admin to add approvers under the folder's Approvals tab.")
return true
}

// Submitting again would only hit the duplicate-request error, so point at the existing one
if hasPendingRequest {
util.PrintErrorMessageAndExit("Your access request for this account is awaiting approval. You'll be able to launch a session once it's approved.")
return true
}

// 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
}
Comment thread
bernie-g marked this conversation as resolved.

requestReason := reason
if requestReason == "" {
label := "Reason (visible to approvers, optional)"
if requireReason {
label = "Reason (visible to approvers)"
}
reasonPrompt := promptui.Prompt{
Label: label,
Validate: func(input string) error {
if requireReason && strings.TrimSpace(input) == "" {
return errors.New("a reason is required for this account")
}
if len(input) > 500 {
return errors.New("reason must be at most 500 characters")
}
return nil
},
}
reasonInput, reasonErr := reasonPrompt.Run()
if reasonErr != nil {
if errors.Is(reasonErr, promptui.ErrInterrupt) {
util.PrintErrorMessageAndExit("Access request cancelled")
}
log.Info().Msg("No access request created.")
return true
}
requestReason = strings.TrimSpace(reasonInput)
}

if _, reqErr := api.CallPAMCreateAccessRequest(httpClient, api.PAMCreateAccessRequestBody{
Path: path,
Reason: requestReason,
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"
Expand Down
122 changes: 0 additions & 122 deletions packages/pam/local/rdp-proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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
Expand Down
Loading