Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const (
operationCallAwsAuthLoginGateway = "CallAwsAuthLoginGateway"
operationCallPAMAccess = "CallPAMAccess"
operationCallPAMAccessApprovalRequest = "CallPAMAccessApprovalRequest"
operationCallPAMCreateAccessRequest = "CallPAMCreateAccessRequest"
operationCallPAMSessionCredentials = "CallPAMSessionCredentials"
operationCallGetPamSessionKey = "CallGetPamSessionKey"
operationCallUploadPamSessionLog = "CallUploadPamSessionLog"
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions packages/api/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down
71 changes: 70 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,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
}
Comment thread
bernie-g marked this conversation as resolved.

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"
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