diff --git a/packages/api/api.go b/packages/api/api.go index 9bce7e93..36480834 100644 --- a/packages/api/api.go +++ b/packages/api/api.go @@ -77,6 +77,7 @@ const ( operationCallGetCertificateBundle = "CallGetCertificateBundle" operationCallRenewCertificate = "CallRenewCertificate" operationCallGetCertificateRequest = "CallGetCertificateRequest" + operationCallRevokeUserSession = "CallRevokeUserSession" ) var ErrNotFound = errors.New("resource not found") @@ -160,6 +161,25 @@ func CallLoginV3(httpClient *resty.Client, request GetLoginV3Request) (GetLoginV return loginV3Response, nil } +// CallRevokeUserSession revokes a single server-side login session by its id +// (the tokenVersionId claim carried in every session JWT). +func CallRevokeUserSession(httpClient *resty.Client, sessionID string) error { + response, err := httpClient. + R(). + SetHeader("User-Agent", USER_AGENT). + Delete(fmt.Sprintf("%v/v2/users/me/sessions/%v", config.INFISICAL_URL, url.PathEscape(sessionID))) + + if err != nil { + return NewGenericRequestError(operationCallRevokeUserSession, err) + } + + if response.IsError() { + return NewAPIErrorWithResponse(operationCallRevokeUserSession, response, nil) + } + + return nil +} + func CallVerifyMfaToken(httpClient *resty.Client, request VerifyMfaTokenRequest) (*VerifyMfaTokenResponse, *VerifyMfaTokenErrorResponse, error) { var verifyMfaTokenResponse VerifyMfaTokenResponse var responseError VerifyMfaTokenErrorResponse diff --git a/packages/cmd/init.go b/packages/cmd/init.go index 37eaeae4..78f1fd96 100644 --- a/packages/cmd/init.go +++ b/packages/cmd/init.go @@ -6,6 +6,7 @@ package cmd import ( "encoding/json" "fmt" + "os" "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/config" @@ -57,64 +58,81 @@ var initCmd = &cobra.Command{ } httpClient.SetAuthToken(userCreds.UserCredentials.JTWToken) - selectedOrgID, selectedSubOrgName, err := pickOrganization(httpClient, "Which Infisical organization would you like to select a project from?", userCreds.UserCredentials.Email) - if err != nil { - util.HandleError(err, "Unable to select organization") + // The profile already carries an organization (and --org can retarget it + // for this command), so don't ask again. Only fall back to the picker + // when the profile has no organization recorded, which happens for + // sessions migrated from a CLI that predates profiles. + // + // OrganizationID is the organization the session acts in. For a session + // scoped to a sub-organization that is the sub-organization, which is + // also where its projects are filed, not the root from the token. + selectedOrgID := userCreds.OrganizationID + var selectedSubOrgName *string + if parent, own := util.SplitOrgDisplayName(userCreds.OrganizationName); parent != "" { + selectedSubOrgName = &own } - tokenResponse, err := api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrgID}) - if tokenResponse.MfaEnabled { - i := 1 - for i < 6 { - mfaVerifyCode := askForMFACode(tokenResponse.MfaMethod) + if selectedOrgID == "" { + pickedOrgID, pickedSubOrgName, err := pickOrganization(httpClient, "Which Infisical organization would you like to select a project from?", userCreds.UserCredentials.Email) + if err != nil { + util.HandleError(err, "Unable to select organization") + } + selectedSubOrgName = pickedSubOrgName - httpClient, err := util.GetRestyClientWithCustomHeaders() - if err != nil { - util.HandleError(err, "Unable to get resty client with custom headers") - } - httpClient.SetAuthToken(tokenResponse.Token) - verifyMFAresponse, mfaErrorResponse, requestError := api.CallVerifyMfaToken(httpClient, api.VerifyMfaTokenRequest{ - Email: userCreds.UserCredentials.Email, - MFAToken: mfaVerifyCode, - MFAMethod: tokenResponse.MfaMethod, - }) - if requestError != nil { - util.HandleError(err) - break - } else if mfaErrorResponse != nil { - if mfaErrorResponse.Context.Code == "mfa_invalid" { - msg := fmt.Sprintf("Incorrect, verification code. You have %v attempts left", 5-i) - util.PrintlnStderr(msg) - if i == 5 { - util.PrintErrorMessageAndExit("No tries left, please try again in a bit") - break - } - } - - if mfaErrorResponse.Context.Code == "mfa_expired" { - util.PrintErrorMessageAndExit("Your 2FA verification code has expired, please try logging in again") - break - } - i++ - } else { - httpClient.SetAuthToken(verifyMFAresponse.Token) - tokenResponse, err = api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrgID}) - break - } + newSessionToken, err := selectOrganizationToken(userCreds.UserCredentials.JTWToken, userCreds.UserCredentials.Email, pickedOrgID) + if err != nil { + util.HandleError(err, "Unable to select organization") } - } - if err != nil { - util.HandleError(err, "Unable to select organization") - } + // The session token is now scoped to the selected organization; record + // it on the profile this invocation resolved to so later commands in + // this project don't have to ask again. + userCreds.UserCredentials.JTWToken = newSessionToken + orgID, subOrgID := util.ParseTokenOrgClaims(newSessionToken) + if orgID == "" { + orgID = pickedOrgID + } + orgInfo := util.DescribeSessionOrg(newSessionToken, orgID, subOrgID) + + updatedProfile := userCreds.Profile + updatedProfile.OrganizationID = orgID + updatedProfile.SubOrganizationID = subOrgID + updatedProfile.OrganizationName = orgInfo.DisplayName() + updatedProfile.OrganizationSlug = orgInfo.Slug + selectedOrgID = updatedProfile.ScopedOrganizationID() + + // Only move the global default when this invocation was using it; a + // terminal pinned via env var, flag, or directory scope must not switch + // other terminals. + makeActive := userCreds.ProfileSource == util.ProfileSourceDefault + err = util.PersistLoginProfile(updatedProfile, &userCreds.UserCredentials, makeActive) + httpClient.SetAuthToken(newSessionToken) + + if err != nil { + util.HandleError(err, "Unable to store your user credentials") + } + } else { + orgDisplay := userCreds.OrganizationName + if orgDisplay == "" { + orgDisplay = selectedOrgID + } - // set the config jwt token to the new token - userCreds.UserCredentials.JTWToken = tokenResponse.Token - err = util.StoreUserCredsInKeyRing(&userCreds.UserCredentials) - httpClient.SetAuthToken(tokenResponse.Token) + // An --org override is per command, so a project linked under it + // would not resolve on later runs that use the profile's default. + // That would surface later as an unrelated-looking "project not + // found", and a warning here can be silenced, so refuse and point + // at the two ways to make the organization stick. + if userCreds.OrganizationSource != util.OrgSourceProfileDefault && userCreds.Profile.OrganizationID != "" && userCreds.OrganizationID != userCreds.Profile.ScopedOrganizationID() { + profileOrg := userCreds.Profile.OrganizationName + if profileOrg == "" { + profileOrg = userCreds.Profile.ScopedOrganizationID() + } + util.PrintErrorMessageAndExit( + fmt.Sprintf("Profile '%s' defaults to organization %s, so a project linked here under %s (selected via %s) would not be found by later commands unless they also pass --org.", userCreds.ProfileName, profileOrg, orgDisplay, userCreds.OrganizationSource), + fmt.Sprintf("Make %s the profile's default with [infisical profile set-org %s], or keep both organizations by running [infisical profile new --org %s] and then [infisical profile bind ] in this directory.", orgDisplay, orgDisplay, orgDisplay)) + } - if err != nil { - util.HandleError(err, "Unable to store your user credentials") + util.PrintlnStderr(fmt.Sprintf("Using organization %s from profile '%s'. Pass --org to pick a different one.", orgDisplay, userCreds.ProfileName)) } workspaceResponse, err := api.CallGetAllWorkSpacesUserBelongsTo(httpClient) @@ -140,11 +158,48 @@ var initCmd = &cobra.Command{ util.HandleError(err) } + offerDirectoryProfileBinding(userCreds.ProfileName) + Telemetry.CaptureEvent("cli-command:init", posthog.NewProperties().Set("version", util.CLI_VERSION)) }, } +// offerDirectoryProfileBinding asks (only when multiple profiles exist) +// whether this directory should always use the profile init just ran with, so +// commands run here pick the right tenant without flags or env vars. +func offerDirectoryProfileBinding(profileName string) { + configFile, err := util.GetMigratedConfigFile() + if err != nil || profileName == "" || len(configFile.Profiles) < 2 { + return + } + + cwd, err := os.Getwd() + if err != nil { + return + } + + if boundProfile, _, ok := util.FindGoverningDirectoryProfile(configFile, cwd); ok && boundProfile == profileName { + return + } + + prompt := promptui.Select{ + Label: fmt.Sprintf("Bind this directory to profile '%s'? Commands run here will then select it automatically. Select[Yes/No]", profileName), + Items: []string{"No", "Yes"}, + } + _, result, err := prompt.Run() + if err != nil || result != "Yes" { + return + } + + util.SetDirectoryProfile(&configFile, cwd, profileName) + if err := util.WriteConfigFile(&configFile); err != nil { + util.PrintWarning(fmt.Sprintf("Unable to save the directory profile binding [err=%s]", err)) + return + } + util.PrintlnStderr(fmt.Sprintf("Directory %s now uses profile '%s'. Manage bindings with [infisical profile bind] and [infisical profile unbind].", cwd, profileName)) +} + func init() { RootCmd.AddCommand(initCmd) } diff --git a/packages/cmd/login.go b/packages/cmd/login.go index 873efce5..d2f288bb 100644 --- a/packages/cmd/login.go +++ b/packages/cmd/login.go @@ -48,10 +48,21 @@ const REPLACE_USER = "Override current logged in user" const EXIT_USER_MENU = "Exit" const QUIT_BROWSER_LOGIN = "q" +// mfaMaxAttempts is how many verification codes a user may try before a +// command gives up. Every MFA prompt in the CLI uses it, so they stay in step. +const mfaMaxAttempts = 5 + // loginCmd represents the login command var loginCmd = &cobra.Command{ - Use: "login", - Short: "Login into your Infisical account", + Use: "login", + Short: "Login into your Infisical account", + Long: `Login into your Infisical account. + +Sessions are stored in login profiles. Without options the profile is named +after the account and organization, as in scott@example.com--acme-x4k2. Use +--save-as to store the login under a name of your choice, creating that +profile or replacing its session, and --profile to sign back in to a +profile that already exists.`, DisableFlagsInUseLine: true, PreRunE: func(cmd *cobra.Command, args []string) error { // daniel: oidc-jwt is deprecated in favor of `jwt`. we backfill the `jwt` flag with the value of `oidc-jwt` if it's set. @@ -127,21 +138,52 @@ var loginCmd = &cobra.Command{ // standalone user auth if loginMethod == "user" { - isDirectUserLoginFlagsAndEnvsSet, err := validateDirectUserLoginFlagsAndEnvsSet(cmd, presetDomain) + existingConfig, err := util.GetMigratedConfigFile() + if err != nil { + util.HandleError(err, "Unable to read the Infisical config file") + } + + saveAs, err := cmd.Flags().GetString("save-as") + if err != nil { + util.HandleError(err) + } + profileOverride, profileOverrideSource := util.GetProfileOverride() + target, err := resolveLoginTarget(saveAs, profileOverride, profileOverrideSource, existingConfig) + if err != nil { + util.HandleError(err) + } + + // Signing back in to a profile takes its organization, so + // --organization-id is not required to drive the login + // non-interactively the way it is for a fresh one. + isDirectUserLoginFlagsAndEnvsSet, err := validateDirectUserLoginFlagsAndEnvsSet(cmd, presetDomain, target.reauth && target.profile.ScopedOrganizationID() != "") if err != nil { util.HandleError(err) } + // Login creates or refreshes a session and never scopes it through + // --org, so resolve the existing session with the override suspended: + // applying it here could only fail (unknown organization, MFA) and + // abort the login. + if _, orgSource := util.GetOrgOverride(); orgSource == util.OrgSourceFlag && !silentMode { + util.PrintWarning("--org does not apply to login. Pick the organization in the prompt, or pass --organization-id.") + } + restoreOrgOverride := util.SuspendOrgOverride() currentLoggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) - // if the key can't be found or there is an error getting current credentials from key ring, allow them to override - if err != nil && (strings.Contains(err.Error(), "we couldn't find your logged in details")) { + restoreOrgOverride() + // if the key can't be found, the selected profile doesn't exist yet, or + // there is an error getting current credentials from key ring, allow them to override + if err != nil && (errors.Is(err, util.ErrProfileNotFound) || errors.Is(err, util.ErrProfileDomainMismatch) || strings.Contains(err.Error(), "we couldn't find your logged in details")) { log.Debug().Err(err) } else if err != nil { util.HandleError(err) } - if currentLoggedInUserDetails.IsUserLoggedIn && !currentLoggedInUserDetails.LoginExpired && len(currentLoggedInUserDetails.UserCredentials.PrivateKey) != 0 { + // A login that names its profile (--save-as, or --profile for an + // existing one) is a deliberate write to that profile; skip the + // add/override menu. + if !target.explicit && currentLoggedInUserDetails.IsUserLoggedIn && !currentLoggedInUserDetails.LoginExpired && len(currentLoggedInUserDetails.UserCredentials.PrivateKey) != 0 { shouldOverride, err := userLoginMenu(currentLoggedInUserDetails.UserCredentials.Email) if err != nil { util.HandleError(err) @@ -154,7 +196,27 @@ var loginCmd = &cobra.Command{ domainFlagExplicitlySet := cmd.Flags().Changed("domain") shouldPrintInfo := !silentMode && !plainOutput - usePresetDomain, err := usePresetDomain(presetDomain, domainFlagExplicitlySet, shouldPrintInfo) + printPresetDomainInfo := shouldPrintInfo + + // Signing back in to a profile keeps it on its instance: the stored + // domain stands in for the flag, so no hosting prompt appears, and an + // explicitly requested different instance is refused rather than + // silently moving the profile there. + if target.reauth && target.profile.Domain != "" { + profileDomain := util.AppendAPIEndpoint(target.profile.Domain) + if config.INFISICAL_DOMAIN_EXPLICITLY_SET && util.AppendAPIEndpoint(presetDomain) != profileDomain { + util.PrintErrorMessageAndExit(fmt.Sprintf("Profile '%s' belongs to %s, but %s was requested. Sign in there as a new profile with --save-as , or move this profile with [infisical user update domain].", + target.name, util.DisplayDomain(profileDomain), util.DisplayDomain(presetDomain))) + } + presetDomain = profileDomain + domainFlagExplicitlySet = true + printPresetDomainInfo = false + if shouldPrintInfo { + util.PrintlnStderr(fmt.Sprintf("Signing back in to profile '%s' on %s.", target.name, util.DisplayDomain(profileDomain))) + } + } + + usePresetDomain, err := usePresetDomain(presetDomain, domainFlagExplicitlySet, printPresetDomainInfo) if err != nil { util.HandleError(err) @@ -217,17 +279,82 @@ var loginCmd = &cobra.Command{ var organizationId string + // Signing back in to a profile keeps its organization, so the + // profile supplies the default and neither the flag nor the + // picker is needed; an explicit --organization-id still wins. + profileOrgID := "" + if target.reauth { + profileOrgID = target.profile.ScopedOrganizationID() + } + if isDirectUserLoginFlagsAndEnvsSet { - organizationId, err = util.GetCmdFlagOrEnv(cmd, "organization-id", []string{"INFISICAL_ORGANIZATION_ID"}) + if profileOrgID != "" { + organizationId, err = util.GetCmdFlagOrEnvWithDefaultValue(cmd, "organization-id", []string{"INFISICAL_ORGANIZATION_ID"}, profileOrgID) + } else { + organizationId, err = util.GetCmdFlagOrEnv(cmd, "organization-id", []string{"INFISICAL_ORGANIZATION_ID"}) + } if err != nil { util.HandleError(err) } + } else if organizationId == "" { + organizationId = profileOrgID } cliDefaultLogin(&userCredentialsToBeStored, email, password, organizationId) } - err = util.StoreUserCredsInKeyRing(&userCredentialsToBeStored) + // The browser flow scopes the session to whatever organization the + // browser had selected. Signing back in to a profile keeps its + // organization, so re-scope the session when the two differ. + if wantOrg := target.profile.ScopedOrganizationID(); target.reauth && wantOrg != "" { + gotOrg, gotSubOrg := util.ParseTokenOrgClaims(userCredentialsToBeStored.JTWToken) + if gotSubOrg != "" { + gotOrg = gotSubOrg + } + if gotOrg != wantOrg { + rescopedToken, err := selectOrganizationToken(userCredentialsToBeStored.JTWToken, userCredentialsToBeStored.Email, wantOrg) + if err != nil { + util.HandleError(err, fmt.Sprintf("Unable to scope the session to the organization of profile '%s'. Run [infisical login] without --profile to pick another organization.", target.name)) + } + userCredentialsToBeStored.JTWToken = rescopedToken + } + } + + orgID, subOrgID := util.ParseTokenOrgClaims(userCredentialsToBeStored.JTWToken) + orgInfo := util.DescribeSessionOrg(userCredentialsToBeStored.JTWToken, orgID, subOrgID) + orgName := orgInfo.DisplayName() + + profileName := target.name + if profileName == "" { + profileName = util.DeriveProfileName(existingConfig, userCredentialsToBeStored.Email, config.INFISICAL_URL, orgID, orgName, orgInfo.Slug) + } + + if existingProfile, found := util.FindProfile(existingConfig, profileName); found && existingProfile.Email != userCredentialsToBeStored.Email { + if target.reauth { + // Signing back in means the same account. A different one is + // almost certainly a browser signed in as someone else, so do + // not quietly hand the profile to that account. + util.PrintErrorMessageAndExit(fmt.Sprintf("Profile '%s' belongs to %s, but you signed in as %s. Nothing was stored. To keep this login, run [infisical login --save-as ] with another name.", profileName, existingProfile.Email, userCredentialsToBeStored.Email)) + } + util.PrintWarning(fmt.Sprintf("Profile '%s' previously stored the session for %s and now stores the session for %s.", profileName, existingProfile.Email, userCredentialsToBeStored.Email)) + } + + // A login that names its profile (--save-as, or --profile) is a scoped + // write: it must not move the global default out from under other + // terminals that rely on it. This also keeps expired-session renewals + // (which re-exec login with --profile) from stealing the default. + // Untargeted logins keep the familiar "last login wins" behavior. + makeActive := !target.explicit + + err = util.PersistLoginProfile(models.Profile{ + Name: profileName, + Email: userCredentialsToBeStored.Email, + Domain: config.INFISICAL_URL, + OrganizationID: orgID, + OrganizationName: orgName, + OrganizationSlug: orgInfo.Slug, + SubOrganizationID: subOrgID, + }, &userCredentialsToBeStored, makeActive) if err != nil { log.Error().Msgf("Unable to store your credentials in system vault") log.Error().Msgf("\nTo trouble shoot further, read https://infisical.com/docs/cli/faq") @@ -236,11 +363,6 @@ var loginCmd = &cobra.Command{ util.HandleError(err) } - err = util.WriteInitalConfig(&userCredentialsToBeStored) - if err != nil { - util.HandleError(err, "Unable to write write to Infisical Config file. Please try again") - } - // Identify the user in PostHog and alias the anonymous machine ID // so that pre-login CLI events are merged into the same person record. // This call is idempotent (gated on LastIdentifiedEmail in the config), @@ -267,6 +389,20 @@ var loginCmd = &cobra.Command{ boldWhite.Printf(">>>> Welcome to Infisical!") boldWhite.Printf(" You are now logged in as %v <<<< \n", userCredentialsToBeStored.Email) + orgDetail := "" + if orgName != "" { + orgDetail = fmt.Sprintf(" (org %s)", orgName) + } + switch { + case target.reauth: + util.PrintlnStderr(fmt.Sprintf("Signed back in to profile '%s'%s.", profileName, orgDetail)) + case profileName != userCredentialsToBeStored.Email: + util.PrintlnStderr(fmt.Sprintf("Session saved to profile '%s'%s. Select it with --profile %s or INFISICAL_PROFILE=%s.", profileName, orgDetail, profileName, profileName)) + } + if configAfterLogin, err := util.GetConfigFile(); err == nil && configAfterLogin.ActiveProfile != "" && configAfterLogin.ActiveProfile != profileName { + util.PrintlnStderr(fmt.Sprintf("Your default profile remains '%s'; terminals using it are unaffected. Run [infisical profile use %s] to make '%s' the default.", configAfterLogin.ActiveProfile, profileName, profileName)) + } + plainBold := color.New(color.Bold) plainBold.Println("\nQuick links") @@ -337,7 +473,7 @@ func cliDefaultLogin(userCredentialsToBeStored *models.UserCredentials, email st if loginTwoResponse.MfaEnabled { i := 1 - for i < 6 { + for i <= mfaMaxAttempts { mfaVerifyCode := askForMFACode("email") httpClient, err := util.GetRestyClientWithCustomHeaders() @@ -355,9 +491,9 @@ func cliDefaultLogin(userCredentialsToBeStored *models.UserCredentials, email st break } else if mfaErrorResponse != nil { if mfaErrorResponse.Context.Code == "mfa_invalid" { - msg := fmt.Sprintf("Incorrect, verification code. You have %v attempts left", 5-i) + msg := fmt.Sprintf("Incorrect, verification code. You have %v attempts left", mfaMaxAttempts-i) util.PrintlnStderr(msg) - if i == 5 { + if i == mfaMaxAttempts { util.PrintErrorMessageAndExit("No tries left, please try again in a bit") break } @@ -424,6 +560,7 @@ func init() { loginCmd.Flags().String("email", "", "email for 'user' login method") loginCmd.Flags().String("password", "", "password for 'user' login method") loginCmd.Flags().String("organization-id", "", "organization id for 'user' login method") + loginCmd.Flags().String("save-as", "", "store this login as the named profile, creating it or replacing its session ('user' login method). Use --profile to sign back in to an existing profile instead.") loginCmd.Flags().MarkDeprecated("oidc-jwt", "use --jwt instead") @@ -707,73 +844,71 @@ func getFreshUserCredentialsWithSrp(email string, password string) (*api.GetLogi func GetJwtTokenWithOrganizationId(oldJwtToken string, email string, organizationId string) string { log.Debug().Msg(fmt.Sprint("GetJwtTokenWithOrganizationId: ", "oldJwtToken", oldJwtToken)) - httpClient, err := util.GetRestyClientWithCustomHeaders() - if err != nil { - util.HandleError(err, "Unable to get resty client with custom headers") - } - httpClient.SetAuthToken(oldJwtToken) - selectedOrganizationId := organizationId if selectedOrganizationId == "" { + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + util.HandleError(err, "Unable to get resty client with custom headers") + } + httpClient.SetAuthToken(oldJwtToken) + selectedOrganizationId, _, err = pickOrganization(httpClient, "Which Infisical organization would you like to log into?", email) if err != nil { util.HandleError(err, "Unable to select organization") } } - selectedOrgRes, err := api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrganizationId}) + // The exchange, MFA prompt included, is shared with [profile set-org] and + // [profile new], so the flows cannot drift apart. + token, err := selectOrganizationToken(oldJwtToken, email, selectedOrganizationId) if err != nil { - util.HandleError(err) + util.HandleError(err, "Unable to select organization") } - if selectedOrgRes.MfaEnabled { - i := 1 - for i < 6 { - mfaVerifyCode := askForMFACode(selectedOrgRes.MfaMethod) + return token +} - httpClient, err := util.GetRestyClientWithCustomHeaders() - if err != nil { - util.HandleError(err, "Unable to get resty client with custom headers") - } - httpClient.SetAuthToken(selectedOrgRes.Token) - verifyMFAresponse, mfaErrorResponse, requestError := api.CallVerifyMfaToken(httpClient, api.VerifyMfaTokenRequest{ - Email: email, - MFAToken: mfaVerifyCode, - MFAMethod: selectedOrgRes.MfaMethod, - }) - if requestError != nil { - util.HandleError(err) - break - } else if mfaErrorResponse != nil { - if mfaErrorResponse.Context.Code == "mfa_invalid" { - msg := fmt.Sprintf("Incorrect, verification code. You have %v attempts left", 5-i) - util.PrintlnStderr(msg) - if i == 5 { - util.PrintErrorMessageAndExit("No tries left, please try again in a bit") - break - } - } +// loginTarget says which profile a login writes to and how it was chosen. +type loginTarget struct { + // name is empty when the profile is derived from the account after login. + name string + // reauth is true when --profile named an existing profile, which is signed + // back in to: same account, same instance, same organization. + reauth bool + profile models.Profile + // explicit is true when the user named the profile with either flag, which + // makes the login a scoped write that leaves the machine default alone. + explicit bool +} - if mfaErrorResponse.Context.Code == "mfa_expired" { - util.PrintErrorMessageAndExit("Your 2FA verification code has expired, please try logging in again") - break - } - i++ - } else { - httpClient.SetAuthToken(verifyMFAresponse.Token) - selectedOrgRes, err = api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrganizationId}) - break - } +// resolveLoginTarget applies the two ways to name a profile on login. --save-as +// stores the login under that name, creating the profile or replacing its +// session. --profile (or INFISICAL_PROFILE) signs back in to a profile that +// already exists; naming a missing one is refused rather than quietly creating +// it, since everywhere else --profile only selects. An ambient INFISICAL_PROFILE +// does not conflict with --save-as, but the two flags naming different +// profiles do. +func resolveLoginTarget(saveAs string, override string, overrideSource string, configFile models.ConfigFile) (loginTarget, error) { + if saveAs != "" { + if err := util.ValidateProfileName(saveAs); err != nil { + return loginTarget{}, err } + if overrideSource == util.ProfileSourceFlag && override != saveAs { + return loginTarget{}, fmt.Errorf("--profile and --save-as name different profiles ('%s' and '%s'). Use --profile to sign back in to an existing profile, or --save-as to store this login under a name, not both", override, saveAs) + } + return loginTarget{name: saveAs, explicit: true}, nil } - if err != nil { - util.HandleError(err, "Unable to select organization") + if override == "" { + return loginTarget{}, nil } - return selectedOrgRes.Token - + profile, found := util.FindProfile(configFile, override) + if !found { + return loginTarget{}, fmt.Errorf("profile '%s' (selected via %s) does not exist. --profile signs back in to an existing profile; to store this login under that name run [infisical login --save-as %s]", override, overrideSource, override) + } + return loginTarget{name: override, reauth: true, profile: profile, explicit: true}, nil } func userLoginMenu(currentLoggedInUserEmail string) (bool, error) { @@ -991,11 +1126,15 @@ func browserLoginHandler(success chan models.UserCredentials, failure chan error } // check if one of the flag or all the envs are set -func validateDirectUserLoginFlagsAndEnvsSet(cmd *cobra.Command, domain string) (isDirectUserLogin bool, err error) { +// orgIDOptional is set when the login already knows which organization to use, +// which is the case when signing back in to an existing profile. +func validateDirectUserLoginFlagsAndEnvsSet(cmd *cobra.Command, domain string, orgIDOptional bool) (isDirectUserLogin bool, err error) { requiredFlagsEnvs := map[string]string{ - "email": "INFISICAL_EMAIL", - "password": "INFISICAL_PASSWORD", - "organization-id": "INFISICAL_ORGANIZATION_ID", + "email": "INFISICAL_EMAIL", + "password": "INFISICAL_PASSWORD", + } + if !orgIDOptional { + requiredFlagsEnvs["organization-id"] = "INFISICAL_ORGANIZATION_ID" } var missingFlagsEnvs []string diff --git a/packages/cmd/login_status.go b/packages/cmd/login_status.go index 1c269f6a..fc9c2b8b 100644 --- a/packages/cmd/login_status.go +++ b/packages/cmd/login_status.go @@ -261,18 +261,23 @@ type loginStatusJSONOutput struct { } type loginStatusSessionJSON struct { - PrincipalType string `json:"principalType,omitempty"` - Status string `json:"status,omitempty"` - Domain string `json:"domain,omitempty"` - Email string `json:"email,omitempty"` - UserID string `json:"userId,omitempty"` - AuthMethod string `json:"authMethod,omitempty"` - TokenSource string `json:"tokenSource,omitempty"` - Identity *loginStatusIdentityJSON `json:"identity,omitempty"` - Token *loginStatusTokenJSON `json:"token,omitempty"` - Organization *string `json:"organization,omitempty"` - SubOrganization *string `json:"subOrganization,omitempty"` - Verification *loginStatusVerificationJSON `json:"verification,omitempty"` + PrincipalType string `json:"principalType,omitempty"` + Status string `json:"status,omitempty"` + Domain string `json:"domain,omitempty"` + // Profile and ProfileSource describe which login profile the user session + // came from and how it was selected (flag, env var, directory, default). + Profile string `json:"profile,omitempty"` + ProfileSource string `json:"profileSource,omitempty"` + Email string `json:"email,omitempty"` + UserID string `json:"userId,omitempty"` + AuthMethod string `json:"authMethod,omitempty"` + TokenSource string `json:"tokenSource,omitempty"` + Identity *loginStatusIdentityJSON `json:"identity,omitempty"` + Token *loginStatusTokenJSON `json:"token,omitempty"` + Organization *string `json:"organization,omitempty"` + OrganizationName string `json:"organizationName,omitempty"` + SubOrganization *string `json:"subOrganization,omitempty"` + Verification *loginStatusVerificationJSON `json:"verification,omitempty"` } type loginStatusTokenJSON struct { @@ -310,6 +315,9 @@ func buildSessionJSON(ctx loginStatusContext) loginStatusSessionJSON { switch ctx.kind { case principalKindUser: session.Email = ctx.loggedInUser.UserCredentials.Email + session.Profile = ctx.loggedInUser.ProfileName + session.ProfileSource = ctx.loggedInUser.ProfileSource + session.OrganizationName = ctx.loggedInUser.OrganizationName if ctx.claimsErr != nil { return session } @@ -385,6 +393,9 @@ func renderHuman(ctx loginStatusContext) { if ctx.domain != "" { printStatusItem("Domain", ctx.domain) } + if ctx.kind == principalKindUser && ctx.loggedInUser.ProfileName != "" { + printStatusItem("Profile", profileLine(ctx.loggedInUser)) + } if method := authMethodLabel(ctx); method != "" { printStatusItem("Auth method", method) } @@ -403,8 +414,11 @@ func renderHuman(ctx loginStatusContext) { if org := organizationLineFor(ctx); org != "" { printStatusItem("Organization", org) } + if ctx.kind == principalKindUser && ctx.loggedInUser.OrganizationSource != "" && ctx.loggedInUser.OrganizationSource != util.OrgSourceProfileDefault { + printStatusItem("Organization via", ctx.loggedInUser.OrganizationSource) + } if ctx.kind == principalKindUser && ctx.claimsErr == nil && ctx.claims.SubOrganizationID != "" { - printStatusItem("Sub-organization", ctx.claims.SubOrganizationID) + printStatusItem("Sub-organization", subOrganizationLineFor(ctx)) } if status != statusAuthenticated { @@ -471,16 +485,45 @@ func tokenSourceLabel(ctx loginStatusContext) string { return "" } +// profileLine renders the profile a user session came from and how it was +// selected, e.g. "globex (via --profile flag)". +func profileLine(details util.LoggedInUserDetails) string { + if details.ProfileSource == "" { + return details.ProfileName + } + return fmt.Sprintf("%s (via %s)", details.ProfileName, details.ProfileSource) +} + +// userOrgNames splits the organization name the profile recorded into the +// root and sub-organization parts that match the token's organizationId and +// subOrganizationId claims. The token only carries ids; the profile knows the +// names, as "Parent / Child" when the session is scoped to a sub-organization. +func userOrgNames(ctx loginStatusContext) (rootName string, subName string) { + return splitScopedOrgNames(ctx.loggedInUser.OrganizationName, ctx.claims.SubOrganizationID != "") +} + func organizationLineFor(ctx loginStatusContext) string { switch ctx.kind { case principalKindUser: - return orgStatusLine(ctx.claims.OrganizationID, ctx.claimsErr) + line := orgStatusLine(ctx.claims.OrganizationID, ctx.claimsErr) + if rootName, _ := userOrgNames(ctx); ctx.claimsErr == nil && ctx.claims.OrganizationID != "" && rootName != "" { + line = fmt.Sprintf("%s (%s)", rootName, line) + } + return line case principalKindMachineIdentity: return orgStatusLine(ctx.claims.OrgID, ctx.claimsErr) } return "" } +func subOrganizationLineFor(ctx loginStatusContext) string { + line := ctx.claims.SubOrganizationID + if _, subName := userOrgNames(ctx); subName != "" { + line = fmt.Sprintf("%s (%s)", subName, line) + } + return line +} + func tokenStatusLine(claims loginTokenClaims, claimsErr error) string { if claimsErr != nil { return "unknown (could not parse token)" diff --git a/packages/cmd/login_status_test.go b/packages/cmd/login_status_test.go index d5e3dc9d..a2574253 100644 --- a/packages/cmd/login_status_test.go +++ b/packages/cmd/login_status_test.go @@ -468,3 +468,71 @@ func makeUnsignedJWT(t *testing.T, claims map[string]any) string { enc := base64.RawURLEncoding return enc.EncodeToString(headerJSON) + "." + enc.EncodeToString(payloadJSON) + "." } + +func TestBuildSessionJSON_UserIncludesProfile(t *testing.T) { + ctx := loginStatusContext{ + kind: principalKindUser, + loggedInUser: util.LoggedInUserDetails{ + ProfileName: "globex", + ProfileSource: util.ProfileSourceFlag, + OrganizationName: "Globex", + }, + } + + session := buildSessionJSON(ctx) + if session.Profile != "globex" || session.ProfileSource != util.ProfileSourceFlag { + t.Fatalf("profile fields missing from JSON: %+v", session) + } + if session.OrganizationName != "Globex" { + t.Fatalf("organization name missing from JSON: %+v", session) + } + + machine := buildSessionJSON(loginStatusContext{kind: principalKindMachineIdentity}) + if machine.Profile != "" || machine.ProfileSource != "" { + t.Fatalf("machine identity sessions have no profile: %+v", machine) + } +} + +func TestProfileLine(t *testing.T) { + withSource := profileLine(util.LoggedInUserDetails{ProfileName: "globex", ProfileSource: util.ProfileSourceEnv}) + if withSource != "globex (via INFISICAL_PROFILE environment variable)" { + t.Fatalf("unexpected profile line %q", withSource) + } + if got := profileLine(util.LoggedInUserDetails{ProfileName: "globex"}); got != "globex" { + t.Fatalf("expected the bare name without a source, got %q", got) + } +} + +func TestUserOrgNames(t *testing.T) { + root := loginStatusContext{ + kind: principalKindUser, + loggedInUser: util.LoggedInUserDetails{OrganizationName: "Acme"}, + claims: loginTokenClaims{OrganizationID: "root-1"}, + } + if rootName, subName := userOrgNames(root); rootName != "Acme" || subName != "" { + t.Fatalf("root session: got %q, %q", rootName, subName) + } + + sub := loginStatusContext{ + kind: principalKindUser, + loggedInUser: util.LoggedInUserDetails{OrganizationName: "Acme / Research"}, + claims: loginTokenClaims{OrganizationID: "root-1", SubOrganizationID: "sub-1"}, + } + if rootName, subName := userOrgNames(sub); rootName != "Acme" || subName != "Research" { + t.Fatalf("sub-organization session: got %q, %q", rootName, subName) + } + if got := organizationLineFor(sub); got != "Acme (root-1)" { + t.Fatalf("organization line = %q", got) + } + if got := subOrganizationLineFor(sub); got != "Research (sub-1)" { + t.Fatalf("sub-organization line = %q", got) + } + + // A sub-organization session whose recorded name is not split cannot be + // attributed to either id, so only the ids are shown. + unsplit := sub + unsplit.loggedInUser.OrganizationName = "Research" + if got := organizationLineFor(unsplit); got != "root-1" { + t.Fatalf("organization line without a split name = %q", got) + } +} diff --git a/packages/cmd/login_target_test.go b/packages/cmd/login_target_test.go new file mode 100644 index 00000000..f3f3a616 --- /dev/null +++ b/packages/cmd/login_target_test.go @@ -0,0 +1,96 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/Infisical/infisical-merge/packages/models" + "github.com/Infisical/infisical-merge/packages/util" +) + +func TestResolveLoginTarget(t *testing.T) { + configFile := models.ConfigFile{ + Profiles: []models.Profile{ + {Name: "work", Email: "a@x.com", Domain: "https://eu.infisical.com/api", OrganizationID: "org-1"}, + }, + } + + t.Run("no flags derive the name after login and may move the default", func(t *testing.T) { + target, err := resolveLoginTarget("", "", "", configFile) + if err != nil { + t.Fatal(err) + } + if target.name != "" || target.explicit || target.reauth { + t.Fatalf("expected an untargeted login, got %+v", target) + } + }) + + t.Run("--save-as names a new profile without moving the default", func(t *testing.T) { + target, err := resolveLoginTarget("client-b", "", "", configFile) + if err != nil { + t.Fatal(err) + } + if target.name != "client-b" || !target.explicit || target.reauth { + t.Fatalf("unexpected target %+v", target) + } + }) + + t.Run("--save-as an existing profile replaces it rather than signing back in", func(t *testing.T) { + target, err := resolveLoginTarget("work", "", "", configFile) + if err != nil { + t.Fatal(err) + } + if target.name != "work" || target.reauth { + t.Fatalf("unexpected target %+v", target) + } + }) + + t.Run("--save-as validates the name", func(t *testing.T) { + if _, err := resolveLoginTarget("bad name!", "", "", configFile); err == nil { + t.Fatal("expected an invalid name to be rejected") + } + }) + + t.Run("--profile signs back in to an existing profile", func(t *testing.T) { + target, err := resolveLoginTarget("", "work", util.ProfileSourceFlag, configFile) + if err != nil { + t.Fatal(err) + } + if !target.reauth || !target.explicit || target.name != "work" || target.profile.Domain != "https://eu.infisical.com/api" { + t.Fatalf("unexpected target %+v", target) + } + }) + + t.Run("--profile for a missing profile is refused with a --save-as hint", func(t *testing.T) { + _, err := resolveLoginTarget("", "missing", util.ProfileSourceEnv, configFile) + if err == nil || !strings.Contains(err.Error(), "--save-as missing") || !strings.Contains(err.Error(), util.ProfileSourceEnv) { + t.Fatalf("expected a refusal naming the source and the fix, got %v", err) + } + }) + + t.Run("the --profile flag and --save-as naming different profiles conflict", func(t *testing.T) { + if _, err := resolveLoginTarget("other", "work", util.ProfileSourceFlag, configFile); err == nil { + t.Fatal("expected a conflict error") + } + }) + + t.Run("an ambient INFISICAL_PROFILE does not conflict with --save-as", func(t *testing.T) { + target, err := resolveLoginTarget("other", "work", util.ProfileSourceEnv, configFile) + if err != nil { + t.Fatal(err) + } + if target.name != "other" || target.reauth { + t.Fatalf("expected --save-as to win over the pinned profile, got %+v", target) + } + }) + + t.Run("both flags naming the same profile save it", func(t *testing.T) { + target, err := resolveLoginTarget("work", "work", util.ProfileSourceFlag, configFile) + if err != nil { + t.Fatal(err) + } + if target.name != "work" || target.reauth || !target.explicit { + t.Fatalf("unexpected target %+v", target) + } + }) +} diff --git a/packages/cmd/logout.go b/packages/cmd/logout.go new file mode 100644 index 00000000..1d603b2c --- /dev/null +++ b/packages/cmd/logout.go @@ -0,0 +1,98 @@ +/* +Copyright (c) 2023 Infisical Inc. +*/ +package cmd + +import ( + "fmt" + + "github.com/Infisical/infisical-merge/packages/util" + "github.com/posthog/posthog-go" + "github.com/spf13/cobra" +) + +var logoutCmd = &cobra.Command{ + Use: "logout", + Short: "End a login session and revoke it on the server", + Long: `End a login session. + +The session is revoked on the server and its credentials are removed from this +machine. The profile itself is kept, so [infisical login --profile ] signs +back in without setting it up again. + +Several profiles for the same account on the same machine share one server +session. A session another profile still uses is left intact, and only this +profile's stored credentials are removed.`, + DisableFlagsInUseLine: true, + Example: "infisical logout\ninfisical logout --profile globex\ninfisical logout --all", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + all, err := cmd.Flags().GetBool("all") + if err != nil { + util.HandleError(err) + } + localOnly, err := cmd.Flags().GetBool("local-only") + if err != nil { + util.HandleError(err) + } + + configFile, err := util.GetMigratedConfigFile() + if err != nil { + util.HandleError(err, "Unable to read the Infisical config file") + } + + if len(configFile.Profiles) == 0 { + util.PrintlnStderr("No login profiles found, so there is nothing to log out of.") + return + } + + var targetNames []string + if all { + for _, profile := range configFile.Profiles { + targetNames = append(targetNames, profile.Name) + } + } else { + resolved := util.ResolveProfile(configFile) + if resolved.Name == "" { + util.PrintErrorMessageAndExit("No profile is selected. Pass --profile , or --all to log out of every profile.") + } + if _, found := util.FindProfile(configFile, resolved.Name); !found { + util.PrintErrorMessageAndExit(fmt.Sprintf("Profile '%s' does not exist. Run [infisical profile list] to see available profiles.", resolved.Name)) + } + targetNames = []string{resolved.Name} + } + + // The domain follows the profile, so revocation must reach the instance + // that issued the session rather than whatever default is configured. + results := util.LogoutProfilesAcrossDomains(configFile, targetNames, localOnly) + + for _, result := range results { + switch { + case !result.HadSession: + util.PrintlnStderr(fmt.Sprintf("Profile '%s' had no stored session.", result.ProfileName)) + case result.SharedWith != "": + util.PrintlnStderr(fmt.Sprintf("Removed the stored session for profile '%s'. The server session is still used by profile '%s', so it was left active.", result.ProfileName, result.SharedWith)) + case result.RevokeErr != nil: + util.PrintWarning(fmt.Sprintf("Removed the stored session for profile '%s', but could not revoke it on the server [err=%s]. It stays valid until it expires; you can revoke it from the web app.", result.ProfileName, result.RevokeErr)) + case result.Revoked: + util.PrintlnStderr(fmt.Sprintf("Logged out of profile '%s' and revoked its session on the server.", result.ProfileName)) + default: + util.PrintlnStderr(fmt.Sprintf("Removed the stored session for profile '%s'.", result.ProfileName)) + } + + if result.LocalErr != nil { + util.PrintWarning(fmt.Sprintf("Unable to remove the stored credentials for profile '%s' [err=%s]", result.ProfileName, result.LocalErr)) + } + } + + util.PrintlnStderr("\nProfiles are kept so you can sign back in with [infisical login --profile ]. Remove one entirely with [infisical profile delete ].") + + Telemetry.CaptureEvent("cli-command:logout", posthog.NewProperties().Set("all", all).Set("localOnly", localOnly).Set("version", util.CLI_VERSION)) + }, +} + +func init() { + logoutCmd.Flags().Bool("all", false, "log out of every profile on this machine") + logoutCmd.Flags().Bool("local-only", false, "remove the stored credentials without revoking the session on the server") + RootCmd.AddCommand(logoutCmd) +} diff --git a/packages/cmd/org.go b/packages/cmd/org.go new file mode 100644 index 00000000..e0f900fd --- /dev/null +++ b/packages/cmd/org.go @@ -0,0 +1,296 @@ +/* +Copyright (c) 2023 Infisical Inc. +*/ +package cmd + +import ( + "fmt" + "text/tabwriter" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/posthog/posthog-go" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +var orgCmd = &cobra.Command{ + Use: "org", + Short: "List organizations and change which one your profile uses", + Long: `List organizations and change which one your profile uses. + +The organization is a setting on your login profile, not a separate login. Use +[infisical profile current] to see the profile and organization in effect, and +--org on any command to use a different organization just for that command.`, + DisableFlagsInUseLine: true, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +var orgListCmd = &cobra.Command{ + Use: "list", + Short: "List the organizations this profile's account can use", + DisableFlagsInUseLine: true, + Example: "infisical org list", + Args: cobra.NoArgs, + PreRun: func(cmd *cobra.Command, args []string) { + util.RequireLogin() + }, + Run: func(cmd *cobra.Command, args []string) { + details := requireUserSession() + + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + util.HandleError(err, "Unable to get resty client with custom headers") + } + httpClient.SetAuthToken(details.UserCredentials.JTWToken) + + // The token's own claims say what it is scoped to. A sub-organization + // session carries the root in organizationId and the sub-organization + // in subOrganizationId, and acts in the latter, so that is the one to + // mark as current. + currentOrgID := details.OrganizationID + if claimOrgID, claimSubOrgID := util.ParseTokenOrgClaims(details.UserCredentials.JTWToken); claimSubOrgID != "" { + currentOrgID = claimSubOrgID + } else if claimOrgID != "" { + currentOrgID = claimOrgID + } + + writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(writer, "CURRENT\tNAME\tSLUG\tID") + + marker := func(id string) string { + if id == currentOrgID { + return "*" + } + return "" + } + + // The sub-org aware listing carries slugs and nested organizations. + // Older instances may not have it, so fall back to the flat list. + if subOrgsResp, err := api.CallGetAllOrganizationsWithSubOrgs(httpClient); err == nil && len(subOrgsResp.Organizations) > 0 { + for _, org := range subOrgsResp.Organizations { + fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n", marker(org.ID), util.SanitizeDisplay(org.Name), util.SanitizeDisplay(org.Slug), org.ID) + for _, sub := range org.SubOrganizations { + fmt.Fprintf(writer, "%s\t └─ %s\t%s\t%s\n", marker(sub.ID), util.SanitizeDisplay(sub.Name), util.SanitizeDisplay(sub.Slug), sub.ID) + } + } + } else { + orgResp, err := api.CallGetAllOrganizations(httpClient) + if err != nil { + util.HandleError(err, "Unable to list your organizations") + } + for _, org := range orgResp.Organizations { + fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n", marker(org.ID), util.SanitizeDisplay(org.Name), "", org.ID) + } + } + writer.Flush() + + util.PrintlnStderr(fmt.Sprintf("\nProfile '%s' currently uses the organization marked above. Change it with [infisical profile set-org ], or use another one for a single command with --org .", details.ProfileName)) + + Telemetry.CaptureEvent("cli-command:org list", posthog.NewProperties().Set("version", util.CLI_VERSION)) + }, +} + +// newSetOrgCommand builds the command that changes a profile's default +// organization. It is registered twice, as [infisical profile set-org] (the +// canonical name, which says what it changes) and as [infisical org switch] +// (the name people reach for), so both lead to the same behavior. +// newSetOrgCommand builds the set-org command. invocation is the full command +// path as a user types it ("profile set-org" / "org switch"), used for examples. +func newSetOrgCommand(use string, invocation string, short string) *cobra.Command { + command := &cobra.Command{ + Use: use, + Short: short, + Long: `Set the organization a profile uses by default. + +This changes a setting on the profile, so it persists for future commands. To +use a different organization for a single command instead, pass --org, and to +keep a second organization available as its own profile use +[infisical profile new].`, + DisableFlagsInUseLine: true, + Example: fmt.Sprintf("infisical %s\ninfisical %s globex", invocation, invocation), + Args: cobra.MaximumNArgs(1), + PreRun: func(cmd *cobra.Command, args []string) { + util.RequireLogin() + }, + Run: runSetOrg, + } + + command.Flags().String("org-id", "", "the id of the organization to use (deprecated, pass the organization as an argument instead)") + return command +} + +func runSetOrg(cmd *cobra.Command, args []string) { + orgIDFlag, err := cmd.Flags().GetString("org-id") + if err != nil { + util.HandleError(err) + } + // This command does its own organization exchange, which can prompt for + // MFA, so resolve the session with the --org override suspended. + globalOrgSelector, _ := util.GetOrgOverride() + restoreOrgOverride := util.SuspendOrgOverride() + details := requireUserSession() + restoreOrgOverride() + + // The organization can come from a positional argument (name, slug, or id), + // the deprecated --org-id flag, the global --org flag, or the picker. + selector := orgIDFlag + if selector == "" { + selector = globalOrgSelector + } + if len(args) == 1 { + selector = args[0] + } + + var selectedOrgID string + if selector != "" { + resolvedOrg, err := util.ResolveOrgSelector(details.UserCredentials.JTWToken, selector) + if err != nil { + util.HandleError(err) + } + selectedOrgID = resolvedOrg.ID + } else { + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + util.HandleError(err, "Unable to get resty client with custom headers") + } + httpClient.SetAuthToken(details.UserCredentials.JTWToken) + + selectedOrgID, _, err = pickOrganization(httpClient, fmt.Sprintf("Which organization should profile '%s' use?", details.ProfileName), details.UserCredentials.Email) + if err != nil { + util.HandleError(err, "Unable to select organization") + } + } + + newSessionToken, err := selectOrganizationToken(details.UserCredentials.JTWToken, details.UserCredentials.Email, selectedOrgID) + if err != nil { + util.HandleError(err, "Unable to change organization") + } + + orgID, subOrgID := util.ParseTokenOrgClaims(newSessionToken) + if orgID == "" { + orgID = selectedOrgID + } + orgInfo := util.DescribeSessionOrg(newSessionToken, orgID, subOrgID) + orgName := orgInfo.DisplayName() + + profile := details.Profile + profile.OrganizationID = orgID + profile.OrganizationName = orgName + profile.OrganizationSlug = orgInfo.Slug + profile.SubOrganizationID = subOrgID + + // A session cached for this organization by --org is redundant now that it + // is the profile's own, so drop it together with its keyring entry. + if util.RemoveOrgSession(&profile, profile.ScopedOrganizationID()) { + if err := util.DeleteOrgSessionToken(profile.Name, profile.ScopedOrganizationID()); err != nil { + log.Debug().Err(err).Msg("unable to remove the now redundant cached organization session") + } + } + + credentials := details.UserCredentials + credentials.JTWToken = newSessionToken + + // Only move the global default when this invocation was using it; a + // terminal pinned via env var, flag, or directory scope must not switch + // other terminals. + makeActive := details.ProfileSource == util.ProfileSourceDefault + if err := util.PersistLoginProfile(profile, &credentials, makeActive); err != nil { + util.HandleError(err, "Unable to store your user credentials") + } + + orgDisplay := orgName + if orgDisplay == "" { + orgDisplay = orgID + } + + util.PrintlnStderr(fmt.Sprintf("Profile '%s' now uses organization %s by default.", profile.Name, orgDisplay)) + util.PrintlnStderr(fmt.Sprintf("To keep both organizations available at once, create a second profile with [infisical profile new --org %s].", orgDisplay)) + if !makeActive { + util.PrintlnStderr(fmt.Sprintf("This shell selects its profile via the %s. Use --profile %s or INFISICAL_PROFILE=%s to target the updated profile here.", details.ProfileSource, profile.Name, profile.Name)) + } + + Telemetry.CaptureEvent("cli-command:org switch", posthog.NewProperties().Set("version", util.CLI_VERSION)) +} + +// requireUserSession loads the resolved profile's session, triggering the +// interactive login flow when it is missing or expired. +func requireUserSession() util.LoggedInUserDetails { + details, err := util.GetCurrentLoggedInUserDetails(true) + if err != nil { + util.HandleError(err, "Unable to get your login details") + } + if details.LoginExpired { + details = util.EstablishUserLoginSession() + } + return details +} + +// selectOrganizationToken exchanges the given session token for one scoped to +// orgID, walking the user through MFA when the organization requires it. +func selectOrganizationToken(sessionToken string, email string, orgID string) (string, error) { + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + return "", fmt.Errorf("unable to get resty client with custom headers [err=%w]", err) + } + httpClient.SetAuthToken(sessionToken) + + tokenResponse, err := api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: orgID}) + if err != nil { + return "", err + } + + if tokenResponse.MfaEnabled { + i := 1 + for i <= mfaMaxAttempts { + mfaVerifyCode := askForMFACode(tokenResponse.MfaMethod) + + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + return "", fmt.Errorf("unable to get resty client with custom headers [err=%w]", err) + } + httpClient.SetAuthToken(tokenResponse.Token) + verifyMFAresponse, mfaErrorResponse, requestError := api.CallVerifyMfaToken(httpClient, api.VerifyMfaTokenRequest{ + Email: email, + MFAToken: mfaVerifyCode, + MFAMethod: tokenResponse.MfaMethod, + }) + if requestError != nil { + return "", requestError + } else if mfaErrorResponse != nil { + if mfaErrorResponse.Context.Code == "mfa_invalid" { + msg := fmt.Sprintf("Incorrect, verification code. You have %v attempts left", mfaMaxAttempts-i) + util.PrintlnStderr(msg) + if i == mfaMaxAttempts { + util.PrintErrorMessageAndExit("No tries left, please try again in a bit") + break + } + } + + if mfaErrorResponse.Context.Code == "mfa_expired" { + util.PrintErrorMessageAndExit("Your 2FA verification code has expired, please try logging in again") + break + } + i++ + } else { + httpClient.SetAuthToken(verifyMFAresponse.Token) + tokenResponse, err = api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: orgID}) + if err != nil { + return "", err + } + break + } + } + } + + return tokenResponse.Token, nil +} + +func init() { + orgCmd.AddCommand(orgListCmd) + orgCmd.AddCommand(newSetOrgCommand("switch [org]", "org switch", "Change the organization this profile uses (same as [infisical profile set-org])")) + RootCmd.AddCommand(orgCmd) +} diff --git a/packages/cmd/profile.go b/packages/cmd/profile.go new file mode 100644 index 00000000..6070f223 --- /dev/null +++ b/packages/cmd/profile.go @@ -0,0 +1,759 @@ +/* +Copyright (c) 2023 Infisical Inc. +*/ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "text/tabwriter" + + "github.com/Infisical/infisical-merge/packages/config" + "github.com/Infisical/infisical-merge/packages/models" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/mattn/go-isatty" + "github.com/posthog/posthog-go" + "github.com/spf13/cobra" +) + +var profileCmd = &cobra.Command{ + Use: "profile", + Short: "Manage login profiles for working across organizations and instances", + Long: `Manage login profiles. + +A profile is one login: an account on one instance, plus the organization it +uses by default. Selecting a profile selects all three, so switching tenants +never means logging in again. + +Create the first one with [infisical login], one per extra organization with +[infisical profile new], and one per extra account or instance with +[infisical login --save-as ]. Sign back in to an existing profile with +[infisical login --profile ]. + +Which profile a command uses is decided in this order: + 1. --profile on the command + 2. the INFISICAL_PROFILE environment variable ([infisical profile pin]) + 3. a directory bound with [infisical profile bind] + 4. the default profile ([infisical profile use]) + +The organization is a setting on the profile, changed with +[infisical profile set-org] or overridden for one command with --org.`, + DisableFlagsInUseLine: true, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +// shellOutputIsCaptured reports whether stdout is being read by something +// rather than shown on screen. Commands that work by printing shell statements +// only take effect when the caller captures them, as in eval "$(...)"; a +// terminal on stdout means the statement was displayed and nothing changed. +func shellOutputIsCaptured() bool { + return !isatty.IsTerminal(os.Stdout.Fd()) +} + +// requireShellCapture stops a shell-mutating command that was run bare, and +// shows the form that actually works, rather than reporting a success that did +// not happen. manualHint covers shells that cannot evaluate the statement. +func requireShellCapture(invocation string, manualHint string) { + if shellOutputIsCaptured() { + return + } + util.PrintlnStderr(fmt.Sprintf("This command works by printing a shell statement, so it only takes effect when the shell reads it:\n\n eval \"$(%s)\"\n\nNothing has been changed. Tip: add a shell alias if you use this often.\n%s", invocation, manualHint)) + os.Exit(1) +} + +// manualPinHint explains how to select a profile without eval: PowerShell and +// cmd.exe cannot evaluate the printed statement, and in scripts or CI setting +// the variable directly is the reliable choice. +func manualPinHint(profileName string) string { + env := util.INFISICAL_PROFILE_ENV_NAME + return fmt.Sprintf("Without eval, set the variable yourself: in PowerShell run [$env:%s = %s], in cmd.exe [set %s=%s], and in scripts or CI export %s=%s (or pass --profile %s) before running commands.", + env, util.ShellQuote(profileName), env, profileName, env, profileName, profileName) +} + +// manualUnpinHint is the counterpart of manualPinHint for removing a pin. +func manualUnpinHint() string { + env := util.INFISICAL_PROFILE_ENV_NAME + return fmt.Sprintf("Without eval, clear the variable yourself: in PowerShell run [Remove-Item Env:%s], in cmd.exe [set %s=], and in scripts unset %s.", env, env, env) +} + +// splitScopedOrgNames attributes a recorded organization display name to the +// root and sub-organization ids of a session. Sub-organization sessions record +// "Parent / Child"; a name that is not split cannot be attributed to either id +// with confidence, so nothing is returned for it. +func splitScopedOrgNames(displayName string, scopedToSubOrg bool) (rootName string, subName string) { + parent, own := util.SplitOrgDisplayName(displayName) + if !scopedToSubOrg { + return own, "" + } + if parent == "" { + return "", "" + } + return parent, own +} + +// labelWithID renders "Name (id)", falling back to whichever part is known. +func labelWithID(name string, id string) string { + switch { + case name != "" && id != "": + return fmt.Sprintf("%s (%s)", name, id) + case name != "": + return name + default: + return id + } +} + +// orgLabel renders a profile's default organization for humans. +func orgLabel(profile models.Profile) string { + if profile.OrganizationName != "" { + return profile.OrganizationName + } + if profile.OrganizationID != "" { + return profile.OrganizationID + } + return "not set" +} + +var profileListCmd = &cobra.Command{ + Use: "list", + Short: "List all login profiles", + DisableFlagsInUseLine: true, + Example: "infisical profile list", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + configFile, err := util.GetMigratedConfigFile() + if err != nil { + util.HandleError(err, "Unable to read the Infisical config file") + } + + if len(configFile.Profiles) == 0 { + util.PrintlnStderr("No login profiles found. Run [infisical login] to create one.") + return + } + + resolved := util.ResolveProfile(configFile) + + scopesByProfile := map[string][]string{} + for dir, name := range configFile.DirectoryProfiles { + scopesByProfile[name] = append(scopesByProfile[name], dir) + } + + writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(writer, "CURRENT\tNAME\tEMAIL\tORGANIZATION\tSESSION\tDOMAIN\tDIRECTORY SCOPES") + for _, profile := range configFile.Profiles { + marker := "" + if profile.Name == resolved.Name { + marker = "*" + } + + organization := profile.OrganizationName + if organization == "" { + organization = profile.OrganizationID + } + if organization == "" { + organization = "-" + } + + scopes := append([]string(nil), scopesByProfile[profile.Name]...) + sort.Strings(scopes) + scopesDisplay := strings.Join(scopes, ", ") + if scopesDisplay == "" { + scopesDisplay = "-" + } + + fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", marker, util.SanitizeDisplay(profile.Name), util.SanitizeDisplay(profile.Email), + util.SanitizeDisplay(organization), util.SessionStatus(profile), util.SanitizeDisplay(util.DisplayDomain(profile.Domain)), scopesDisplay) + } + writer.Flush() + + Telemetry.CaptureEvent("cli-command:profile list", posthog.NewProperties().Set("numberOfProfiles", len(configFile.Profiles)).Set("version", util.CLI_VERSION)) + }, +} + +var profileCurrentCmd = &cobra.Command{ + Use: "current", + Short: "Show which profile commands run here will use, and why", + DisableFlagsInUseLine: true, + Example: "infisical profile current", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + plain, err := cmd.Flags().GetBool("plain") + if err != nil { + util.HandleError(err) + } + + resolved, profile, found := util.ResolveActiveProfileDetails() + if resolved.Name == "" { + util.PrintErrorMessageAndExit("No profile is selected. Run [infisical login] to create one.") + } + + if plain { + util.PrintlnStdout(resolved.Name) + return + } + + selectedVia := resolved.Source + if resolved.ScopeDir != "" { + selectedVia = fmt.Sprintf("%s (%s)", selectedVia, resolved.ScopeDir) + } + + util.PrintlnStdout("Profile:", resolved.Name) + util.PrintlnStdout("Selected via:", selectedVia) + if resolved.ShadowedName != "" { + util.PrintlnStdout("Overrides binding:", fmt.Sprintf("%s (bound at %s). Run [eval \"$(infisical profile unpin)\"] to use it.", resolved.ShadowedName, resolved.ShadowedScopeDir)) + } + + if !found { + util.PrintlnStdout("Status: profile does not exist. Run [infisical login --save-as " + resolved.Name + "] to create it.") + return + } + + util.PrintlnStdout("Email:", profile.Email) + + // The organization is a setting on the profile, so report it here too, + // along with an override when one is in effect for this command. + rootName, subName := splitScopedOrgNames(profile.OrganizationName, profile.SubOrganizationID != "") + organization := labelWithID(rootName, profile.OrganizationID) + + orgSelector, orgSource := util.GetOrgOverride() + if orgSelector != "" { + util.PrintlnStdout("Organization:", orgSelector) + util.PrintlnStdout("Organization via:", orgSource) + if organization != "" { + util.PrintlnStdout("Profile default organization:", organization) + } + } else if organization != "" { + util.PrintlnStdout("Organization:", organization) + util.PrintlnStdout("Organization via:", util.OrgSourceProfileDefault) + } + if profile.SubOrganizationID != "" { + util.PrintlnStdout("Sub-organization:", labelWithID(subName, profile.SubOrganizationID)) + } + util.PrintlnStdout("Domain:", util.DisplayDomain(profile.Domain)) + + Telemetry.CaptureEvent("cli-command:profile current", posthog.NewProperties().Set("version", util.CLI_VERSION)) + }, +} + +var profileNewCmd = &cobra.Command{ + Use: "new [name]", + Short: "Create a profile for another organization, reusing your current login", + Long: `Create a profile for another organization without logging in again. + +The new profile reuses the account and instance you are already signed in to, +scoped to the organization you choose, so both organizations stay usable at the +same time. The organization comes from --org when given, otherwise you are +asked to pick one. + +Creating a profile does not change which one other terminals use. Pass --pin to +start using it in this terminal (run the command through eval), or --use to +make it the default for the machine. + +To add a different account, or an account on another instance, use +[infisical login --save-as ] instead.`, + DisableFlagsInUseLine: true, + Example: "infisical profile new client-b --org globex\neval \"$(infisical profile new client-b --org globex --pin)\"\ninfisical profile new client-b --org globex --use", + Args: cobra.ExactArgs(1), + PreRun: func(cmd *cobra.Command, args []string) { + util.RequireLogin() + }, + Run: func(cmd *cobra.Command, args []string) { + profileName := args[0] + if err := util.ValidateProfileName(profileName); err != nil { + util.HandleError(err) + } + + useAsDefault, err := cmd.Flags().GetBool("use") + if err != nil { + util.HandleError(err) + } + pinTerminal, err := cmd.Flags().GetBool("pin") + if err != nil { + util.HandleError(err) + } + + configFile, err := util.GetMigratedConfigFile() + if err != nil { + util.HandleError(err, "Unable to read the Infisical config file") + } + if _, exists := util.FindProfile(configFile, profileName); exists { + util.PrintErrorMessageAndExit(fmt.Sprintf("Profile '%s' already exists. Pick another name, or change the organization it uses with [infisical profile set-org --profile %s].", profileName, profileName)) + } + + // This command does its own organization exchange, which can prompt for + // MFA, so resolve the session with the --org override suspended rather + // than letting it be applied (and fail) during resolution. + orgSelector, _ := util.GetOrgOverride() + restoreOrgOverride := util.SuspendOrgOverride() + details := requireUserSession() + restoreOrgOverride() + + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + util.HandleError(err, "Unable to get resty client with custom headers") + } + httpClient.SetAuthToken(details.UserCredentials.JTWToken) + + var selectedOrgID string + if orgSelector != "" { + resolvedOrg, err := util.ResolveOrgSelector(details.UserCredentials.JTWToken, orgSelector) + if err != nil { + util.HandleError(err) + } + selectedOrgID = resolvedOrg.ID + } else { + selectedOrgID, _, err = pickOrganization(httpClient, fmt.Sprintf("Which organization should profile '%s' use?", profileName), details.UserCredentials.Email) + if err != nil { + util.HandleError(err, "Unable to select organization") + } + } + + sessionToken, err := selectOrganizationToken(details.UserCredentials.JTWToken, details.UserCredentials.Email, selectedOrgID) + if err != nil { + util.HandleError(err, "Unable to scope the session to that organization") + } + + orgID, subOrgID := util.ParseTokenOrgClaims(sessionToken) + orgInfo := util.DescribeSessionOrg(sessionToken, orgID, subOrgID) + orgName := orgInfo.DisplayName() + + credentials := details.UserCredentials + credentials.JTWToken = sessionToken + + domain := details.Profile.Domain + if domain == "" { + domain = config.INFISICAL_URL + } + + // Creating a profile does not take over the machine default unless asked, + // since other terminals may be relying on it. Organization sessions + // cached by --org belong to the profile they were minted under, so the + // new profile starts without any. + err = util.PersistLoginProfile(models.Profile{ + Name: profileName, + Email: details.UserCredentials.Email, + Domain: domain, + OrganizationID: orgID, + OrganizationName: orgName, + OrganizationSlug: orgInfo.Slug, + SubOrganizationID: subOrgID, + }, &credentials, useAsDefault) + if err != nil { + util.HandleError(err, "Unable to store the new profile") + } + + orgDisplay := orgName + if orgDisplay == "" { + orgDisplay = orgID + } + + // The profile exists either way, so an uncaptured --pin is a warning + // rather than a failure. + pinTookEffect := pinTerminal && shellOutputIsCaptured() + if pinTerminal { + // stdout carries only the export so the output stays eval-safe. + util.PrintlnStdout(fmt.Sprintf("export %s=%s", util.INFISICAL_PROFILE_ENV_NAME, util.ShellQuote(profileName))) + } + + util.PrintlnStderr(fmt.Sprintf("Created profile '%s' (%s, org %s). Profile '%s' is unchanged.", profileName, details.UserCredentials.Email, orgDisplay, details.ProfileName)) + + switch { + case useAsDefault && pinTookEffect: + util.PrintlnStderr("It is now the default profile, and this terminal is pinned to it.") + case useAsDefault: + util.PrintlnStderr("It is now the default profile for this machine.") + case pinTookEffect: + util.PrintlnStderr("This terminal is pinned to it. Other terminals and the default profile are unaffected.") + case pinTerminal: + util.PrintWarning(fmt.Sprintf("--pin had no effect because the shell did not read the output. Pin this terminal with [eval \"$(infisical profile pin %s)\"]. %s", profileName, manualPinHint(profileName))) + default: + util.PrintlnStderr(fmt.Sprintf("Start using it here with [eval \"$(infisical profile pin %s)\"], in a directory with [infisical profile bind %s], or everywhere with [infisical profile use %s].", profileName, profileName, profileName)) + } + + Telemetry.CaptureEvent("cli-command:profile new", posthog.NewProperties().Set("version", util.CLI_VERSION)) + }, +} + +var profileUseCmd = &cobra.Command{ + Use: "use [name]", + Short: "Make a profile the default for this machine", + Long: `Make a profile the default for this machine. + +This is the fallback used when nothing more specific applies. To choose a +profile for one terminal use [infisical profile pin], and for one directory use +[infisical profile bind].`, + DisableFlagsInUseLine: true, + Example: "infisical profile use work-eu", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + profileName := args[0] + + configFile, err := util.GetMigratedConfigFile() + if err != nil { + util.HandleError(err, "Unable to read the Infisical config file") + } + + profile, found := util.FindProfile(configFile, profileName) + if !found { + util.PrintErrorMessageAndExit(fmt.Sprintf("Profile '%s' does not exist. Run [infisical profile list] to see available profiles.", profileName)) + } + + if err := util.SetActiveProfile(&configFile, profileName); err != nil { + util.HandleError(err) + } + if err := util.WriteConfigFile(&configFile); err != nil { + util.HandleError(err, "Unable to save the Infisical config file") + } + util.PrintlnStderr(fmt.Sprintf("Default profile is now '%s' (%s, org %s, %s)", profileName, profile.Email, orgLabel(profile), util.DisplayDomain(profile.Domain))) + + Telemetry.CaptureEvent("cli-command:profile use", posthog.NewProperties().Set("version", util.CLI_VERSION)) + }, +} + +var profilePinCmd = &cobra.Command{ + Use: "pin [name]", + Short: "Pin this terminal to a profile, leaving other terminals alone", + Long: `Pin the current terminal to a profile. + +Prints an export statement, so run it through eval to have it take effect in +the shell you are in: + + eval "$(infisical profile pin globex)" + +Only this terminal is affected. The default profile and every other terminal +keep whatever they were using, which is what makes it possible to work in +several organizations at once. Undo with [infisical profile unpin]. + +The statement is written for POSIX shells such as bash and zsh. PowerShell +cannot evaluate it, so set the variable directly there: + + $env:INFISICAL_PROFILE = 'globex' + +In scripts and CI, set INFISICAL_PROFILE (or pass --profile) instead of calling +pin: with output redirected the statement is only printed, and nothing changes +until a shell evaluates it.`, + DisableFlagsInUseLine: true, + Example: "eval \"$(infisical profile pin globex)\"", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + profileName := args[0] + + configFile, err := util.GetMigratedConfigFile() + if err != nil { + util.HandleError(err, "Unable to read the Infisical config file") + } + + profile, found := util.FindProfile(configFile, profileName) + if !found { + util.PrintErrorMessageAndExit(fmt.Sprintf("Profile '%s' does not exist. Run [infisical profile list] to see available profiles.", profileName)) + } + + requireShellCapture(fmt.Sprintf("infisical profile pin %s", profileName), manualPinHint(profileName)) + + // stdout carries only the export so the output stays eval-safe. The + // output being captured does not prove a shell evaluated it (it could be + // a file or a pipe), so say what happens rather than claiming success. + util.PrintlnStdout(fmt.Sprintf("export %s=%s", util.INFISICAL_PROFILE_ENV_NAME, util.ShellQuote(profileName))) + util.PrintlnStderr(fmt.Sprintf("Profile '%s' (%s, org %s) is pinned in this terminal once the shell evaluates the printed export. Other terminals and the default profile are unaffected.", profileName, profile.Email, orgLabel(profile))) + + Telemetry.CaptureEvent("cli-command:profile pin", posthog.NewProperties().Set("version", util.CLI_VERSION)) + }, +} + +var profileUnpinCmd = &cobra.Command{ + Use: "unpin", + Short: "Remove this terminal's profile pin", + Long: `Remove the pin from the current terminal, so it falls back to a bound +directory or the default profile. + +Prints an unset statement, so run it through eval: + + eval "$(infisical profile unpin)" + +In PowerShell, which cannot evaluate it, run [Remove-Item Env:INFISICAL_PROFILE] +instead.`, + DisableFlagsInUseLine: true, + Example: "eval \"$(infisical profile unpin)\"", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + requireShellCapture("infisical profile unpin", manualUnpinHint()) + + util.PrintlnStdout(fmt.Sprintf("unset %s", util.INFISICAL_PROFILE_ENV_NAME)) + util.PrintlnStderr("This terminal's profile pin is removed once the shell evaluates the printed statement. It then follows a bound directory, or the default profile.") + + Telemetry.CaptureEvent("cli-command:profile unpin", posthog.NewProperties().Set("version", util.CLI_VERSION)) + }, +} + +var profileBindCmd = &cobra.Command{ + Use: "bind [name] [path]", + Short: "Bind a directory to a profile, so commands run there select it automatically", + Long: `Bind a directory, and everything under it, to a profile. + +Commands run inside that directory select the profile with no flag and no +environment variable, so moving between projects moves between organizations. +The nearest bound directory wins, and the binding is stored in your own +configuration, never in the repository. + +With no arguments, binds the current directory to the profile already in +effect, which is usually what you want right after logging in or switching. +Name a profile to bind a different one, and add a path to bind somewhere other +than the current directory. Undo with [infisical profile unbind].`, + DisableFlagsInUseLine: true, + Example: "infisical profile bind\ninfisical profile bind client-a\ninfisical profile bind client-a ~/work/client-a", + Args: cobra.MaximumNArgs(2), + Run: func(cmd *cobra.Command, args []string) { + configFile, err := util.GetMigratedConfigFile() + if err != nil { + util.HandleError(err, "Unable to read the Infisical config file") + } + + var profileName string + var chosenVia string + + if len(args) == 0 { + // Bind whatever this directory would already have used, so the + // common "make this stick here" case needs no arguments. + resolved := util.ResolveProfile(configFile) + if resolved.Name == "" { + util.PrintErrorMessageAndExit("No profile is in effect here, so there is nothing to bind. Run [infisical login], or name a profile: [infisical profile bind ].") + } + profileName = resolved.Name + chosenVia = resolved.Source + } else { + profileName = args[0] + } + + if _, found := util.FindProfile(configFile, profileName); !found { + // A lone path is the likely mistake here, since the first argument + // is the profile and the second is the directory. + if len(args) == 1 { + if info, statErr := os.Stat(args[0]); statErr == nil && info.IsDir() { + util.PrintErrorMessageAndExit(fmt.Sprintf("'%s' is a directory, not a profile. Use [infisical profile bind] on its own to bind the profile already in effect, or [infisical profile bind %s] to name one.", args[0], args[0])) + } + } + util.PrintErrorMessageAndExit(fmt.Sprintf("Profile '%s' does not exist. Run [infisical profile list] to see available profiles.", profileName)) + } + + target := "." + if len(args) == 2 { + target = args[1] + } + + boundDir, err := filepath.Abs(target) + if err != nil { + util.HandleError(err, "Unable to resolve the directory") + } + dirInfo, err := os.Stat(boundDir) + if err != nil || !dirInfo.IsDir() { + util.PrintErrorMessageAndExit(fmt.Sprintf("%s is not an existing directory", boundDir)) + } + + // An identical binding on a parent already covers this directory, so + // say so rather than silently adding a redundant entry. + existingName, existingDir, hasExisting := util.FindGoverningDirectoryProfile(configFile, boundDir) + redundant := hasExisting && existingName == profileName && existingDir != filepath.Clean(boundDir) + + util.SetDirectoryProfile(&configFile, boundDir, profileName) + if err := util.WriteConfigFile(&configFile); err != nil { + util.HandleError(err, "Unable to save the Infisical config file") + } + + if chosenVia != "" { + util.PrintlnStderr(fmt.Sprintf("Directory %s (and its subdirectories) now uses profile '%s', which was already in effect here via the %s.", boundDir, profileName, chosenVia)) + } else { + util.PrintlnStderr(fmt.Sprintf("Directory %s (and its subdirectories) now uses profile '%s'.", boundDir, profileName)) + } + if redundant { + util.PrintlnStderr(fmt.Sprintf("Note: %s was already covered by the binding on %s, so this only makes it explicit.", boundDir, existingDir)) + } + util.PrintlnStderr("Remove with [infisical profile unbind].") + + Telemetry.CaptureEvent("cli-command:profile bind", posthog.NewProperties().Set("implicitProfile", len(args) == 0).Set("version", util.CLI_VERSION)) + }, +} + +var profileUnbindCmd = &cobra.Command{ + Use: "unbind [path]", + Short: "Remove a directory's profile binding", + Long: `Remove a directory's profile binding. + +Defaults to whichever binding covers the current directory, so running it +inside a bound tree undoes that binding.`, + DisableFlagsInUseLine: true, + Example: "infisical profile unbind\ninfisical profile unbind ~/work/client-a", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + configFile, err := util.GetMigratedConfigFile() + if err != nil { + util.HandleError(err, "Unable to read the Infisical config file") + } + + var target string + if len(args) == 1 { + target, err = filepath.Abs(args[0]) + if err != nil { + util.HandleError(err, "Unable to resolve the given path") + } + if _, ok := configFile.DirectoryProfiles[filepath.Clean(target)]; !ok { + util.PrintErrorMessageAndExit(fmt.Sprintf("No directory binding exists for %s. Run [infisical profile list] to see bindings.", target)) + } + } else { + cwd, err := os.Getwd() + if err != nil { + util.HandleError(err, "Unable to determine the current directory") + } + _, scopeDir, ok := util.FindGoverningDirectoryProfile(configFile, cwd) + if !ok { + util.PrintlnStderr("No directory binding covers the current directory.") + return + } + target = scopeDir + } + + util.RemoveDirectoryProfile(&configFile, target) + if err := util.WriteConfigFile(&configFile); err != nil { + util.HandleError(err, "Unable to save the Infisical config file") + } + util.PrintlnStderr(fmt.Sprintf("Removed the profile binding for %s", target)) + + Telemetry.CaptureEvent("cli-command:profile unbind", posthog.NewProperties().Set("version", util.CLI_VERSION)) + }, +} + +var profileRenameCmd = &cobra.Command{ + Use: "rename [name] [new-name]", + Short: "Rename a profile", + Long: `Rename a profile. + +Everything that refers to the profile follows the new name: its stored session, +the default-profile setting, and directory bindings. Terminals pinned to the old +name with [infisical profile pin] keep pointing at it and need pinning again.`, + DisableFlagsInUseLine: true, + Example: "infisical profile rename scott@example.com--globex globex", + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + oldName, newName := args[0], args[1] + if err := util.ValidateProfileName(newName); err != nil { + util.HandleError(err) + } + + configFile, err := util.GetMigratedConfigFile() + if err != nil { + util.HandleError(err, "Unable to read the Infisical config file") + } + profile, found := util.FindProfile(configFile, oldName) + if !found { + util.PrintErrorMessageAndExit(fmt.Sprintf("Profile '%s' does not exist. Run [infisical profile list] to see available profiles.", oldName)) + } + if _, exists := util.FindProfile(configFile, newName); exists { + util.PrintErrorMessageAndExit(fmt.Sprintf("Profile '%s' already exists. Pick another name.", newName)) + } + + // Keyring entries are keyed by name, so copy them under the new name + // first and remove the old ones only once the config is saved. If + // anything in between fails the old profile is left intact. + renamed := profile + renamed.Name = newName + if err := util.CopyStoredSession(profile, newName); err != nil { + util.HandleError(err, "Unable to store the session under the new name") + } + if err := util.RenameProfile(&configFile, oldName, newName); err != nil { + _ = util.ClearStoredSession(renamed) + util.HandleError(err) + } + if err := util.WriteConfigFile(&configFile); err != nil { + _ = util.ClearStoredSession(renamed) + util.HandleError(err, "Unable to save the Infisical config file") + } + if err := util.ClearStoredSession(profile); err != nil { + util.PrintWarning(fmt.Sprintf("Renamed, but the session stored under the old name could not be removed [err=%s].", err)) + } + + util.PrintlnStderr(fmt.Sprintf("Renamed profile '%s' to '%s'.", oldName, newName)) + if profile.Name == profile.Email { + util.PrintlnStderr("Older CLI versions only recognize profiles named after their email, so they will ask you to log in again.") + } + if os.Getenv(util.INFISICAL_PROFILE_ENV_NAME) == oldName { + util.PrintlnStderr(fmt.Sprintf("This terminal is pinned to the old name. Run [eval \"$(infisical profile pin %s)\"] to pin it again.", newName)) + } + + Telemetry.CaptureEvent("cli-command:profile rename", posthog.NewProperties().Set("version", util.CLI_VERSION)) + }, +} + +var profileDeleteCmd = &cobra.Command{ + Use: "delete [name]", + Short: "Delete a profile and its stored session credentials", + DisableFlagsInUseLine: true, + Example: "infisical profile delete old-client", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + profileName := args[0] + + configFile, err := util.GetMigratedConfigFile() + if err != nil { + util.HandleError(err, "Unable to read the Infisical config file") + } + + if _, found := util.FindProfile(configFile, profileName); !found { + util.PrintErrorMessageAndExit(fmt.Sprintf("Profile '%s' does not exist. Run [infisical profile list] to see available profiles.", profileName)) + } + + localOnly, err := cmd.Flags().GetBool("local-only") + if err != nil { + util.HandleError(err) + } + + // Deleting a profile ends its session too, otherwise the credential + // would keep working on the server after it looks gone locally. + for _, result := range util.LogoutProfilesAcrossDomains(configFile, []string{profileName}, localOnly) { + switch { + case result.SharedWith != "": + util.PrintlnStderr(fmt.Sprintf("The server session is still used by profile '%s', so it was left active.", result.SharedWith)) + case result.RevokeErr != nil: + util.PrintWarning(fmt.Sprintf("Could not revoke the session on the server [err=%s]. It stays valid until it expires.", result.RevokeErr)) + case result.Revoked: + util.PrintlnStderr("Revoked the session on the server.") + } + } + + util.RemoveProfile(&configFile, profileName) + if err := util.WriteConfigFile(&configFile); err != nil { + util.HandleError(err, "Unable to save the Infisical config file") + } + + util.PrintlnStderr(fmt.Sprintf("Deleted profile '%s'", profileName)) + if configFile.ActiveProfile == "" && len(configFile.Profiles) > 0 { + util.PrintlnStderr("No default profile is set. Pick one with [infisical profile use ].") + } + + Telemetry.CaptureEvent("cli-command:profile delete", posthog.NewProperties().Set("version", util.CLI_VERSION)) + }, +} + +func init() { + profileCurrentCmd.Flags().Bool("plain", false, "print only the profile name (useful for shell prompts)") + profileDeleteCmd.Flags().Bool("local-only", false, "remove the profile without revoking its session on the server") + + profileNewCmd.Flags().Bool("use", false, "also make the new profile the default for this machine") + profileNewCmd.Flags().Bool("pin", false, "also pin this terminal to the new profile, for use with: eval \"$(infisical profile new --pin)\"") + profileCmd.AddCommand(profileNewCmd) + profileCmd.AddCommand(newSetOrgCommand("set-org [org]", "profile set-org", "Set the organization this profile uses by default")) + profileCmd.AddCommand(profileListCmd) + profileCmd.AddCommand(profileCurrentCmd) + profileCmd.AddCommand(profileUseCmd) + profileCmd.AddCommand(profilePinCmd) + profileCmd.AddCommand(profileUnpinCmd) + profileCmd.AddCommand(profileBindCmd) + profileCmd.AddCommand(profileUnbindCmd) + profileCmd.AddCommand(profileRenameCmd) + profileCmd.AddCommand(profileDeleteCmd) + RootCmd.AddCommand(profileCmd) +} diff --git a/packages/cmd/reset.go b/packages/cmd/reset.go index 2af92583..53e33dce 100644 --- a/packages/cmd/reset.go +++ b/packages/cmd/reset.go @@ -4,6 +4,7 @@ Copyright (c) 2023 Infisical Inc. package cmd import ( + "fmt" "os" "github.com/Infisical/infisical-merge/packages/util" @@ -12,17 +13,50 @@ import ( ) var resetCmd = &cobra.Command{ - Use: "reset", - Short: "Used to delete all Infisical related data on your machine", - DisableFlagsInUseLine: true, - Example: "infisical reset", - Args: cobra.NoArgs, + Use: "reset", + Short: "Used to delete all Infisical related data on your machine", + Example: "infisical reset", + Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { - // delete keyring item of current logged in user + // revoke and delete every stored login session configFile, _ := util.GetConfigFile() + util.MigrateConfigProfiles(&configFile) + + localOnly, err := cmd.Flags().GetBool("local-only") + if err != nil { + util.HandleError(err) + } + + var profileNames []string + for _, profile := range configFile.Profiles { + profileNames = append(profileNames, profile.Name) + } + for _, result := range util.LogoutProfilesAcrossDomains(configFile, profileNames, localOnly) { + if result.RevokeErr != nil { + util.PrintWarning(fmt.Sprintf("Could not revoke the session for profile '%s' [err=%s]. It stays valid until it expires.", result.ProfileName, result.RevokeErr)) + } + } + + keyringKeys := map[string]bool{} + if configFile.LoggedInUserEmail != "" { + keyringKeys[configFile.LoggedInUserEmail] = true + } + for _, user := range configFile.LoggedInUsers { + if user.Email != "" { + keyringKeys[user.Email] = true + } + } + for _, profile := range configFile.Profiles { + keyringKeys[profile.Name] = true + for _, ref := range profile.OrgSessions { + keyringKeys[util.OrgSessionKeyringKey(profile.Name, ref.OrgID)] = true + } + } // delete from keyring - util.DeleteValueInKeyring(configFile.LoggedInUserEmail) + for key := range keyringKeys { + util.DeleteValueInKeyring(key) + } // delete config _, pathToDir, err := util.GetFullConfigFilePath() @@ -41,5 +75,6 @@ var resetCmd = &cobra.Command{ } func init() { + resetCmd.Flags().Bool("local-only", false, "remove local data without revoking sessions on the server") RootCmd.AddCommand(resetCmd) } diff --git a/packages/cmd/root.go b/packages/cmd/root.go index fa103824..a566160b 100644 --- a/packages/cmd/root.go +++ b/packages/cmd/root.go @@ -123,6 +123,26 @@ func resolveDomain(cmd *cobra.Command, flagValue string) string { return domain } +// Commands that manage profiles/sessions themselves print their own outcome, +// so the ambient "using profile X" notice would just be noise for them. +var profileNoticeExemptCommands = map[string]bool{ + "login": true, + "logout": true, + "profile": true, + "org": true, + "user": true, + "reset": true, + "vault": true, +} + +func topLevelCommandName(cmd *cobra.Command) string { + current := cmd + for current.Parent() != nil && current.Parent() != RootCmd { + current = current.Parent() + } + return current.Name() +} + func init() { util.GetStderrWriter = RootCmdStderrWriter util.GetStdoutWriter = RootCmdStdoutWriter @@ -133,12 +153,41 @@ func init() { RootCmd.PersistentFlags().Bool("telemetry", true, "Infisical collects non-sensitive telemetry data to enhance features and improve user experience. Participation is voluntary") RootCmd.PersistentFlags().StringVar(&config.INFISICAL_URL, "domain", fmt.Sprintf("%s/api", util.INFISICAL_DEFAULT_US_URL), "Point the CLI to your Infisical instance (e.g., https://eu.infisical.com for EU Cloud, or https://your-instance.com for self-hosted). Can also set via INFISICAL_DOMAIN environment variable or the 'domain' field in .infisical.json. Required for non-US Cloud users.") RootCmd.PersistentFlags().Bool("silent", false, "Disable output of tip/info messages. Useful when running in scripts or CI/CD pipelines.") + RootCmd.PersistentFlags().String("profile", "", "Use a specific login profile for this command (see [infisical profile list]). Can also set via the INFISICAL_PROFILE environment variable.") + RootCmd.PersistentFlags().String("org", "", "Use a specific organization for this command, by name, slug, or id. Overrides the profile's default organization without changing it. Can also set via the INFISICAL_ORG environment variable.") RootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { silent, err := cmd.Flags().GetBool("silent") if err != nil { util.HandleError(err) } + profileFlag, err := cmd.Flags().GetString("profile") + if err != nil { + util.HandleError(err) + } + if profileFlag != "" { + config.INFISICAL_PROFILE_OVERRIDE = profileFlag + config.INFISICAL_PROFILE_OVERRIDE_SOURCE = util.ProfileSourceFlag + } else if envProfile := strings.TrimSpace(os.Getenv(util.INFISICAL_PROFILE_ENV_NAME)); envProfile != "" { + config.INFISICAL_PROFILE_OVERRIDE = envProfile + config.INFISICAL_PROFILE_OVERRIDE_SOURCE = util.ProfileSourceEnv + } + + orgFlag, err := cmd.Flags().GetString("org") + if err != nil { + util.HandleError(err) + } + if orgFlag != "" { + config.INFISICAL_ORG_OVERRIDE = orgFlag + config.INFISICAL_ORG_OVERRIDE_SOURCE = util.OrgSourceFlag + } else if envOrg := strings.TrimSpace(os.Getenv(util.INFISICAL_ORG_ENV_NAME)); envOrg != "" { + config.INFISICAL_ORG_OVERRIDE = envOrg + config.INFISICAL_ORG_OVERRIDE_SOURCE = util.OrgSourceEnv + } + + _, envDomainSet := util.GetEnvDomain() + config.INFISICAL_DOMAIN_EXPLICITLY_SET = cmd.Flags().Changed("domain") || envDomainSet + config.INFISICAL_URL = util.AppendAPIEndpoint(resolveDomain(cmd, config.INFISICAL_URL)) if !util.IsRunningInDocker() && !silent && !isStructuredOutputRequested(cmd) { @@ -156,6 +205,13 @@ func init() { } } + // The "Using profile ..." notice is printed by the session loader the + // first time a command actually uses a login session, so commands that + // never do (scan, agent, gateway, ...) stay quiet even in a pinned + // terminal. Profile-management commands print their own outcome. + if !silent && !isStructuredOutputRequested(cmd) && !profileNoticeExemptCommands[topLevelCommandName(cmd)] { + util.EnableProfileNotice(cmd.ErrOrStderr()) + } } isTelemetryOn, _ := RootCmd.PersistentFlags().GetBool("telemetry") diff --git a/packages/cmd/user.go b/packages/cmd/user.go index 0a461bcf..f5969ed5 100644 --- a/packages/cmd/user.go +++ b/packages/cmd/user.go @@ -10,7 +10,6 @@ import ( "time" "github.com/Infisical/infisical-merge/packages/config" - "github.com/Infisical/infisical-merge/packages/models" "github.com/Infisical/infisical-merge/packages/util" "github.com/manifoldco/promptui" "github.com/posthog/posthog-go" @@ -30,54 +29,44 @@ var userCmd = &cobra.Command{ var switchCmd = &cobra.Command{ Use: "switch", - Short: "Used to switch between Infisical profiles", + Short: "Switch the default login profile (same as [infisical profile use], with a picker)", DisableFlagsInUseLine: true, - Example: "infisical switch", + Example: "infisical user switch", Args: cobra.ExactArgs(0), PreRun: func(cmd *cobra.Command, args []string) { util.RequireLogin() }, Run: func(cmd *cobra.Command, args []string) { - //get previous logged in profiles - loggedInProfiles, err := getLoggedInUsers() + configFile, err := util.GetMigratedConfigFile() if err != nil { - util.HandleError(err, "[infisical user switch]: Unable to get logged Profiles") + util.HandleError(err, "[infisical user switch]: Unable to get config file") } - //prompt user - profile, err := LoggedInUsersPrompt(loggedInProfiles) - if err != nil { - util.HandleError(err, "[infisical user switch]: Prompt error") + if len(configFile.Profiles) == 0 { + util.PrintErrorMessageAndExit("No login profiles found. Run [infisical login] to create one.") } - //write to config file - configFile, err := util.GetConfigFile() - if err != nil { - util.HandleError(err, "[infisical user switch]: Unable to get config file") + labels := make([]string, len(configFile.Profiles)) + for idx, profile := range configFile.Profiles { + label := fmt.Sprintf("%s (%s", profile.Name, profile.Email) + if profile.OrganizationName != "" { + label = fmt.Sprintf("%s, org %s", label, profile.OrganizationName) + } + labels[idx] = fmt.Sprintf("%s, %s)", label, util.DisplayDomain(profile.Domain)) } - configFile.LoggedInUserEmail = profile - - //set logged in user domain - ok := util.ConfigContainsEmail(configFile.LoggedInUsers, profile) - - if !ok { - //profile not in loggedInUsers - configFile.LoggedInUsers = append(configFile.LoggedInUsers, models.LoggedInUser{ - Email: profile, - Domain: config.INFISICAL_URL, - }) - //set logged in user domain - configFile.LoggedInUserDomain = config.INFISICAL_URL + prompt := promptui.Select{ + Label: "Which of your Infisical profiles would you like to use", + Items: labels, + Size: 7, + } + idx, _, err := prompt.Run() + if err != nil { + util.HandleError(err, "[infisical user switch]: Prompt error") + } - } else { - //exists, set logged in user domain - for _, v := range configFile.LoggedInUsers { - if profile == v.Email { - configFile.LoggedInUserDomain = v.Domain - break - } - } + if err := util.SetActiveProfile(&configFile, configFile.Profiles[idx].Name); err != nil { + util.HandleError(err, "[infisical user switch]: Unable to switch profile") } err = util.WriteConfigFile(&configFile) @@ -85,7 +74,9 @@ var switchCmd = &cobra.Command{ util.HandleError(err, "") } - Telemetry.CaptureEvent("cli-command:user switch", posthog.NewProperties().Set("numberOfLoggedInProfiles", len(loggedInProfiles)).Set("version", util.CLI_VERSION)) + util.PrintlnStderr(fmt.Sprintf("Default profile is now '%s'", configFile.Profiles[idx].Name)) + + Telemetry.CaptureEvent("cli-command:user switch", posthog.NewProperties().Set("numberOfLoggedInProfiles", len(configFile.Profiles)).Set("version", util.CLI_VERSION)) }, } @@ -175,8 +166,14 @@ var updateCmd = &cobra.Command{ } var domainCmd = &cobra.Command{ - Use: "domain", - Short: "Used to update the domain of an Infisical profile", + Use: "domain", + Short: "Point a profile at a different Infisical instance", + Long: `Point a profile at a different Infisical instance. + +Exactly the profile you pick is changed, even when other profiles share its +account and instance. A session issued by the previous instance is not valid on +the new one and must not be sent there, so the profile's stored credentials are +cleared and you are asked to sign in again.`, DisableFlagsInUseLine: true, Example: "infisical user update domain", Args: cobra.ExactArgs(0), @@ -184,22 +181,35 @@ var domainCmd = &cobra.Command{ util.RequireLogin() }, Run: func(cmd *cobra.Command, args []string) { - //prompt for profiles selection - loggedInProfiles, err := getLoggedInUsers() + configFile, err := util.GetMigratedConfigFile() if err != nil { - util.HandleError(err, "[infisical user update domain]: Unable to get logged Profiles") + util.HandleError(err, "[infisical user update domain]: Unable to get config file") + } + if len(configFile.Profiles) == 0 { + util.PrintErrorMessageAndExit("No login profiles found. Run [infisical login] to create one.") } - //prompt user - profile, err := LoggedInUsersPrompt(loggedInProfiles) + // Selecting a profile rather than an email matters: several profiles can + // share an email and an instance while holding different organizations, + // and only the chosen one should move. + labels := make([]string, len(configFile.Profiles)) + for idx, profile := range configFile.Profiles { + labels[idx] = fmt.Sprintf("%s (%s, org %s, %s)", profile.Name, profile.Email, orgLabel(profile), util.DisplayDomain(profile.Domain)) + } + prompt := promptui.Select{ + Label: "Which profile should point at a different instance", + Items: labels, + Size: 7, + } + index, _, err := prompt.Run() if err != nil { util.HandleError(err, "[infisical user update domain]: Prompt error") } + selected := configFile.Profiles[index] domain := "" domainQuery := true if config.INFISICAL_URL_MANUAL_OVERRIDE != fmt.Sprintf("%s/api", util.INFISICAL_DEFAULT_EU_URL) && config.INFISICAL_URL_MANUAL_OVERRIDE != fmt.Sprintf("%s/api", util.INFISICAL_DEFAULT_US_URL) { - override, err := DomainOverridePrompt() if err != nil { util.HandleError(err, "[infisical user update domain]: Domain override prompt error") @@ -209,53 +219,42 @@ var domainCmd = &cobra.Command{ domainQuery = false domain = config.INFISICAL_URL_MANUAL_OVERRIDE } - } if domainQuery { - //prompt to update domain domain, err = NewDomainPrompt() if err != nil { util.HandleError(err, "[infisical user update domain]: Prompt error") } } - //write to config file - configFile, err := util.GetConfigFile() - if err != nil { - util.HandleError(err, "[infisical user update domain]: Unable to get config file") + if util.AppendAPIEndpoint(domain) == util.AppendAPIEndpoint(selected.Domain) { + util.PrintlnStderr(fmt.Sprintf("Profile '%s' already uses %s. Nothing changed.", selected.Name, util.DisplayDomain(domain))) + return } - //check if profile in logged in profiles - - //if not add new profile loggedInUsers - //else update profile from loggedinUsers slice - ok := util.ConfigContainsEmail(configFile.LoggedInUsers, profile) - if !ok { - configFile.LoggedInUsers = append(configFile.LoggedInUsers, models.LoggedInUser{ - Email: profile, - Domain: domain, - }) - } else { - //exists, set logged in user domain - for idx, v := range configFile.LoggedInUsers { - if profile == v.Email { - configFile.LoggedInUsers[idx].Domain = domain //inplace - break - } - } - + // Remove the old session before recording the new instance, and abandon + // the change if it cannot be removed. Persisting the new instance while + // the previous session survived under this profile name would send that + // token to the new endpoint, which is the disclosure this clearing + // exists to prevent. + if err := util.ClearStoredSession(selected); err != nil { + util.HandleError(err, fmt.Sprintf("Unable to clear the stored session of profile '%s'. It still points at %s, because its existing session must not be sent to %s.", + selected.Name, util.DisplayDomain(selected.Domain), util.DisplayDomain(domain))) } - //check if current loggedinuser is selected profile - //if yes set current domain to changed domain - if configFile.LoggedInUserEmail == profile { - configFile.LoggedInUserDomain = domain + + if !util.RepointProfileDomain(&configFile, selected.Name, domain) { + util.PrintlnStderr(fmt.Sprintf("Profile '%s' already uses %s. Nothing changed.", selected.Name, util.DisplayDomain(domain))) + return } - err = util.WriteConfigFile(&configFile) - if err != nil { + if err := util.WriteConfigFile(&configFile); err != nil { util.HandleError(err, "") } + + util.PrintlnStderr(fmt.Sprintf("Profile '%s' now points at %s. Its previous session was cleared, so run [infisical login --profile %s --domain %s] to sign in there.", + selected.Name, util.DisplayDomain(domain), selected.Name, util.DisplayDomain(domain))) + Telemetry.CaptureEvent("cli-command:user domain", posthog.NewProperties().Set("version", util.CLI_VERSION)) }, } @@ -272,34 +271,6 @@ func init() { RootCmd.AddCommand(userCmd) } -// This returns all logged in user emails from the config file. -// If none, it returns the current logged in user in a slice -func getLoggedInUsers() ([]string, error) { - loggedInProfiles := []string{} - - if util.ConfigFileExists() { - configFile, err := util.GetConfigFile() - if err != nil { - return loggedInProfiles, err - } - - //get logged in profiles - // - if len(configFile.LoggedInUsers) > 0 { - for _, v := range configFile.LoggedInUsers { - loggedInProfiles = append(loggedInProfiles, v.Email) - } - } else { - - loggedInProfiles = append(loggedInProfiles, configFile.LoggedInUserEmail) - } - return loggedInProfiles, nil - } else { - //empty - return loggedInProfiles, errors.New("couldn't retrieve config file") - } -} - func NewDomainPrompt() (string, error) { urlValidation := func(input string) error { _, err := url.ParseRequestURI(input) @@ -323,17 +294,3 @@ func NewDomainPrompt() (string, error) { return util.AppendAPIEndpoint(domain), nil } - -func LoggedInUsersPrompt(profiles []string) (string, error) { - prompt := promptui.Select{Label: "Which of your Infisical profiles would you like to use", - Items: profiles, - Size: 7, - } - - idx, _, err := prompt.Run() - if err != nil { - return "", err - } - - return profiles[idx], nil -} diff --git a/packages/cmd/vault.go b/packages/cmd/vault.go index c671dee0..a7ec062e 100644 --- a/packages/cmd/vault.go +++ b/packages/cmd/vault.go @@ -55,8 +55,15 @@ var vaultSetCmd = &cobra.Command{ return } + // Sessions stored in the previous backend are unreachable after the + // switch, so drop all login state and require a fresh login. configFile.VaultBackendType = wantedVaultTypeName configFile.LoggedInUserEmail = "" + configFile.LoggedInUserDomain = "" + configFile.LoggedInUsers = nil + configFile.ActiveProfile = "" + configFile.Profiles = nil + configFile.DirectoryProfiles = nil configFile.VaultBackendPassphrase = base64.StdEncoding.EncodeToString([]byte(util.GenerateRandomString(10))) err = util.WriteConfigFile(&configFile) diff --git a/packages/config/config.go b/packages/config/config.go index c5e162c9..bf0967d0 100644 --- a/packages/config/config.go +++ b/packages/config/config.go @@ -3,3 +3,21 @@ package config var INFISICAL_URL string var INFISICAL_URL_MANUAL_OVERRIDE string var INFISICAL_LOGIN_URL string + +// INFISICAL_PROFILE_OVERRIDE holds the per-invocation profile selection from +// the --profile flag or the INFISICAL_PROFILE env var (flag wins). Set by the +// root command's PersistentPreRun. Empty when neither is provided. +var INFISICAL_PROFILE_OVERRIDE string +var INFISICAL_PROFILE_OVERRIDE_SOURCE string + +// INFISICAL_ORG_OVERRIDE holds the per-invocation organization selection from +// the --org flag or the INFISICAL_ORG env var (flag wins). The organization is +// a field of the resolved profile, not part of its identity, so this overrides +// the profile's default organization for a single command without changing it. +var INFISICAL_ORG_OVERRIDE string +var INFISICAL_ORG_OVERRIDE_SOURCE string + +// INFISICAL_DOMAIN_EXPLICITLY_SET is true when the domain came from the +// --domain flag or a domain env var. An explicit domain is honored even when +// the resolved profile has its own saved domain. +var INFISICAL_DOMAIN_EXPLICITLY_SET bool diff --git a/packages/models/cli.go b/packages/models/cli.go index a4ab86ad..7004d513 100644 --- a/packages/models/cli.go +++ b/packages/models/cli.go @@ -9,8 +9,64 @@ type UserCredentials struct { RefreshToken string `json:"RefreshToken"` } +// OrgSessionRef records an organization-scoped session cached for a profile +// after an --org/INFISICAL_ORG exchange. Only metadata lives here, enough to +// match a later selector by id, slug, or name without listing organizations. +// The token itself is a separate keyring entry (see util.OrgSessionKeyringKey), +// so the profile's main credential entry never grows: platform keyrings cap a +// single entry at a few kilobytes, which two or three extra session tokens +// would exceed. +type OrgSessionRef struct { + OrgID string `json:"orgId"` + // OrgName is the display name, "Parent / Child" for a sub-organization. + OrgName string `json:"orgName,omitempty"` + OrgSlug string `json:"orgSlug,omitempty"` +} + +// Profile is a named login session: one account on one instance. The +// organization is the profile's default (overridable per command with +// --org/INFISICAL_ORG), not part of its identity. The keyring entry holding the +// session credentials is keyed by Name; profiles migrated from the legacy +// single-session config are named after the account email so their existing +// email-keyed keyring entries keep working. +type Profile struct { + Name string `json:"name"` + Email string `json:"email"` + Domain string `json:"domain"` + // OrganizationID is the root organization of the profile's default + // session (the JWT organizationId claim). When the session is scoped to a + // sub-organization, SubOrganizationID names it and is the organization the + // session actually acts in; use ScopedOrganizationID for that. + OrganizationID string `json:"organizationId,omitempty"` + // OrganizationName is the display name of the scoped organization, + // "Parent / Child" for a sub-organization. + OrganizationName string `json:"organizationName,omitempty"` + // OrganizationSlug is the slug of the scoped organization, so --org by slug + // resolves locally. Empty for profiles written before it was recorded or by + // instances without the sub-organization aware listing. + OrganizationSlug string `json:"organizationSlug,omitempty"` + SubOrganizationID string `json:"subOrganizationId,omitempty"` + // OrgSessions indexes the organization-scoped sessions cached for this + // profile by --org/INFISICAL_ORG. See OrgSessionRef. + OrgSessions []OrgSessionRef `json:"orgSessions,omitempty"` +} + +// ScopedOrganizationID returns the organization the profile's session acts in: +// the sub-organization when scoped to one, otherwise the root organization. +// Projects and API permissions belong to this organization, not the root, so +// callers filtering or comparing organizations must use it. +func (p Profile) ScopedOrganizationID() string { + if p.SubOrganizationID != "" { + return p.SubOrganizationID + } + return p.OrganizationID +} + // The file struct for Infisical config file type ConfigFile struct { + // LoggedInUserEmail, LoggedInUserDomain, and LoggedInUsers predate profiles. + // They are kept in sync with the active profile so older CLI versions and + // scripts that read them keep working. LoggedInUserEmail string `json:"loggedInUserEmail"` LoggedInUserDomain string `json:"LoggedInUserDomain,omitempty"` LoggedInUsers []LoggedInUser `json:"loggedInUsers,omitempty"` @@ -24,6 +80,14 @@ type ConfigFile struct { // happened on an older CLI version that predates the IdentifyUser flow, // or when the email is changed via `infisical user switch`. LastIdentifiedEmail string `json:"lastIdentifiedEmail,omitempty"` + + // ActiveProfile is the global default profile used when no --profile flag, + // INFISICAL_PROFILE env var, or directory scope selects one. + ActiveProfile string `json:"activeProfile,omitempty"` + Profiles []Profile `json:"profiles,omitempty"` + // DirectoryProfiles maps an absolute directory path to the profile name + // that commands run inside that directory (or any subdirectory) should use. + DirectoryProfiles map[string]string `json:"directoryProfiles,omitempty"` } type LoggedInUser struct { diff --git a/packages/telemetry/telemetry.go b/packages/telemetry/telemetry.go index b568304f..1d0aa96b 100644 --- a/packages/telemetry/telemetry.go +++ b/packages/telemetry/telemetry.go @@ -122,7 +122,7 @@ func (t *Telemetry) IdentifyUserIfNeeded() { return } - email := configFile.LoggedInUserEmail + email := util.ActiveAccountEmail(configFile) if email == "" || email == configFile.LastIdentifiedEmail { return } @@ -273,8 +273,8 @@ func (t *Telemetry) GetDistinctId() (string, error) { // 4. Anonymous fallback keyed by the local machine ID. if t.attachedIdentityId != "" { distinctId = "identity-" + t.attachedIdentityId - } else if infisicalConfig.LoggedInUserEmail != "" { - distinctId = infisicalConfig.LoggedInUserEmail + } else if accountEmail := util.ActiveAccountEmail(infisicalConfig); accountEmail != "" { + distinctId = accountEmail } else if envIdentityId, _ := machineIdentityClaimsFromEnv(); envIdentityId != "" { distinctId = "identity-" + envIdentityId } else if machineId != "" { diff --git a/packages/util/auth.go b/packages/util/auth.go index a40f6022..325952ed 100644 --- a/packages/util/auth.go +++ b/packages/util/auth.go @@ -70,8 +70,10 @@ func EstablishUserLoginSession() LoggedInUserDetails { PrintErrorMessageAndExit(fmt.Sprintf("Failed to determine executable path: %v", err)) } + loginArgs := LoginRenewalArgs(ResolveActiveProfileDetails()) + // Spawn infisical login command - loginCmd := exec.Command(exePath, "login", "--silent") + loginCmd := exec.Command(exePath, loginArgs...) loginCmd.Stdin = os.Stdin loginCmd.Stdout = os.Stdout loginCmd.Stderr = os.Stderr diff --git a/packages/util/config.go b/packages/util/config.go index 99bfb47d..bd335f19 100644 --- a/packages/util/config.go +++ b/packages/util/config.go @@ -8,72 +8,10 @@ import ( "os" "path/filepath" - "github.com/Infisical/infisical-merge/packages/config" "github.com/Infisical/infisical-merge/packages/models" "github.com/rs/zerolog/log" ) -func WriteInitalConfig(userCredentials *models.UserCredentials) error { - fullConfigFilePath, fullConfigFileDirPath, err := GetFullConfigFilePath() - if err != nil { - return err - } - - // create directory - if _, err := os.Stat(fullConfigFileDirPath); errors.Is(err, os.ErrNotExist) { - err := os.Mkdir(fullConfigFileDirPath, os.ModePerm) - if err != nil { - return err - } - } - - // get existing config - existingConfigFile, err := GetConfigFile() - if err != nil { - return fmt.Errorf("writeInitalConfig: unable to write config file because [err=%s]", err) - } - - //if profiles exists - loggedInUser := models.LoggedInUser{ - Email: userCredentials.Email, - Domain: config.INFISICAL_URL, - } - //if empty or if email not in loggedinUsers - if len(existingConfigFile.LoggedInUsers) == 0 || !ConfigContainsEmail(existingConfigFile.LoggedInUsers, userCredentials.Email) { - existingConfigFile.LoggedInUsers = append(existingConfigFile.LoggedInUsers, loggedInUser) - } else { - //if exists update domain of loggedin users - for idx, user := range existingConfigFile.LoggedInUsers { - if user.Email == userCredentials.Email { - existingConfigFile.LoggedInUsers[idx] = loggedInUser - } - } - } - - configFile := models.ConfigFile{ - LoggedInUserEmail: userCredentials.Email, - LoggedInUserDomain: config.INFISICAL_URL, - LoggedInUsers: existingConfigFile.LoggedInUsers, - VaultBackendType: existingConfigFile.VaultBackendType, - VaultBackendPassphrase: existingConfigFile.VaultBackendPassphrase, - Domains: existingConfigFile.Domains, - LastIdentifiedEmail: existingConfigFile.LastIdentifiedEmail, - } - - configFileMarshalled, err := json.Marshal(configFile) - if err != nil { - return err - } - - // Create file in directory - err = WriteToFile(fullConfigFilePath, configFileMarshalled, 0600) - if err != nil { - return err - } - - return err -} - func ConfigFileExists() bool { fullConfigFileURI, _, err := GetFullConfigFilePath() if err != nil { diff --git a/packages/util/constants.go b/packages/util/constants.go index 764ad3ca..896fdeac 100644 --- a/packages/util/constants.go +++ b/packages/util/constants.go @@ -53,6 +53,14 @@ const ( INFISICAL_GATEWAY_TOKEN_NAME_LEGACY = "TOKEN" // backwards compatibility with gateway helm chart, where token was the only supported auth method + // Selects the login profile for a single shell/invocation without changing + // the global default (mirrors AWS_PROFILE / OP_ACCOUNT semantics). + INFISICAL_PROFILE_ENV_NAME = "INFISICAL_PROFILE" + + // Selects the organization for a single shell/invocation without changing + // the profile's default organization (mirrors kubectl's namespace scoping). + INFISICAL_ORG_ENV_NAME = "INFISICAL_ORG" + // Generic env variable used for auth methods that require a machine identity ID INFISICAL_MACHINE_IDENTITY_ID_NAME = "INFISICAL_MACHINE_IDENTITY_ID" INFISICAL_DOMAIN_ENV_NAME = "INFISICAL_DOMAIN" diff --git a/packages/util/credentials.go b/packages/util/credentials.go index 194f9327..913ce34a 100644 --- a/packages/util/credentials.go +++ b/packages/util/credentials.go @@ -5,29 +5,74 @@ import ( "errors" "fmt" "strings" + "sync" "time" + "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/config" "github.com/Infisical/infisical-merge/packages/models" jwt "github.com/golang-jwt/jwt/v5" + "github.com/rs/zerolog/log" "github.com/zalando/go-keyring" ) type LoggedInUserDetails struct { - IsUserLoggedIn bool - LoginExpired bool + IsUserLoggedIn bool + LoginExpired bool + // ProfileName is the resolved profile whose session is loaded; it is also + // the keyring key holding UserCredentials. + ProfileName string + // ProfileSource describes how the profile was selected (flag, env var, + // directory scope, or the global default). + ProfileSource string + Profile models.Profile UserCredentials models.UserCredentials + // OrganizationID/Name describe the organization this invocation is actually + // scoped to, which is the profile's default unless --org/INFISICAL_ORG + // selected another one. OrganizationSource says which of the two it was. + OrganizationID string + OrganizationName string + OrganizationSource string } var ErrUserNotLoggedIn = errors.New("we couldn't find your logged in details, try running [infisical login] then try again") -func StoreUserCredsInKeyRing(userCred *models.UserCredentials) error { - userCredMarshalled, err := json.Marshal(userCred) +// ErrProfileNotFound wraps errors caused by an explicitly selected profile +// (--profile flag, INFISICAL_PROFILE env var, or directory scope) that has no +// entry in the config file. Callers that create profiles (login) treat it as +// "not logged in yet" rather than a failure. +var ErrProfileNotFound = errors.New("profile not found") + +// ErrOrgSwitchNeedsMFA is returned when scoping a session to another +// organization requires MFA, which cannot be completed non-interactively. +var ErrOrgSwitchNeedsMFA = errors.New("organization requires MFA") + +// ErrProfileDomainMismatch is returned when an explicitly requested instance is +// not the one the resolved profile belongs to. Commands that create sessions +// (login) treat it as "no usable session yet" rather than a failure. +var ErrProfileDomainMismatch = errors.New("profile belongs to a different instance") + +var domainMismatchNoticeOnce sync.Once + +// StoreUserCredsInKeyRing stores the session credentials under the given +// keyring key. The key is the profile name; for profiles migrated from the +// pre-profile config that name is the account email, which matches the legacy +// keyring entries. +func StoreUserCredsInKeyRing(keyName string, userCred *models.UserCredentials) error { + // Refresh tokens are deliberately never written to the vault. The CLI does + // not renew sessions (see GetCurrentLoggedInUserDetails), so storing one + // would only mean a stolen vault yields a long-lived rotating credential + // instead of an access token that expires with JWT_AUTH_LIFETIME. Clearing + // it here also purges tokens written by earlier versions on the next write. + toStore := *userCred + toStore.RefreshToken = "" + + userCredMarshalled, err := json.Marshal(&toStore) if err != nil { return fmt.Errorf("StoreUserCredsInKeyRing: something went wrong when marshalling user creds [err=%s]", err) } - err = SetValueInKeyring(userCred.Email, string(userCredMarshalled)) + err = SetValueInKeyring(keyName, string(userCredMarshalled)) if err != nil { return fmt.Errorf("StoreUserCredsInKeyRing: unable to store user credentials because [err=%s]", err) } @@ -35,8 +80,61 @@ func StoreUserCredsInKeyRing(userCred *models.UserCredentials) error { return err } -func GetUserCredsFromKeyRing(userEmail string) (credentials models.UserCredentials, err error) { - credentialsValue, err := GetValueInKeyring(userEmail) +// StoreOrgSessionToken caches a session token minted for one organization in +// its own keyring entry. See models.OrgSessionRef for why it is kept apart from +// the profile's main credentials. +func StoreOrgSessionToken(profileName string, orgID string, token string) error { + return SetValueInKeyring(OrgSessionKeyringKey(profileName, orgID), token) +} + +// GetOrgSessionToken loads a cached organization session token. ok is false +// when no entry exists or it cannot be read. +func GetOrgSessionToken(profileName string, orgID string) (token string, ok bool) { + token, err := GetValueInKeyring(OrgSessionKeyringKey(profileName, orgID)) + if err != nil || token == "" { + return "", false + } + return token, true +} + +// DeleteOrgSessionToken removes a cached organization session token. A missing +// entry counts as success. +func DeleteOrgSessionToken(profileName string, orgID string) error { + err := DeleteValueInKeyring(OrgSessionKeyringKey(profileName, orgID)) + if err == nil || IsKeyringEntryAbsent(err) { + return nil + } + return err +} + +// CopyStoredSession stores a copy of a profile's credentials, including its +// cached organization sessions, under another profile name. A profile with no +// stored session copies nothing. Rename uses it, then clears the old entries. +func CopyStoredSession(profile models.Profile, newName string) error { + creds, err := GetUserCredsFromKeyRing(profile.Name) + if err != nil { + if strings.Contains(err.Error(), "credentials not found in system keyring") { + return nil + } + return err + } + if err := StoreUserCredsInKeyRing(newName, &creds); err != nil { + return err + } + for _, ref := range profile.OrgSessions { + token, ok := GetOrgSessionToken(profile.Name, ref.OrgID) + if !ok { + continue + } + if err := StoreOrgSessionToken(newName, ref.OrgID, token); err != nil { + return err + } + } + return nil +} + +func GetUserCredsFromKeyRing(keyName string) (credentials models.UserCredentials, err error) { + credentialsValue, err := GetValueInKeyring(keyName) if err != nil { if err == keyring.ErrUnsupportedPlatform { return models.UserCredentials{}, errors.New("your OS does not support keyring. Consider using a service token https://infisical.com/docs/documentation/platform/token") @@ -58,61 +156,200 @@ func GetUserCredsFromKeyRing(userEmail string) (credentials models.UserCredentia } func GetCurrentLoggedInUserDetails(setConfigVariables bool) (LoggedInUserDetails, error) { - if ConfigFileExists() { - configFile, err := GetConfigFile() - if err != nil { - return LoggedInUserDetails{}, fmt.Errorf("getCurrentLoggedInUserDetails: unable to get logged in user from config file [err=%s]", err) + if !ConfigFileExists() { + return LoggedInUserDetails{}, nil + } + + configFile, err := GetMigratedConfigFile() + if err != nil { + return LoggedInUserDetails{}, fmt.Errorf("getCurrentLoggedInUserDetails: unable to get logged in user from config file [err=%s]", err) + } + + resolved := ResolveProfile(configFile) + if resolved.Name == "" { + return LoggedInUserDetails{}, nil + } + + profile, profileFound := FindProfile(configFile, resolved.Name) + if !profileFound { + if resolved.Source != ProfileSourceDefault { + return LoggedInUserDetails{}, fmt.Errorf("%w: profile '%s' (selected via %s) does not exist. Run [infisical profile list] to see available profiles, or [infisical login --save-as %s] to create it", ErrProfileNotFound, resolved.Name, resolved.Source, resolved.Name) } + // Unmigrated legacy state: treat the email as an implicit profile. + profile = models.Profile{Name: resolved.Name, Email: resolved.Name, Domain: configFile.LoggedInUserDomain} + } - if configFile.LoggedInUserEmail == "" { - return LoggedInUserDetails{}, nil + userCreds, err := GetUserCredsFromKeyRing(profile.Name) + if err != nil { + if strings.Contains(err.Error(), "credentials not found in system keyring") { + return LoggedInUserDetails{}, ErrUserNotLoggedIn + } else { + return LoggedInUserDetails{}, fmt.Errorf("failed to fetch credentials from keyring because [err=%s]", err) } + } - userCreds, err := GetUserCredsFromKeyRing(configFile.LoggedInUserEmail) - if err != nil { - if strings.Contains(err.Error(), "credentials not found in system keyring") { - return LoggedInUserDetails{}, ErrUserNotLoggedIn + if setConfigVariables { + config.INFISICAL_URL_MANUAL_OVERRIDE = config.INFISICAL_URL + if profile.Domain != "" { + profileURL := AppendAPIEndpoint(profile.Domain) + if config.INFISICAL_DOMAIN_EXPLICITLY_SET { + // An explicit domain is honored, but this profile's session was + // issued by a different instance and must not be sent there: it + // would hand a valid bearer token to whoever runs that host. + if profileURL != config.INFISICAL_URL { + return LoggedInUserDetails{}, fmt.Errorf("%w: profile '%s' belongs to %s, but %s was requested. Its session is not valid there and will not be sent. Log in to that instance with [infisical login --domain %s --profile ], or select a profile that uses it with --profile", + ErrProfileDomainMismatch, profile.Name, DisplayDomain(profileURL), DisplayDomain(config.INFISICAL_URL), DisplayDomain(config.INFISICAL_URL)) + } } else { - return LoggedInUserDetails{}, fmt.Errorf("failed to fetch credentials from keyring because [err=%s]", err) + config.INFISICAL_URL = profileURL } } + } + + // Sessions are intentionally not renewed with the refresh token, so a + // session lives at most JWT_AUTH_LIFETIME and expiry sends the user back + // through login. Renewing correctly would require handling the server's + // refresh-token rotation (it invalidates the previous token outside a + // 10-second grace window and treats later reuse as theft by revoking the + // session), which a CLI cannot do safely while several processes share one + // vault entry. The bounded lifetime also keeps forgotten sessions from + // living on indefinitely. + isAuthenticated := !IsJWTExpired(userCreds.JTWToken) + + details := LoggedInUserDetails{ + IsUserLoggedIn: true, // was logged in + LoginExpired: !isAuthenticated, + ProfileName: profile.Name, + ProfileSource: resolved.Source, + Profile: profile, + UserCredentials: userCreds, + // A session scoped to a sub-organization acts in that sub-organization, + // which is where its projects and permissions live, so that is the + // organization reported here rather than the root from the token. + OrganizationID: profile.ScopedOrganizationID(), + OrganizationName: profile.OrganizationName, + OrganizationSource: OrgSourceProfileDefault, + } + + // The organization is a field of the profile, so --org/INFISICAL_ORG can + // retarget this one invocation without touching the profile's default. Only + // on setConfigVariables paths: read-only probes must not make network calls + // or write to the keyring. + if selector, selectorSource := GetOrgOverride(); selector != "" && setConfigVariables && isAuthenticated { + if err := applyOrgOverride(&details, selector, selectorSource); err != nil { + return LoggedInUserDetails{}, err + } + } + + // This is the point where a command actually uses the session, so it is + // where the "Using profile ..." notice belongs. An expired session is about + // to be renewed, and the renewal loads it again, so stay quiet until then. + if setConfigVariables && !details.LoginExpired { + printProfileNotice(resolved, details) + } + + return details, nil +} + +// applyOrgOverride retargets the session to the organization named by the +// --org/INFISICAL_ORG selector, minting and caching a token for it when needed. +func applyOrgOverride(details *LoggedInUserDetails, selector string, selectorSource string) error { + profile := details.Profile + + // What the profile already knows resolves most selectors with no API call: + // its own organization needs no exchange, and a cached organization + // session only needs its token read back. + var target ResolvedOrg + if known, ok := MatchKnownOrg(profile, selector); ok { + if known.IsProfileDefault { + details.OrganizationSource = selectorSource + return nil + } + if token, ok := GetOrgSessionToken(profile.Name, known.OrgID); ok && !IsJWTExpired(token) { + details.UserCredentials.JTWToken = token + details.OrganizationID = known.OrgID + details.OrganizationName = known.OrgName + details.OrganizationSource = selectorSource + return nil + } + // The cached token is gone or expired, but the organization is known, + // so skip the listing and go straight to a fresh exchange. + target = ResolvedOrg{ID: known.OrgID, Name: known.OrgName, Slug: known.OrgSlug} + } else { + resolved, err := ResolveOrgSelector(details.UserCredentials.JTWToken, selector) + if err != nil { + return err + } + target = resolved + } - if setConfigVariables { - config.INFISICAL_URL_MANUAL_OVERRIDE = config.INFISICAL_URL - //configFile.LoggedInUserDomain - //if not empty set as infisical url - if configFile.LoggedInUserDomain != "" { - config.INFISICAL_URL = AppendAPIEndpoint(configFile.LoggedInUserDomain) + if target.ID == profile.ScopedOrganizationID() { + // The selector named the profile's own organization in a way it could + // not match locally, usually a slug recorded as empty by an older + // build. Remember the slug so the next run needs no listing. + details.OrganizationName = target.Name + details.OrganizationSource = selectorSource + if target.Slug != "" && profile.OrganizationSlug == "" { + if err := UpdateStoredProfile(profile.Name, func(p *models.Profile) { p.OrganizationSlug = target.Slug }); err != nil { + log.Debug().Err(err).Msg("unable to record the organization slug on the profile") } } + return nil + } - isAuthenticated := !IsJWTExpired(userCreds.JTWToken) - - // TODO: add refresh token - // if !isAuthenticated { - // accessTokenResponse, err := api.CallGetNewAccessTokenWithRefreshToken(httpClient, userCreds.RefreshToken) - // if err == nil && accessTokenResponse.Token != "" { - // isAuthenticated = true - // userCreds.JTWToken = accessTokenResponse.Token - // } - // } - - if !isAuthenticated { - return LoggedInUserDetails{ - IsUserLoggedIn: true, // was logged in - LoginExpired: true, - UserCredentials: userCreds, - }, nil + orgToken, err := ExchangeSessionForOrganization(details.UserCredentials.JTWToken, target.ID) + if err != nil { + if errors.Is(err, ErrOrgSwitchNeedsMFA) { + return fmt.Errorf("organization '%s' requires MFA, which cannot be completed with %s. Run [infisical profile set-org %s --profile %s] once to verify and cache the session", target.Name, selectorSource, selector, profile.Name) } + return fmt.Errorf("unable to scope your session to organization '%s' [err=%s]", target.Name, err) + } - return LoggedInUserDetails{ - IsUserLoggedIn: true, - LoginExpired: false, - UserCredentials: userCreds, - }, nil + // Cache the token in its own keyring entry and index it on the profile, so + // later runs skip both the listing and the exchange. The profile's own + // session token is left alone: --org retargets a single command, and + // writing the organization token as the primary one would silently repoint + // the profile. Both writes are best effort; this run already holds the + // token in memory. + if err := StoreOrgSessionToken(profile.Name, target.ID, orgToken); err != nil { + log.Debug().Err(err).Msg("unable to cache organization-scoped session token") } else { - return LoggedInUserDetails{}, nil + ref := models.OrgSessionRef{OrgID: target.ID, OrgName: target.Name, OrgSlug: target.Slug} + if err := UpdateStoredProfile(profile.Name, func(p *models.Profile) { RecordOrgSession(p, ref) }); err != nil { + log.Debug().Err(err).Msg("unable to index the cached organization session on the profile") + } + } + + // Only this invocation runs against the organization-scoped token. + details.UserCredentials.JTWToken = orgToken + details.OrganizationID = target.ID + details.OrganizationName = target.Name + details.OrganizationSource = selectorSource + return nil +} + +// ExchangeSessionForOrganization trades a valid session token for one scoped to +// the given organization. Returns ErrOrgSwitchNeedsMFA when the organization +// requires MFA, which callers must handle interactively. +func ExchangeSessionForOrganization(sessionToken string, orgID string) (string, error) { + httpClient, err := GetRestyClientWithCustomHeaders() + if err != nil { + return "", err } + httpClient.SetAuthToken(sessionToken) + + selectOrgRes, err := api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: orgID}) + if err != nil { + return "", err + } + if selectOrgRes.MfaEnabled { + return "", ErrOrgSwitchNeedsMFA + } + if selectOrgRes.Token == "" { + return "", errors.New("the server returned an empty session token") + } + + return selectOrgRes.Token, nil } func IsJWTExpired(token string) bool { diff --git a/packages/util/helper.go b/packages/util/helper.go index 2c8625bb..7b00f9aa 100644 --- a/packages/util/helper.go +++ b/packages/util/helper.go @@ -380,17 +380,19 @@ func ConfigContainsEmail(users []models.LoggedInUser, email string) bool { } func RequireLogin() { - // get the config file that stores the current logged in user email + // get the config file that stores login profiles configFile, _ := GetConfigFile() + MigrateConfigProfiles(&configFile) - if configFile.LoggedInUserEmail == "" { + if ResolveProfile(configFile).Name == "" { EstablishUserLoginSession() } } func IsLoggedIn() bool { configFile, _ := GetConfigFile() - return configFile.LoggedInUserEmail != "" + MigrateConfigProfiles(&configFile) + return ResolveProfile(configFile).Name != "" } func RequireServiceToken() { diff --git a/packages/util/logout.go b/packages/util/logout.go new file mode 100644 index 00000000..76d9fde0 --- /dev/null +++ b/packages/util/logout.go @@ -0,0 +1,241 @@ +package util + +import ( + "errors" + "fmt" + "os" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/config" + "github.com/Infisical/infisical-merge/packages/models" + "github.com/rs/zerolog/log" + "github.com/zalando/go-keyring" +) + +// LogoutResult reports what happened to one profile during a logout. +type LogoutResult struct { + ProfileName string + // HadSession is false when the profile had no stored credentials, e.g. + // because it was already logged out. + HadSession bool + // Revoked is true when at least one server-side session was revoked. + Revoked bool + // SharedWith names a profile that still uses the same server session, in + // which case the session is left alone and only local credentials are + // removed. + SharedWith string + // RevokeErr is set when revocation was attempted and failed. Local + // credentials are still removed in that case. + RevokeErr error + // LocalErr is set when the stored credentials could not be removed. + LocalErr error +} + +// profileTokens loads every session token stored for a profile: its own and +// the cached organization-scoped ones listed in its index. ok is false when the +// profile has no stored session at all. +func profileTokens(profile models.Profile) (tokens []string, ok bool) { + creds, err := GetUserCredsFromKeyRing(profile.Name) + if err != nil { + return nil, false + } + if creds.JTWToken != "" { + tokens = append(tokens, creds.JTWToken) + } + for _, ref := range profile.OrgSessions { + if token, found := GetOrgSessionToken(profile.Name, ref.OrgID); found { + tokens = append(tokens, token) + } + } + return tokens, true +} + +// collectSessionIDs returns every distinct server-side session id represented +// by the given tokens. +func collectSessionIDs(tokens []string) []string { + seen := map[string]bool{} + ids := []string{} + for _, token := range tokens { + if id := ParseTokenSessionID(token); id != "" && !seen[id] { + seen[id] = true + ids = append(ids, id) + } + } + return ids +} + +// liveToken returns the first token that is still valid. Tokens are ordered +// with the profile's own first; an organization token cached later can outlive +// it, and revocation needs some live token to authenticate with. +func liveToken(tokens []string) string { + for _, token := range tokens { + if token != "" && !IsJWTExpired(token) { + return token + } + } + return "" +} + +// IsKeyringEntryAbsent reports whether a keyring delete or read failed only +// because the entry does not exist. The system keyrings report +// keyring.ErrNotFound; the encrypted file backend surfaces the missing file. +func IsKeyringEntryAbsent(err error) bool { + return errors.Is(err, keyring.ErrNotFound) || errors.Is(err, os.ErrNotExist) +} + +// ClearStoredSession removes a profile's stored credentials, including its +// cached organization sessions. Entries that are already absent count as +// success, since the goal is that nothing remains. +func ClearStoredSession(profile models.Profile) error { + var firstErr error + if err := DeleteValueInKeyring(profile.Name); err != nil && !IsKeyringEntryAbsent(err) { + firstErr = err + } + for _, ref := range profile.OrgSessions { + if err := DeleteOrgSessionToken(profile.Name, ref.OrgID); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +// RevokeSession ends a server-side session by id, authenticating with a token +// that belongs to the account owning it. +func RevokeSession(sessionToken string, sessionID string) error { + httpClient, err := GetRestyClientWithCustomHeaders() + if err != nil { + return err + } + httpClient.SetAuthToken(sessionToken) + + return api.CallRevokeUserSession(httpClient, sessionID) +} + +// LogoutProfiles revokes the server-side sessions belonging to targetNames and +// removes their stored credentials. +// +// The server keys sessions by user, IP, and user agent, so several profiles for +// the same account on one machine share a single session. A session still used +// by a profile that is not being logged out is therefore left intact, and only +// the local credentials are removed; otherwise logging out of one tenant would +// silently sign the user out of the others. +func LogoutProfiles(configFile models.ConfigFile, targetNames []string, localOnly bool) []LogoutResult { + targets := map[string]bool{} + for _, name := range targetNames { + targets[name] = true + } + + // Session ids that must survive because a profile we are keeping uses them. + retained := map[string]string{} + for _, profile := range configFile.Profiles { + if targets[profile.Name] { + continue + } + tokens, ok := profileTokens(profile) + if !ok { + continue + } + for _, id := range collectSessionIDs(tokens) { + retained[id] = profile.Name + } + } + + results := make([]LogoutResult, 0, len(targetNames)) + for _, name := range targetNames { + result := LogoutResult{ProfileName: name} + + profile, found := FindProfile(configFile, name) + if !found { + profile = models.Profile{Name: name} + } + + tokens, ok := profileTokens(profile) + if !ok { + results = append(results, result) + continue + } + result.HadSession = true + + if !localOnly { + // Any unexpired token authenticates revocation. Checking only the + // profile's own token would skip revocation while a cached + // organization token was still usable, leaving it live on the + // server after the local copy was deleted. + authToken := liveToken(tokens) + for _, sessionID := range collectSessionIDs(tokens) { + if owner, shared := retained[sessionID]; shared { + result.SharedWith = owner + continue + } + if authToken == "" { + result.RevokeErr = fmt.Errorf("every stored session has expired, so none could be revoked") + continue + } + if err := RevokeSession(authToken, sessionID); err != nil { + result.RevokeErr = err + log.Debug().Err(err).Str("profile", name).Msg("unable to revoke session") + continue + } + result.Revoked = true + } + } + + if err := ClearStoredSession(profile); err != nil { + result.LocalErr = err + log.Debug().Err(err).Str("profile", name).Msg("unable to remove stored credentials") + } + + results = append(results, result) + } + + return results +} + +// SessionStatus describes whether a profile currently holds usable credentials. +func SessionStatus(profile models.Profile) string { + creds, err := GetUserCredsFromKeyRing(profile.Name) + if err != nil { + return "none" + } + if IsJWTExpired(creds.JTWToken) { + return "expired" + } + if cached := len(profile.OrgSessions); cached > 0 { + return fmt.Sprintf("active (+%d org)", cached) + } + return "active" +} + +// LogoutProfilesAcrossDomains logs out profiles that may live on different +// Infisical instances, pointing each revocation at the instance that issued the +// session. The process-wide domain is restored afterwards. +func LogoutProfilesAcrossDomains(configFile models.ConfigFile, targetNames []string, localOnly bool) []LogoutResult { + originalURL := config.INFISICAL_URL + defer func() { config.INFISICAL_URL = originalURL }() + + byDomain := map[string][]string{} + for _, name := range targetNames { + domain := originalURL + if profile, found := FindProfile(configFile, name); found && profile.Domain != "" { + domain = AppendAPIEndpoint(profile.Domain) + } + byDomain[domain] = append(byDomain[domain], name) + } + + // Preserve the caller's ordering in the combined result. + resultsByName := map[string]LogoutResult{} + for domain, names := range byDomain { + config.INFISICAL_URL = domain + for _, result := range LogoutProfiles(configFile, names, localOnly) { + resultsByName[result.ProfileName] = result + } + } + + results := make([]LogoutResult, 0, len(targetNames)) + for _, name := range targetNames { + if result, ok := resultsByName[name]; ok { + results = append(results, result) + } + } + return results +} diff --git a/packages/util/profile.go b/packages/util/profile.go new file mode 100644 index 00000000..83ae8606 --- /dev/null +++ b/packages/util/profile.go @@ -0,0 +1,991 @@ +package util + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/config" + "github.com/Infisical/infisical-merge/packages/models" + jwt "github.com/golang-jwt/jwt/v5" + "github.com/rs/zerolog/log" +) + +// Human-readable labels for where the active profile selection came from. +const ( + ProfileSourceFlag = "--profile flag" + ProfileSourceEnv = INFISICAL_PROFILE_ENV_NAME + " environment variable" + ProfileSourceDirectory = "directory scope" + ProfileSourceDefault = "default profile" +) + +// Human-readable labels for where the organization selection came from. +const ( + OrgSourceFlag = "--org flag" + OrgSourceEnv = INFISICAL_ORG_ENV_NAME + " environment variable" + OrgSourceProfileDefault = "profile default" +) + +// Profile names double as keyring keys, so keep them to the character set +// already proven safe there (emails, including plus-addressed ones, are the +// historical keys). Applies only to user-typed names; derived names (raw +// emails) are stored as-is. +var profileNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9@._+-]*$`) + +// ResolvedProfile describes which profile an invocation resolved to and why. +type ResolvedProfile struct { + Name string + Source string + // ScopeDir is the directory whose binding selected the profile. Only set + // when Source is ProfileSourceDirectory. + ScopeDir string + // ShadowedName and ShadowedScopeDir record a directory binding that an + // explicit override took precedence over. Commands report it so that a + // binding quietly not applying is explained rather than surprising. + ShadowedName string + ShadowedScopeDir string +} + +// MaxProfileNameLength bounds user-typed profile names. Names double as +// keyring keys, and platform keyrings cap the size of one entry (about 4 KB on +// macOS for key and value together), so an overlong name could make storing +// the session fail after the login itself succeeded. +const MaxProfileNameLength = 64 + +func ValidateProfileName(name string) error { + if len(name) > MaxProfileNameLength { + return fmt.Errorf("invalid profile name: %d characters is too long, use at most %d", len(name), MaxProfileNameLength) + } + if !profileNamePattern.MatchString(name) { + return fmt.Errorf("invalid profile name '%s': use letters, digits, and the characters @ . _ + - (must start with a letter or digit)", name) + } + return nil +} + +// MigrateConfigProfiles synthesizes profile entries from the legacy +// LoggedInUserEmail/LoggedInUsers fields. Migrated profiles are named after +// the account email, which is also the legacy keyring key, so existing keyring +// entries keep working without being rewritten. Safe to call repeatedly. +// Returns true when the config was modified. +func MigrateConfigProfiles(configFile *models.ConfigFile) bool { + changed := false + + // A roster/LoggedInUserEmail entry only represents a legacy session when no + // profile covers that account yet. Entries whose account already has a + // profile (under any name) are compat mirrors written by profile-aware CLI + // versions, and synthesizing a profile from them would create a phantom + // with no keyring session behind it. + anyProfileForEmail := func(email string) bool { + for _, profile := range configFile.Profiles { + if profile.Email == email { + return true + } + } + return false + } + + for _, user := range configFile.LoggedInUsers { + if user.Email == "" || anyProfileForEmail(user.Email) { + continue + } + configFile.Profiles = append(configFile.Profiles, models.Profile{ + Name: user.Email, + Email: user.Email, + Domain: user.Domain, + }) + changed = true + } + + if configFile.LoggedInUserEmail != "" && !anyProfileForEmail(configFile.LoggedInUserEmail) { + configFile.Profiles = append(configFile.Profiles, models.Profile{ + Name: configFile.LoggedInUserEmail, + Email: configFile.LoggedInUserEmail, + Domain: configFile.LoggedInUserDomain, + }) + changed = true + } + + // Reconcile the active pointer. When an older CLI version switched users it + // only moved LoggedInUserEmail, so a divergence between the two fields means + // the legacy pointer is the fresher one. Prefer the profile named after the + // email (the migrated default); otherwise any profile for that account. + if configFile.LoggedInUserEmail != "" { + activeIdx := findProfileIndex(configFile.Profiles, configFile.ActiveProfile) + if activeIdx < 0 || configFile.Profiles[activeIdx].Email != configFile.LoggedInUserEmail { + targetIdx := findProfileIndex(configFile.Profiles, configFile.LoggedInUserEmail) + if targetIdx < 0 { + for idx, profile := range configFile.Profiles { + if profile.Email == configFile.LoggedInUserEmail { + targetIdx = idx + break + } + } + } + if targetIdx >= 0 && configFile.ActiveProfile != configFile.Profiles[targetIdx].Name { + configFile.ActiveProfile = configFile.Profiles[targetIdx].Name + changed = true + } + } + } + + return changed +} + +// GetMigratedConfigFile loads the config file and migrates legacy login state +// into profiles, persisting the migration once so later reads are stable. +func GetMigratedConfigFile() (models.ConfigFile, error) { + configFile, err := GetConfigFile() + if err != nil { + return models.ConfigFile{}, err + } + + if MigrateConfigProfiles(&configFile) && ConfigFileExists() { + if err := WriteConfigFile(&configFile); err != nil { + // The in-memory migration is still usable; persisting is best effort. + log.Debug().Err(err).Msg("unable to persist profile migration") + } + } + + return configFile, nil +} + +// GetProfileOverride returns the per-invocation profile selection (--profile +// flag or INFISICAL_PROFILE env var) and a label describing where it came from. +func GetProfileOverride() (name string, source string) { + return config.INFISICAL_PROFILE_OVERRIDE, config.INFISICAL_PROFILE_OVERRIDE_SOURCE +} + +// GetOrgOverride returns the per-invocation organization selection (--org flag +// or INFISICAL_ORG env var) and a label describing where it came from. The +// value may be an organization ID, slug, or name; it is resolved against the +// account's organizations only when it is actually needed. +func GetOrgOverride() (selector string, source string) { + return config.INFISICAL_ORG_OVERRIDE, config.INFISICAL_ORG_OVERRIDE_SOURCE +} + +// ActiveAccountEmail returns the email of the profile a command would use, for +// callers that need the account rather than the session. It prefers profile +// state over the legacy pointer, which is only published for email-named +// profiles. +func ActiveAccountEmail(configFile models.ConfigFile) string { + if resolved := ResolveProfile(configFile); resolved.Name != "" { + if profile, found := FindProfile(configFile, resolved.Name); found && profile.Email != "" { + return profile.Email + } + } + return configFile.LoggedInUserEmail +} + +// ShellQuote renders a value safe to embed in a shell statement, using POSIX +// single-quote escaping. Profile names can be derived from an email supplied by +// the server, and pin prints them into output meant for eval, so an unescaped +// name would let a malicious response run commands. +func ShellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'" +} + +// SanitizeDisplay strips control characters from values that came from the +// server before they reach a terminal. Organization and profile names are +// echoed on ordinary commands, and escape sequences there could forge output or +// drive terminal features. +func SanitizeDisplay(value string) string { + return strings.Map(func(r rune) rune { + if r == '\t' { + return ' ' + } + if r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f) { + return -1 + } + return r + }, value) +} + +// SuspendOrgOverride temporarily clears the --org/INFISICAL_ORG selection and +// returns a function that restores it. Commands that perform their own +// organization exchange use this so that session resolution does not try to +// apply the override first, which cannot prompt and therefore fails outright +// for organizations that require MFA. +func SuspendOrgOverride() func() { + selector, source := config.INFISICAL_ORG_OVERRIDE, config.INFISICAL_ORG_OVERRIDE_SOURCE + config.INFISICAL_ORG_OVERRIDE, config.INFISICAL_ORG_OVERRIDE_SOURCE = "", "" + return func() { + config.INFISICAL_ORG_OVERRIDE, config.INFISICAL_ORG_OVERRIDE_SOURCE = selector, source + } +} + +// Match tiers for an --org/INFISICAL_ORG selector, most specific first. An id +// is unique and server-assigned, a slug is unique per instance, and a name is +// neither, so they must not be treated as interchangeable: an organization the +// user also belongs to could otherwise be named after another one's id or slug +// and be selected in its place. +const ( + orgMatchNone = 0 + orgMatchName = 1 + orgMatchSlug = 2 + orgMatchID = 3 +) + +// orgDisplaySeparator joins a parent organization and a sub-organization in +// display names, as in "Acme / Research". +const orgDisplaySeparator = " / " + +// JoinOrgDisplayName renders a sub-organization as "Parent / Child". With no +// parent the name is returned as-is. +func JoinOrgDisplayName(parent, own string) string { + if parent == "" { + return own + } + return parent + orgDisplaySeparator + own +} + +// SplitOrgDisplayName is the inverse of JoinOrgDisplayName. A plain name comes +// back with an empty parent. +func SplitOrgDisplayName(display string) (parent, own string) { + idx := strings.LastIndex(display, orgDisplaySeparator) + if idx < 0 { + return "", display + } + return display[:idx], display[idx+len(orgDisplaySeparator):] +} + +// OrgMatchTier reports how strongly a selector matches an organization, using +// the tiers above. Slug and name comparisons are case-insensitive so +// `--org globex` matches an organization named "Globex". name may be a display +// name of the form "Parent / Child", in which case the sub-organization also +// matches on its own name, so `--org research` finds "Acme / Research". +func OrgMatchTier(selector, id, slug, name string) int { + if selector == "" { + return orgMatchNone + } + if id != "" && strings.EqualFold(selector, id) { + return orgMatchID + } + if slug != "" && strings.EqualFold(selector, slug) { + return orgMatchSlug + } + if name != "" { + if strings.EqualFold(selector, name) { + return orgMatchName + } + if _, own := SplitOrgDisplayName(name); own != name && strings.EqualFold(selector, own) { + return orgMatchName + } + } + return orgMatchNone +} + +// OrgMatchesSelector reports whether an organization matches a selector at all. +// Callers choosing between several candidates must compare tiers with +// OrgMatchTier instead, so that a weaker match cannot shadow a stronger one. +func OrgMatchesSelector(selector, id, slug, name string) bool { + return OrgMatchTier(selector, id, slug, name) != orgMatchNone +} + +// ResolvedOrg is an organization selector resolved against the account. +type ResolvedOrg struct { + ID string + // Name is the display name, "Parent / Child" for a sub-organization. + Name string + Slug string +} + +// ResolveOrgSelector turns an --org/INFISICAL_ORG selector (ID, slug, or name) +// into a concrete organization, searching both root organizations and +// sub-organizations. The sessionToken is only used to list organizations. +func ResolveOrgSelector(sessionToken string, selector string) (ResolvedOrg, error) { + if selector == "" { + return ResolvedOrg{}, errors.New("no organization specified") + } + + httpClient, err := GetRestyClientWithCustomHeaders() + if err != nil { + return ResolvedOrg{}, err + } + httpClient.SetAuthToken(sessionToken) + + // Collect every organization first, then pick the strongest match across + // all of them, so that ordering cannot decide the outcome. + candidates := []ResolvedOrg{} + if subOrgsResp, err := api.CallGetAllOrganizationsWithSubOrgs(httpClient); err == nil { + for _, org := range subOrgsResp.Organizations { + candidates = append(candidates, ResolvedOrg{ID: org.ID, Name: org.Name, Slug: org.Slug}) + for _, sub := range org.SubOrganizations { + candidates = append(candidates, ResolvedOrg{ID: sub.ID, Name: JoinOrgDisplayName(org.Name, sub.Name), Slug: sub.Slug}) + } + } + } + if len(candidates) == 0 { + // Older instances may not expose the sub-org endpoint. + if orgResp, err := api.CallGetAllOrganizations(httpClient); err == nil { + for _, org := range orgResp.Organizations { + candidates = append(candidates, ResolvedOrg{ID: org.ID, Name: org.Name}) + } + } + } + + best := ResolvedOrg{} + bestTier := orgMatchNone + ambiguous := false + for _, candidate := range candidates { + tier := OrgMatchTier(selector, candidate.ID, candidate.Slug, candidate.Name) + switch { + case tier > bestTier: + best, bestTier, ambiguous = candidate, tier, false + case tier == bestTier && tier != orgMatchNone && candidate.ID != best.ID: + ambiguous = true + } + } + + if bestTier == orgMatchNone { + return ResolvedOrg{}, fmt.Errorf("organization '%s' not found for this account. Run [infisical org list] to see available organizations", selector) + } + if ambiguous { + return ResolvedOrg{}, fmt.Errorf("organization '%s' is ambiguous: several organizations match it. Use the organization id instead, which [infisical org list] shows", selector) + } + + return best, nil +} + +// KnownOrgMatch is an organization the profile already knows about: its own +// default organization or a cached organization session. +type KnownOrgMatch struct { + OrgID string + OrgName string + OrgSlug string + // IsProfileDefault is true when the match is the profile's own + // organization, which needs no exchange at all. + IsProfileDefault bool +} + +// MatchKnownOrg resolves an --org/INFISICAL_ORG selector against what the +// profile already knows, so repeated use of the same organization makes no +// API calls: the profile's default organization and every cached organization +// session are compared by id, slug, and name, and the strongest match wins. A +// tie between two different organizations at the strongest tier is not a +// match, so the server-side listing, which reports the ambiguity, decides. +func MatchKnownOrg(profile models.Profile, selector string) (KnownOrgMatch, bool) { + best := KnownOrgMatch{} + bestTier := orgMatchNone + ambiguous := false + + consider := func(candidate KnownOrgMatch) { + tier := OrgMatchTier(selector, candidate.OrgID, candidate.OrgSlug, candidate.OrgName) + switch { + case tier > bestTier: + best, bestTier, ambiguous = candidate, tier, false + case tier == bestTier && tier != orgMatchNone && candidate.OrgID != best.OrgID: + ambiguous = true + } + } + + if scopedID := profile.ScopedOrganizationID(); scopedID != "" { + consider(KnownOrgMatch{OrgID: scopedID, OrgName: profile.OrganizationName, OrgSlug: profile.OrganizationSlug, IsProfileDefault: true}) + } + for _, ref := range profile.OrgSessions { + consider(KnownOrgMatch{OrgID: ref.OrgID, OrgName: ref.OrgName, OrgSlug: ref.OrgSlug}) + } + + if bestTier == orgMatchNone || ambiguous { + return KnownOrgMatch{}, false + } + return best, true +} + +// OrgSessionKeyringKey is the keyring entry holding the session token cached +// for one organization under a profile. Profile names cannot contain ':' (see +// ValidateProfileName; emails do not either), so the key cannot collide with a +// profile's own entry. +func OrgSessionKeyringKey(profileName string, orgID string) string { + return "org-session:" + profileName + ":" + orgID +} + +// RecordOrgSession adds or refreshes the index entry for a cached organization +// session on the profile, in memory. +func RecordOrgSession(profile *models.Profile, ref models.OrgSessionRef) { + for idx := range profile.OrgSessions { + if profile.OrgSessions[idx].OrgID == ref.OrgID { + profile.OrgSessions[idx] = ref + return + } + } + profile.OrgSessions = append(profile.OrgSessions, ref) +} + +// RemoveOrgSession drops the index entry for an organization, in memory, and +// reports whether one existed. The caller deletes the keyring entry. +func RemoveOrgSession(profile *models.Profile, orgID string) bool { + for idx, ref := range profile.OrgSessions { + if ref.OrgID == orgID { + profile.OrgSessions = append(profile.OrgSessions[:idx], profile.OrgSessions[idx+1:]...) + return true + } + } + return false +} + +// UpdateStoredProfile applies mutate to the named profile in the config file +// and saves it. The file is reloaded first, so only that profile changes and +// edits made by other commands in the meantime are kept. +func UpdateStoredProfile(name string, mutate func(profile *models.Profile)) error { + configFile, err := GetMigratedConfigFile() + if err != nil { + return err + } + idx := findProfileIndex(configFile.Profiles, name) + if idx < 0 { + return fmt.Errorf("profile '%s' does not exist", name) + } + mutate(&configFile.Profiles[idx]) + if configFile.ActiveProfile == name { + syncLegacyLoginFields(&configFile, configFile.Profiles[idx]) + } + return WriteConfigFile(&configFile) +} + +// ResolveProfile determines which profile this invocation should use: +// --profile flag > INFISICAL_PROFILE env var > directory scope > global default. +func ResolveProfile(configFile models.ConfigFile) ResolvedProfile { + override, overrideSource := GetProfileOverride() + cwd, err := os.Getwd() + if err != nil { + cwd = "" + } + return resolveProfileWith(configFile, override, overrideSource, cwd) +} + +func resolveProfileWith(configFile models.ConfigFile, override string, overrideSource string, cwd string) ResolvedProfile { + if override != "" { + if overrideSource == "" { + overrideSource = ProfileSourceFlag + } + resolved := ResolvedProfile{Name: override, Source: overrideSource} + // A binding for this directory still exists, it just lost. Remember it + // so the user is told why it did not apply. + if cwd != "" { + if name, scopeDir, ok := lookupDirectoryProfile(configFile, cwd); ok && name != override { + resolved.ShadowedName = name + resolved.ShadowedScopeDir = scopeDir + } + } + return resolved + } + + if cwd != "" { + if name, scopeDir, ok := lookupDirectoryProfile(configFile, cwd); ok { + return ResolvedProfile{Name: name, Source: ProfileSourceDirectory, ScopeDir: scopeDir} + } + } + + if configFile.ActiveProfile != "" { + return ResolvedProfile{Name: configFile.ActiveProfile, Source: ProfileSourceDefault} + } + + // Config written by an older CLI that was never migrated (e.g. read-only + // config directory): fall back to the legacy field, which is also the + // profile name migration would have chosen. + if configFile.LoggedInUserEmail != "" { + return ResolvedProfile{Name: configFile.LoggedInUserEmail, Source: ProfileSourceDefault} + } + + return ResolvedProfile{} +} + +// lookupDirectoryProfile finds the directory binding governing cwd by walking +// from cwd up to the filesystem root; the nearest bound ancestor wins. +func lookupDirectoryProfile(configFile models.ConfigFile, cwd string) (name string, scopeDir string, found bool) { + if len(configFile.DirectoryProfiles) == 0 { + return "", "", false + } + + dir := filepath.Clean(cwd) + for { + if profileName, ok := configFile.DirectoryProfiles[dir]; ok && profileName != "" { + return profileName, dir, true + } + + parent := filepath.Dir(dir) + if parent == dir { + return "", "", false + } + dir = parent + } +} + +// FindGoverningDirectoryProfile returns the binding that would apply to the +// given directory, if any. +func FindGoverningDirectoryProfile(configFile models.ConfigFile, dir string) (name string, scopeDir string, found bool) { + return lookupDirectoryProfile(configFile, dir) +} + +// SetDirectoryProfile binds a directory (and its subtree) to a profile name. +func SetDirectoryProfile(configFile *models.ConfigFile, dir string, name string) { + if configFile.DirectoryProfiles == nil { + configFile.DirectoryProfiles = map[string]string{} + } + configFile.DirectoryProfiles[filepath.Clean(dir)] = name +} + +// RemoveDirectoryProfile removes an exact directory binding. Returns whether +// a binding existed. +func RemoveDirectoryProfile(configFile *models.ConfigFile, dir string) bool { + cleaned := filepath.Clean(dir) + if _, ok := configFile.DirectoryProfiles[cleaned]; !ok { + return false + } + delete(configFile.DirectoryProfiles, cleaned) + return true +} + +func findProfileIndex(profiles []models.Profile, name string) int { + if name == "" { + return -1 + } + for idx, profile := range profiles { + if profile.Name == name { + return idx + } + } + return -1 +} + +func FindProfile(configFile models.ConfigFile, name string) (models.Profile, bool) { + if idx := findProfileIndex(configFile.Profiles, name); idx >= 0 { + return configFile.Profiles[idx], true + } + return models.Profile{}, false +} + +// UpsertProfile inserts the profile or replaces the existing one with the same name. +func UpsertProfile(configFile *models.ConfigFile, profile models.Profile) { + if idx := findProfileIndex(configFile.Profiles, profile.Name); idx >= 0 { + configFile.Profiles[idx] = profile + return + } + configFile.Profiles = append(configFile.Profiles, profile) +} + +// SetActiveProfile marks the profile as the global default and keeps the +// legacy single-user fields in sync so older CLI versions and scripts that +// read them keep working. +func SetActiveProfile(configFile *models.ConfigFile, name string) error { + profile, found := FindProfile(*configFile, name) + if !found { + return fmt.Errorf("profile '%s' does not exist", name) + } + + configFile.ActiveProfile = name + syncLegacyLoginFields(configFile, profile) + return nil +} + +func syncLegacyLoginFields(configFile *models.ConfigFile, profile models.Profile) { + // Older CLI versions load the keyring entry named by LoggedInUserEmail. That + // is only this profile's own entry when the profile is named after the + // email; otherwise an old binary would read some other profile's token while + // pointed at this profile's instance. Leave the legacy pointer empty in that + // case so an old binary asks for a fresh login instead. + if profile.Name != profile.Email { + configFile.LoggedInUserEmail = "" + configFile.LoggedInUserDomain = "" + return + } + + configFile.LoggedInUserEmail = profile.Email + configFile.LoggedInUserDomain = profile.Domain + + if profile.Email == "" { + return + } + + loggedInUser := models.LoggedInUser{Email: profile.Email, Domain: profile.Domain} + if !ConfigContainsEmail(configFile.LoggedInUsers, profile.Email) { + configFile.LoggedInUsers = append(configFile.LoggedInUsers, loggedInUser) + return + } + for idx, user := range configFile.LoggedInUsers { + if user.Email == profile.Email { + configFile.LoggedInUsers[idx] = loggedInUser + } + } +} + +// RepointProfileDomain moves exactly one profile to another instance. Only the +// named profile changes, even when other profiles share its email and current +// instance, because each profile holds its own session and a session issued by +// the previous instance must not follow any of them to the new one. +// +// The organization recorded on the profile described the previous instance, so +// it is cleared. The legacy roster entry is updated only when no remaining +// profile still uses the old instance. Returns false when the profile does not +// exist or already uses that instance; the caller clears the stored session. +func RepointProfileDomain(configFile *models.ConfigFile, profileName string, newDomain string) bool { + idx := findProfileIndex(configFile.Profiles, profileName) + if idx < 0 { + return false + } + + previousDomain := configFile.Profiles[idx].Domain + if AppendAPIEndpoint(previousDomain) == AppendAPIEndpoint(newDomain) { + return false + } + + email := configFile.Profiles[idx].Email + configFile.Profiles[idx].Domain = newDomain + configFile.Profiles[idx].OrganizationID = "" + configFile.Profiles[idx].OrganizationName = "" + configFile.Profiles[idx].OrganizationSlug = "" + configFile.Profiles[idx].SubOrganizationID = "" + configFile.Profiles[idx].OrgSessions = nil + + stillOnPreviousDomain := false + for _, profile := range configFile.Profiles { + if profile.Name != profileName && profile.Email == email && profile.Domain == previousDomain { + stillOnPreviousDomain = true + break + } + } + if !stillOnPreviousDomain { + for i, user := range configFile.LoggedInUsers { + if user.Email == email && user.Domain == previousDomain { + configFile.LoggedInUsers[i].Domain = newDomain + break + } + } + } + + if configFile.ActiveProfile == profileName { + // Re-sync so the legacy pointer reflects the moved profile. + _ = SetActiveProfile(configFile, profileName) + } + + return true +} + +// RenameProfile changes a profile's name everywhere the config refers to it: +// the entry itself, the default-profile pointer, and directory bindings. The +// legacy fields are re-synced, since they are only published for profiles +// named after their email. The caller moves the keyring entries, which are +// keyed by name. +func RenameProfile(configFile *models.ConfigFile, oldName string, newName string) error { + if oldName == newName { + return fmt.Errorf("profile is already named '%s'", oldName) + } + idx := findProfileIndex(configFile.Profiles, oldName) + if idx < 0 { + return fmt.Errorf("profile '%s' does not exist", oldName) + } + if findProfileIndex(configFile.Profiles, newName) >= 0 { + return fmt.Errorf("profile '%s' already exists", newName) + } + + configFile.Profiles[idx].Name = newName + for dir, name := range configFile.DirectoryProfiles { + if name == oldName { + configFile.DirectoryProfiles[dir] = newName + } + } + if configFile.ActiveProfile == oldName { + return SetActiveProfile(configFile, newName) + } + return nil +} + +// RemoveProfile deletes the profile, any directory bindings pointing at it, +// and reconciles the active pointer and legacy fields. The caller is +// responsible for deleting the keyring entry. +func RemoveProfile(configFile *models.ConfigFile, name string) bool { + idx := findProfileIndex(configFile.Profiles, name) + if idx < 0 { + return false + } + + removed := configFile.Profiles[idx] + configFile.Profiles = append(configFile.Profiles[:idx], configFile.Profiles[idx+1:]...) + + for dir, profileName := range configFile.DirectoryProfiles { + if profileName == name { + delete(configFile.DirectoryProfiles, dir) + } + } + + // Drop the legacy roster entry when no remaining profile uses that account. + emailStillUsed := false + for _, profile := range configFile.Profiles { + if profile.Email == removed.Email { + emailStillUsed = true + break + } + } + if !emailStillUsed { + users := configFile.LoggedInUsers[:0] + for _, user := range configFile.LoggedInUsers { + if user.Email != removed.Email { + users = append(users, user) + } + } + configFile.LoggedInUsers = users + } + + if configFile.ActiveProfile == name { + configFile.ActiveProfile = "" + configFile.LoggedInUserEmail = "" + configFile.LoggedInUserDomain = "" + } + + return true +} + +// DeriveProfileName picks the profile name for a login session when the user +// did not name one explicitly. Rules, in order: reuse the profile that already +// holds this account+instance+organization; adopt a pre-profile (migrated) +// entry for the same account+instance whose organization is still unknown; +// otherwise name the profile after the account and organization, as in +// "scott@example.com--acme-x4k2", so the organization is visible without +// listing and a second organization never overwrites the first. The suffix is +// the organization slug, which is stable and already URL-safe; instances that +// report no slugs fall back to the slugified name, then to a prefix of the id. +// Only with no organization information at all is the bare email used. +// +// Names that are not the bare email leave the legacy loggedInUserEmail pointer +// unset (see syncLegacyLoginFields), so a CLI build that predates profiles +// asks for a fresh login rather than loading another profile's session. +func DeriveProfileName(configFile models.ConfigFile, email string, domain string, orgID string, orgName string, orgSlug string) string { + for _, profile := range configFile.Profiles { + if profile.Email == email && profile.Domain == domain && profile.OrganizationID == orgID { + return profile.Name + } + } + for _, profile := range configFile.Profiles { + if profile.Email == email && profile.Domain == domain && profile.OrganizationID == "" { + return profile.Name + } + } + + suffix := slugifyProfileSuffix(orgSlug) + if suffix == "" { + suffix = slugifyProfileSuffix(orgName) + } + if suffix == "" { + if len(orgID) >= 8 { + suffix = orgID[:8] + } else { + suffix = orgID + } + } + + base := email + if suffix != "" { + base = fmt.Sprintf("%s--%s", email, suffix) + } + candidate := base + for i := 2; findProfileIndex(configFile.Profiles, candidate) >= 0; i++ { + candidate = fmt.Sprintf("%s-%d", base, i) + } + return candidate +} + +func slugifyProfileSuffix(value string) string { + var builder strings.Builder + lastWasDash := true // suppress leading dashes + for _, r := range strings.ToLower(strings.TrimSpace(value)) { + switch { + case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'): + builder.WriteRune(r) + lastWasDash = false + default: + if !lastWasDash { + builder.WriteRune('-') + lastWasDash = true + } + } + } + return strings.TrimRight(builder.String(), "-") +} + +// PersistLoginProfile stores the session credentials in the keyring under the +// profile name and records the profile in the config file. makeActive sets the +// profile as the global default; regardless of it, an already-active profile +// keeps the legacy fields in sync. +func PersistLoginProfile(profile models.Profile, userCred *models.UserCredentials, makeActive bool) error { + // Deliberately no name validation here: derived names are raw account + // emails (which may contain any RFC-legal character) and have always been + // valid keyring keys. Rejecting them would block login entirely. Name + // validation applies only where users type a name (--profile, profile new, + // profile rename), at the command layer. + if err := StoreUserCredsInKeyRing(profile.Name, userCred); err != nil { + return err + } + + configFile, err := GetMigratedConfigFile() + if err != nil { + return fmt.Errorf("persistLoginProfile: unable to load config file [err=%s]", err) + } + + UpsertProfile(&configFile, profile) + if makeActive || configFile.ActiveProfile == "" || configFile.ActiveProfile == profile.Name { + if err := SetActiveProfile(&configFile, profile.Name); err != nil { + return err + } + } + + return WriteConfigFile(&configFile) +} + +// ResolveActiveProfileDetails loads the config (with an in-memory migration) +// and resolves the invocation's profile and its stored metadata. found +// reports whether the resolved name has a profile entry. +func ResolveActiveProfileDetails() (resolved ResolvedProfile, profile models.Profile, found bool) { + configFile, err := GetConfigFile() + if err != nil { + return ResolvedProfile{}, models.Profile{}, false + } + MigrateConfigProfiles(&configFile) + + resolved = ResolveProfile(configFile) + if resolved.Name == "" { + return resolved, models.Profile{}, false + } + + profile, found = FindProfile(configFile, resolved.Name) + return resolved, profile, found +} + +// LoginRenewalArgs builds the arguments for re-running login to restore the +// session of the profile this invocation resolved to. An existing profile is +// signed back in to with --profile, which keeps its account, instance, and +// organization; a profile that was selected but never created (a pinned +// terminal or bound directory pointing at it) is created with --save-as, so +// the selection starts working. The instance is passed explicitly as well so +// the login never has to ask. +func LoginRenewalArgs(resolved ResolvedProfile, profile models.Profile, found bool) []string { + args := []string{"login", "--silent"} + if resolved.Name == "" { + return args + } + if !found { + return append(args, "--save-as", resolved.Name) + } + args = append(args, "--profile", resolved.Name) + if profile.Domain != "" { + args = append(args, "--domain", DisplayDomain(profile.Domain)) + } + return args +} + +type userTokenOrgClaims struct { + OrganizationID string `json:"organizationId"` + SubOrganizationID string `json:"subOrganizationId"` + TokenVersionID string `json:"tokenVersionId"` + jwt.RegisteredClaims +} + +// ParseTokenOrgClaims decodes (without verifying) the organization scope +// claims from a user session JWT. Returns empty strings when unparsable. +func ParseTokenOrgClaims(token string) (orgID string, subOrgID string) { + claims := &userTokenOrgClaims{} + parser := jwt.NewParser() + if _, _, err := parser.ParseUnverified(token, claims); err != nil { + return "", "" + } + return claims.OrganizationID, claims.SubOrganizationID +} + +// ParseTokenSessionID decodes (without verifying) the server-side session id +// from a user session JWT. The server keys sessions by user, IP, and user +// agent, so every token the CLI holds for one account on one machine shares a +// single session id, including organization-scoped ones. +func ParseTokenSessionID(token string) string { + claims := &userTokenOrgClaims{} + parser := jwt.NewParser() + if _, _, err := parser.ParseUnverified(token, claims); err != nil { + return "" + } + return claims.TokenVersionID +} + +// OrgInfo describes one organization the account can use. +type OrgInfo struct { + ID string + Name string + Slug string + // ParentName is set for a sub-organization. + ParentName string +} + +// DisplayName renders the organization for humans, "Parent / Child" for a +// sub-organization, so a session inside "Acme / Research" is not reported as +// plain "Acme", which would be indistinguishable from the root organization. +func (o OrgInfo) DisplayName() string { + return JoinOrgDisplayName(o.ParentName, o.Name) +} + +// LookupOrganization finds an organization, root or sub, by id with the given +// session token. Best effort: found is false on any error, so callers can fall +// back to showing the id. The sub-organization aware listing is preferred +// because it carries slugs and nested organizations; instances without it fall +// back to the flat list, which has names only. +func LookupOrganization(sessionToken string, orgID string) (OrgInfo, bool) { + if orgID == "" || sessionToken == "" { + return OrgInfo{}, false + } + + httpClient, err := GetRestyClientWithCustomHeaders() + if err != nil { + return OrgInfo{}, false + } + httpClient.SetAuthToken(sessionToken) + + if subOrgsResp, err := api.CallGetAllOrganizationsWithSubOrgs(httpClient); err == nil { + for _, org := range subOrgsResp.Organizations { + if org.ID == orgID { + return OrgInfo{ID: org.ID, Name: org.Name, Slug: org.Slug}, true + } + for _, sub := range org.SubOrganizations { + if sub.ID == orgID { + return OrgInfo{ID: sub.ID, Name: sub.Name, Slug: sub.Slug, ParentName: org.Name}, true + } + } + } + } + + if orgResp, err := api.CallGetAllOrganizations(httpClient); err == nil { + for _, org := range orgResp.Organizations { + if org.ID == orgID { + return OrgInfo{ID: org.ID, Name: org.Name}, true + } + } + } + + return OrgInfo{}, false +} + +// DescribeSessionOrg describes the organization a session acts in, given the +// organizationId and subOrganizationId claims of its token: the +// sub-organization when there is one, otherwise the root. Names are sanitized +// for display. When the lookup fails only the id is set, so callers can still +// show something. +func DescribeSessionOrg(sessionToken string, orgID string, subOrgID string) OrgInfo { + scopedID := orgID + if subOrgID != "" { + scopedID = subOrgID + } + + info, found := LookupOrganization(sessionToken, scopedID) + if !found { + return OrgInfo{ID: scopedID} + } + info.Name = SanitizeDisplay(info.Name) + info.Slug = SanitizeDisplay(info.Slug) + info.ParentName = SanitizeDisplay(info.ParentName) + return info +} + +// DisplayDomain renders a stored domain (which includes the /api suffix) the +// way users typed it. +func DisplayDomain(domain string) string { + return strings.TrimSuffix(domain, "/api") +} diff --git a/packages/util/profile_notice.go b/packages/util/profile_notice.go new file mode 100644 index 00000000..ee20c346 --- /dev/null +++ b/packages/util/profile_notice.go @@ -0,0 +1,76 @@ +package util + +import ( + "fmt" + "io" + "sync" +) + +var ( + profileNoticeWriter io.Writer + profileNoticeOnce sync.Once +) + +// EnableProfileNotice turns on the one-time "Using profile ..." notice, written +// to w the first time a command actually loads a login session. The root +// command enables it for ordinary commands; profile-management commands print +// their own outcome, and silent or structured-output runs stay quiet, so they +// leave it off. Commands that never load a session, such as scan or agent, +// therefore never print it even when a profile is pinned. +func EnableProfileNotice(w io.Writer) { + profileNoticeWriter = w +} + +// printProfileNotice reports which profile and organization a session load +// resolved to, once per process. See FormatProfileNotice for when it is quiet. +func printProfileNotice(resolved ResolvedProfile, details LoggedInUserDetails) { + if profileNoticeWriter == nil { + return + } + notice := FormatProfileNotice(resolved, details) + if notice == "" { + return + } + profileNoticeOnce.Do(func() { + fmt.Fprintln(profileNoticeWriter, notice) + }) +} + +// FormatProfileNotice renders the notice for a session load whose selection +// came from somewhere non-obvious: the --profile flag, INFISICAL_PROFILE, a +// directory binding, or an --org/INFISICAL_ORG override. Plain default-profile +// usage yields "", so single-profile setups never see it. +func FormatProfileNotice(resolved ResolvedProfile, details LoggedInUserDetails) string { + overridden := details.OrganizationSource != "" && details.OrganizationSource != OrgSourceProfileDefault + if resolved.Source == ProfileSourceDefault && !overridden { + return "" + } + + orgName := details.OrganizationName + if orgName == "" { + orgName = details.OrganizationID + } + detail := "" + if orgName != "" { + detail = fmt.Sprintf(" (org %s)", orgName) + } + + via := resolved.Source + if resolved.ScopeDir != "" { + via = fmt.Sprintf("%s %s", via, resolved.ScopeDir) + } + if overridden { + if resolved.Source == ProfileSourceDefault { + via = details.OrganizationSource + } else { + via = fmt.Sprintf("%s, org via %s", via, details.OrganizationSource) + } + } + + shadowed := "" + if resolved.ShadowedName != "" { + shadowed = fmt.Sprintf(", overriding this directory's binding to '%s'", resolved.ShadowedName) + } + + return fmt.Sprintf("Using profile '%s'%s via %s%s", SanitizeDisplay(resolved.Name), SanitizeDisplay(detail), via, SanitizeDisplay(shadowed)) +} diff --git a/packages/util/profile_notice_test.go b/packages/util/profile_notice_test.go new file mode 100644 index 00000000..41284b26 --- /dev/null +++ b/packages/util/profile_notice_test.go @@ -0,0 +1,41 @@ +package util + +import "testing" + +func TestFormatProfileNotice(t *testing.T) { + t.Run("the default profile with no override is quiet", func(t *testing.T) { + got := FormatProfileNotice(ResolvedProfile{Name: "a@x.com", Source: ProfileSourceDefault}, LoggedInUserDetails{OrganizationName: "Acme", OrganizationSource: OrgSourceProfileDefault}) + if got != "" { + t.Fatalf("expected no notice, got %q", got) + } + }) + + t.Run("a pinned profile names the source and organization", func(t *testing.T) { + got := FormatProfileNotice(ResolvedProfile{Name: "work", Source: ProfileSourceEnv}, LoggedInUserDetails{OrganizationName: "Acme", OrganizationSource: OrgSourceProfileDefault}) + if got != "Using profile 'work' (org Acme) via INFISICAL_PROFILE environment variable" { + t.Fatalf("unexpected notice %q", got) + } + }) + + t.Run("a directory binding includes the directory", func(t *testing.T) { + got := FormatProfileNotice(ResolvedProfile{Name: "work", Source: ProfileSourceDirectory, ScopeDir: "/repo"}, LoggedInUserDetails{OrganizationID: "org-1", OrganizationSource: OrgSourceProfileDefault}) + if got != "Using profile 'work' (org org-1) via directory scope /repo" { + t.Fatalf("unexpected notice %q", got) + } + }) + + t.Run("an org override on the default profile reports the override", func(t *testing.T) { + got := FormatProfileNotice(ResolvedProfile{Name: "a@x.com", Source: ProfileSourceDefault}, LoggedInUserDetails{OrganizationName: "Globex", OrganizationSource: OrgSourceFlag}) + if got != "Using profile 'a@x.com' (org Globex) via --org flag" { + t.Fatalf("unexpected notice %q", got) + } + }) + + t.Run("an org override on a pinned profile reports both", func(t *testing.T) { + got := FormatProfileNotice(ResolvedProfile{Name: "work", Source: ProfileSourceFlag, ShadowedName: "client-a"}, LoggedInUserDetails{OrganizationName: "Globex", OrganizationSource: OrgSourceEnv}) + want := "Using profile 'work' (org Globex) via --profile flag, org via INFISICAL_ORG environment variable, overriding this directory's binding to 'client-a'" + if got != want { + t.Fatalf("unexpected notice %q", got) + } + }) +} diff --git a/packages/util/profile_test.go b/packages/util/profile_test.go new file mode 100644 index 00000000..8701fc63 --- /dev/null +++ b/packages/util/profile_test.go @@ -0,0 +1,878 @@ +package util + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/Infisical/infisical-merge/packages/models" +) + +func TestMigrateConfigProfiles(t *testing.T) { + t.Run("legacy single user becomes a profile named after the email", func(t *testing.T) { + configFile := models.ConfigFile{ + LoggedInUserEmail: "scott@example.com", + LoggedInUserDomain: "https://app.infisical.com/api", + } + + changed := MigrateConfigProfiles(&configFile) + + if !changed { + t.Fatal("expected migration to report a change") + } + if len(configFile.Profiles) != 1 { + t.Fatalf("expected 1 profile, got %d", len(configFile.Profiles)) + } + profile := configFile.Profiles[0] + if profile.Name != "scott@example.com" || profile.Email != "scott@example.com" || profile.Domain != "https://app.infisical.com/api" { + t.Fatalf("unexpected migrated profile: %+v", profile) + } + if configFile.ActiveProfile != "scott@example.com" { + t.Fatalf("expected active profile to be the migrated one, got %q", configFile.ActiveProfile) + } + }) + + t.Run("legacy roster becomes profiles and the active pointer follows LoggedInUserEmail", func(t *testing.T) { + configFile := models.ConfigFile{ + LoggedInUserEmail: "b@example.com", + LoggedInUserDomain: "https://eu.infisical.com/api", + LoggedInUsers: []models.LoggedInUser{ + {Email: "a@example.com", Domain: "https://app.infisical.com/api"}, + {Email: "b@example.com", Domain: "https://eu.infisical.com/api"}, + }, + } + + MigrateConfigProfiles(&configFile) + + if len(configFile.Profiles) != 2 { + t.Fatalf("expected 2 profiles, got %d", len(configFile.Profiles)) + } + if configFile.ActiveProfile != "b@example.com" { + t.Fatalf("expected active profile b@example.com, got %q", configFile.ActiveProfile) + } + }) + + t.Run("is idempotent", func(t *testing.T) { + configFile := models.ConfigFile{ + LoggedInUserEmail: "scott@example.com", + LoggedInUserDomain: "https://app.infisical.com/api", + } + + MigrateConfigProfiles(&configFile) + changed := MigrateConfigProfiles(&configFile) + + if changed { + t.Fatal("expected second migration to be a no-op") + } + if len(configFile.Profiles) != 1 { + t.Fatalf("expected 1 profile after re-migration, got %d", len(configFile.Profiles)) + } + }) + + t.Run("does nothing for an empty config", func(t *testing.T) { + configFile := models.ConfigFile{} + + if MigrateConfigProfiles(&configFile) { + t.Fatal("expected no change for an empty config") + } + if len(configFile.Profiles) != 0 || configFile.ActiveProfile != "" { + t.Fatalf("expected empty config to stay empty, got %+v", configFile) + } + }) + + t.Run("a legacy user switch (LoggedInUserEmail moved by an old binary) wins over a stale active pointer", func(t *testing.T) { + configFile := models.ConfigFile{ + LoggedInUserEmail: "b@example.com", + ActiveProfile: "a@example.com", + Profiles: []models.Profile{ + {Name: "a@example.com", Email: "a@example.com"}, + {Name: "b@example.com", Email: "b@example.com"}, + }, + } + + changed := MigrateConfigProfiles(&configFile) + + if !changed { + t.Fatal("expected reconciliation to report a change") + } + if configFile.ActiveProfile != "b@example.com" { + t.Fatalf("expected active profile b@example.com, got %q", configFile.ActiveProfile) + } + }) + + t.Run("roster mirrors of named profiles do not spawn phantom profiles", func(t *testing.T) { + // State after a targeted first login: only a named profile exists, and + // the legacy fields mirror it for old-binary compatibility. + configFile := models.ConfigFile{ + LoggedInUserEmail: "scott@example.com", + LoggedInUserDomain: "https://app.infisical.com/api", + LoggedInUsers: []models.LoggedInUser{ + {Email: "scott@example.com", Domain: "https://app.infisical.com/api"}, + }, + ActiveProfile: "globex", + Profiles: []models.Profile{ + {Name: "globex", Email: "scott@example.com", Domain: "https://app.infisical.com/api", OrganizationID: "org-2"}, + }, + } + + changed := MigrateConfigProfiles(&configFile) + + if changed { + t.Fatal("expected migration to be a no-op") + } + if len(configFile.Profiles) != 1 { + t.Fatalf("expected no phantom profile, got %+v", configFile.Profiles) + } + if configFile.ActiveProfile != "globex" { + t.Fatalf("expected active profile to stay globex, got %q", configFile.ActiveProfile) + } + }) + + t.Run("legacy switch reconciles to a named profile when no email-named one exists", func(t *testing.T) { + configFile := models.ConfigFile{ + LoggedInUserEmail: "b@example.com", + ActiveProfile: "a-work", + LoggedInUsers: []models.LoggedInUser{ + {Email: "a@example.com"}, + {Email: "b@example.com"}, + }, + Profiles: []models.Profile{ + {Name: "a-work", Email: "a@example.com"}, + {Name: "b-work", Email: "b@example.com"}, + }, + } + + MigrateConfigProfiles(&configFile) + + if len(configFile.Profiles) != 2 { + t.Fatalf("expected no phantom profiles, got %+v", configFile.Profiles) + } + if configFile.ActiveProfile != "b-work" { + t.Fatalf("expected active profile b-work, got %q", configFile.ActiveProfile) + } + }) + + t.Run("a named active profile for the same account is kept", func(t *testing.T) { + configFile := models.ConfigFile{ + LoggedInUserEmail: "scott@example.com", + ActiveProfile: "client-a", + Profiles: []models.Profile{ + {Name: "client-a", Email: "scott@example.com", OrganizationID: "org-1"}, + {Name: "scott@example.com", Email: "scott@example.com"}, + }, + } + + MigrateConfigProfiles(&configFile) + + if configFile.ActiveProfile != "client-a" { + t.Fatalf("expected active profile client-a to be kept, got %q", configFile.ActiveProfile) + } + }) +} + +func TestResolveProfileWith(t *testing.T) { + scopedDir := filepath.Join("/", "home", "scott", "work", "client-a") + + configFile := models.ConfigFile{ + ActiveProfile: "default-profile", + Profiles: []models.Profile{ + {Name: "default-profile", Email: "scott@example.com"}, + {Name: "client-a", Email: "scott@example.com"}, + }, + DirectoryProfiles: map[string]string{ + scopedDir: "client-a", + }, + } + + t.Run("an override beats everything", func(t *testing.T) { + resolved := resolveProfileWith(configFile, "client-b", ProfileSourceEnv, scopedDir) + if resolved.Name != "client-b" || resolved.Source != ProfileSourceEnv { + t.Fatalf("unexpected resolution: %+v", resolved) + } + }) + + t.Run("a directory scope beats the global default", func(t *testing.T) { + resolved := resolveProfileWith(configFile, "", "", scopedDir) + if resolved.Name != "client-a" || resolved.Source != ProfileSourceDirectory || resolved.ScopeDir != scopedDir { + t.Fatalf("unexpected resolution: %+v", resolved) + } + }) + + t.Run("a subdirectory inherits the nearest ancestor binding", func(t *testing.T) { + resolved := resolveProfileWith(configFile, "", "", filepath.Join(scopedDir, "api", "src")) + if resolved.Name != "client-a" || resolved.ScopeDir != scopedDir { + t.Fatalf("unexpected resolution: %+v", resolved) + } + }) + + t.Run("a nested binding beats an ancestor binding", func(t *testing.T) { + nested := filepath.Join(scopedDir, "sub-project") + withNested := configFile + withNested.DirectoryProfiles = map[string]string{ + scopedDir: "client-a", + nested: "client-b", + } + resolved := resolveProfileWith(withNested, "", "", filepath.Join(nested, "deep")) + if resolved.Name != "client-b" || resolved.ScopeDir != nested { + t.Fatalf("unexpected resolution: %+v", resolved) + } + }) + + t.Run("an unbound directory falls back to the global default", func(t *testing.T) { + resolved := resolveProfileWith(configFile, "", "", filepath.Join("/", "home", "scott", "other")) + if resolved.Name != "default-profile" || resolved.Source != ProfileSourceDefault { + t.Fatalf("unexpected resolution: %+v", resolved) + } + }) + + t.Run("unmigrated legacy config falls back to LoggedInUserEmail", func(t *testing.T) { + legacyOnly := models.ConfigFile{LoggedInUserEmail: "scott@example.com"} + resolved := resolveProfileWith(legacyOnly, "", "", "") + if resolved.Name != "scott@example.com" || resolved.Source != ProfileSourceDefault { + t.Fatalf("unexpected resolution: %+v", resolved) + } + }) + + t.Run("nothing resolves on a fresh machine", func(t *testing.T) { + resolved := resolveProfileWith(models.ConfigFile{}, "", "", "") + if resolved.Name != "" { + t.Fatalf("expected empty resolution, got %+v", resolved) + } + }) +} + +func TestDeriveProfileName(t *testing.T) { + base := models.ConfigFile{ + Profiles: []models.Profile{ + {Name: "scott@example.com", Email: "scott@example.com", Domain: "https://app.infisical.com/api", OrganizationID: "org-1"}, + }, + } + + t.Run("a new account is named after the account and organization slug", func(t *testing.T) { + name := DeriveProfileName(base, "new@example.com", "https://app.infisical.com/api", "org-9", "Acme", "acme-x4k2") + if name != "new@example.com--acme-x4k2" { + t.Fatalf("expected email--slug, got %q", name) + } + }) + + t.Run("a plus-addressed email is kept verbatim", func(t *testing.T) { + name := DeriveProfileName(base, "ci+tests@example.com", "https://app.infisical.com/api", "org-9", "Acme", "acme-x4k2") + if name != "ci+tests@example.com--acme-x4k2" { + t.Fatalf("expected plus-addressed email verbatim, got %q", name) + } + }) + + t.Run("with no organization information at all the bare email is used", func(t *testing.T) { + name := DeriveProfileName(base, "new@example.com", "https://app.infisical.com/api", "", "", "") + if name != "new@example.com" { + t.Fatalf("expected bare email, got %q", name) + } + }) + + t.Run("a bare email that is taken is numbered", func(t *testing.T) { + name := DeriveProfileName(base, "scott@example.com", "https://app.infisical.com/api", "", "", "") + if name != "scott@example.com-2" { + t.Fatalf("expected numbered bare email, got %q", name) + } + }) + + t.Run("relogin into the same account, instance, and org reuses the profile", func(t *testing.T) { + name := DeriveProfileName(base, "scott@example.com", "https://app.infisical.com/api", "org-1", "Acme", "") + if name != "scott@example.com" { + t.Fatalf("expected existing profile name to be reused, got %q", name) + } + }) + + t.Run("relogin reuses a named profile for the same account, instance, and org", func(t *testing.T) { + configFile := models.ConfigFile{ + Profiles: []models.Profile{ + {Name: "client-a", Email: "scott@example.com", Domain: "https://app.infisical.com/api", OrganizationID: "org-1"}, + }, + } + name := DeriveProfileName(configFile, "scott@example.com", "https://app.infisical.com/api", "org-1", "Acme", "") + if name != "client-a" { + t.Fatalf("expected named profile to be reused, got %q", name) + } + }) + + t.Run("adopts a migrated profile whose org is unknown", func(t *testing.T) { + configFile := models.ConfigFile{ + Profiles: []models.Profile{ + {Name: "scott@example.com", Email: "scott@example.com", Domain: "https://app.infisical.com/api"}, + }, + } + name := DeriveProfileName(configFile, "scott@example.com", "https://app.infisical.com/api", "org-1", "Acme", "") + if name != "scott@example.com" { + t.Fatalf("expected migrated profile to be adopted, got %q", name) + } + }) + + t.Run("a second organization gets its slug as a suffix instead of overwriting", func(t *testing.T) { + name := DeriveProfileName(base, "scott@example.com", "https://app.infisical.com/api", "org-2", "Beta Corp", "beta-corp-x4k2") + if name != "scott@example.com--beta-corp-x4k2" { + t.Fatalf("expected slug-suffixed name, got %q", name) + } + }) + + t.Run("a slug is normalized to the profile name character set", func(t *testing.T) { + name := DeriveProfileName(base, "scott@example.com", "https://app.infisical.com/api", "org-2", "Beta Corp", "Beta_Corp/EU") + if name != "scott@example.com--beta-corp-eu" { + t.Fatalf("expected a normalized slug suffix, got %q", name) + } + }) + + t.Run("without a slug the organization name is used", func(t *testing.T) { + name := DeriveProfileName(base, "scott@example.com", "https://app.infisical.com/api", "org-2", "Beta Corp", "") + if name != "scott@example.com--beta-corp" { + t.Fatalf("expected name-suffixed fallback, got %q", name) + } + }) + + t.Run("falls back to the org id when the org name is unavailable", func(t *testing.T) { + name := DeriveProfileName(base, "scott@example.com", "https://app.infisical.com/api", "1234567890ab", "", "") + if name != "scott@example.com--12345678" { + t.Fatalf("expected org-id-suffixed name, got %q", name) + } + }) + + t.Run("numbers suffix collisions", func(t *testing.T) { + configFile := models.ConfigFile{ + Profiles: []models.Profile{ + {Name: "scott@example.com", Email: "scott@example.com", Domain: "https://app.infisical.com/api", OrganizationID: "org-1"}, + {Name: "scott@example.com--beta", Email: "scott@example.com", Domain: "https://app.infisical.com/api", OrganizationID: "org-2"}, + }, + } + name := DeriveProfileName(configFile, "scott@example.com", "https://app.infisical.com/api", "org-3", "Beta", "") + if name != "scott@example.com--beta-2" { + t.Fatalf("expected numbered suffix, got %q", name) + } + }) +} + +func TestSetActiveProfileSyncsLegacyFields(t *testing.T) { + t.Run("an email-named profile publishes the legacy pointer", func(t *testing.T) { + configFile := models.ConfigFile{ + Profiles: []models.Profile{ + {Name: "scott@example.com", Email: "scott@example.com", Domain: "https://eu.infisical.com/api"}, + }, + } + + if err := SetActiveProfile(&configFile, "scott@example.com"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if configFile.LoggedInUserEmail != "scott@example.com" || configFile.LoggedInUserDomain != "https://eu.infisical.com/api" { + t.Fatalf("legacy fields not synced: %+v", configFile) + } + if len(configFile.LoggedInUsers) != 1 || configFile.LoggedInUsers[0].Email != "scott@example.com" { + t.Fatalf("legacy roster not synced: %+v", configFile.LoggedInUsers) + } + }) + + t.Run("a named profile clears it, so an old binary cannot load another profile's token", func(t *testing.T) { + configFile := models.ConfigFile{ + LoggedInUserEmail: "scott@example.com", + LoggedInUserDomain: "https://app.infisical.com/api", + Profiles: []models.Profile{ + {Name: "client-a", Email: "scott@example.com", Domain: "https://eu.infisical.com/api"}, + }, + } + + if err := SetActiveProfile(&configFile, "client-a"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if configFile.ActiveProfile != "client-a" { + t.Fatalf("expected active profile client-a, got %q", configFile.ActiveProfile) + } + if configFile.LoggedInUserEmail != "" || configFile.LoggedInUserDomain != "" { + t.Fatalf("expected the legacy pointer to be cleared, got %+v", configFile) + } + }) + + t.Run("a missing profile errors", func(t *testing.T) { + configFile := models.ConfigFile{Profiles: []models.Profile{{Name: "a", Email: "a"}}} + if err := SetActiveProfile(&configFile, "missing"); err == nil { + t.Fatal("expected an error for a missing profile") + } + }) +} + +func TestRemoveProfile(t *testing.T) { + scopedDir := filepath.Join("/", "home", "scott", "work", "client-a") + configFile := models.ConfigFile{ + ActiveProfile: "client-a", + LoggedInUserEmail: "scott@example.com", + LoggedInUserDomain: "https://app.infisical.com/api", + LoggedInUsers: []models.LoggedInUser{ + {Email: "scott@example.com", Domain: "https://app.infisical.com/api"}, + {Email: "other@example.com", Domain: "https://app.infisical.com/api"}, + }, + Profiles: []models.Profile{ + {Name: "client-a", Email: "scott@example.com", Domain: "https://app.infisical.com/api"}, + {Name: "other@example.com", Email: "other@example.com", Domain: "https://app.infisical.com/api"}, + }, + DirectoryProfiles: map[string]string{ + scopedDir: "client-a", + }, + } + + if !RemoveProfile(&configFile, "client-a") { + t.Fatal("expected profile to be removed") + } + + if _, found := FindProfile(configFile, "client-a"); found { + t.Fatal("profile still present after removal") + } + if len(configFile.DirectoryProfiles) != 0 { + t.Fatalf("expected directory bindings to be removed, got %+v", configFile.DirectoryProfiles) + } + if configFile.ActiveProfile != "" || configFile.LoggedInUserEmail != "" { + t.Fatalf("expected active pointers to be cleared, got %+v", configFile) + } + if len(configFile.LoggedInUsers) != 1 || configFile.LoggedInUsers[0].Email != "other@example.com" { + t.Fatalf("expected legacy roster cleanup, got %+v", configFile.LoggedInUsers) + } + + if RemoveProfile(&configFile, "does-not-exist") { + t.Fatal("expected removal of a missing profile to report false") + } +} + +func TestValidateProfileName(t *testing.T) { + // Plus-addressed emails are common for shared/test accounts and must be + // accepted so users can explicitly target their email-named profiles. + valid := []string{"scott@example.com", "ci+tests@example.com", "client-a", "work.eu", "a", "A1_b-c", "scott@example.com--beta-2"} + for _, name := range valid { + if err := ValidateProfileName(name); err != nil { + t.Fatalf("expected %q to be valid: %v", name, err) + } + } + + invalid := []string{"", "-leading-dash", ".leading-dot", "has space", "has/slash", "has:colon"} + for _, name := range invalid { + if err := ValidateProfileName(name); err == nil { + t.Fatalf("expected %q to be invalid", name) + } + } +} + +func TestOrgMatchesSelector(t *testing.T) { + cases := []struct { + selector, id, slug, name string + want bool + }{ + {"globex", "org-1", "globex-demo", "Globex", true}, // by name + {"GLOBEX", "org-1", "globex-demo", "Globex", true}, // case-insensitive + {"globex-demo", "org-1", "globex-demo", "Globex", true}, // by slug + {"org-1", "org-1", "globex-demo", "Globex", true}, // by id + {"acme", "org-1", "globex-demo", "Globex", false}, + {"", "org-1", "globex-demo", "Globex", false}, + {"globex", "org-1", "", "", false}, // no metadata to match against + } + + for _, tc := range cases { + if got := OrgMatchesSelector(tc.selector, tc.id, tc.slug, tc.name); got != tc.want { + t.Fatalf("OrgMatchesSelector(%q, %q, %q, %q) = %v, want %v", tc.selector, tc.id, tc.slug, tc.name, got, tc.want) + } + } +} + +func TestResolveProfileRecordsShadowedBinding(t *testing.T) { + scopedDir := filepath.Join("/", "home", "scott", "work", "client-a") + configFile := models.ConfigFile{ + ActiveProfile: "default-profile", + Profiles: []models.Profile{{Name: "default-profile"}, {Name: "client-a"}, {Name: "client-b"}}, + DirectoryProfiles: map[string]string{scopedDir: "client-a"}, + } + + t.Run("an override records the binding it shadowed", func(t *testing.T) { + resolved := resolveProfileWith(configFile, "client-b", ProfileSourceEnv, scopedDir) + if resolved.Name != "client-b" { + t.Fatalf("expected client-b, got %q", resolved.Name) + } + if resolved.ShadowedName != "client-a" || resolved.ShadowedScopeDir != scopedDir { + t.Fatalf("expected the binding to be recorded, got %+v", resolved) + } + }) + + t.Run("an override matching the binding shadows nothing", func(t *testing.T) { + resolved := resolveProfileWith(configFile, "client-a", ProfileSourceEnv, scopedDir) + if resolved.ShadowedName != "" { + t.Fatalf("expected no shadowed binding, got %+v", resolved) + } + }) + + t.Run("an override outside any binding shadows nothing", func(t *testing.T) { + resolved := resolveProfileWith(configFile, "client-b", ProfileSourceEnv, filepath.Join("/", "tmp")) + if resolved.ShadowedName != "" { + t.Fatalf("expected no shadowed binding, got %+v", resolved) + } + }) +} + +func TestShellQuote(t *testing.T) { + cases := map[string]string{ + "work": "'work'", + "a@x.com": "'a@x.com'", + "ci+tests@x.com": "'ci+tests@x.com'", + "evil$(whoami)@x.com": `'evil$(whoami)@x.com'`, + "back`tick`": "'back`tick`'", + "it's": `'it'\''s'`, + "a; rm -rf /": "'a; rm -rf /'", + } + for in, want := range cases { + if got := ShellQuote(in); got != want { + t.Fatalf("ShellQuote(%q) = %q, want %q", in, got, want) + } + } +} + +func TestSanitizeDisplay(t *testing.T) { + if got := SanitizeDisplay("Acme\x1b[31m evil\x07"); got != "Acme[31m evil" { + t.Fatalf("escape sequences not stripped: %q", got) + } + if got := SanitizeDisplay("line\nbreak"); got != "linebreak" { + t.Fatalf("newline not stripped: %q", got) + } + if got := SanitizeDisplay("Acme / Research"); got != "Acme / Research" { + t.Fatalf("ordinary text altered: %q", got) + } +} + +func TestOrgMatchTierPrecedence(t *testing.T) { + if OrgMatchTier("org-1", "org-1", "slug", "name") != orgMatchID { + t.Fatal("expected an id match to rank highest") + } + if OrgMatchTier("slug", "org-1", "slug", "name") != orgMatchSlug { + t.Fatal("expected a slug match") + } + if OrgMatchTier("NAME", "org-1", "slug", "name") != orgMatchName { + t.Fatal("expected a case-insensitive name match") + } + if OrgMatchTier("other", "org-1", "slug", "name") != orgMatchNone { + t.Fatal("expected no match") + } + // An organization named after another one's id must not outrank it. + if OrgMatchTier("org-1", "attacker-id", "", "org-1") >= OrgMatchTier("org-1", "org-1", "", "") { + t.Fatal("a name match must rank below an id match") + } +} + +func TestRepointProfileDomain(t *testing.T) { + base := func() models.ConfigFile { + return models.ConfigFile{ + ActiveProfile: "acme-work", + LoggedInUserEmail: "scott@example.com", + LoggedInUserDomain: "https://app.infisical.com/api", + LoggedInUsers: []models.LoggedInUser{ + {Email: "scott@example.com", Domain: "https://app.infisical.com/api"}, + }, + Profiles: []models.Profile{ + // Same email and instance, different organizations. + {Name: "acme-work", Email: "scott@example.com", Domain: "https://app.infisical.com/api", OrganizationID: "org-a", OrganizationName: "Acme"}, + {Name: "globex-work", Email: "scott@example.com", Domain: "https://app.infisical.com/api", OrganizationID: "org-b", OrganizationName: "Globex"}, + }, + } + } + + t.Run("only the named profile moves", func(t *testing.T) { + configFile := base() + if !RepointProfileDomain(&configFile, "acme-work", "https://self.example.com/api") { + t.Fatal("expected the profile to move") + } + if configFile.Profiles[0].Domain != "https://self.example.com/api" { + t.Fatalf("selected profile did not move: %+v", configFile.Profiles[0]) + } + if configFile.Profiles[1].Domain != "https://app.infisical.com/api" { + t.Fatalf("a profile sharing the email and instance was moved too: %+v", configFile.Profiles[1]) + } + }) + + t.Run("the organization from the previous instance is dropped", func(t *testing.T) { + configFile := base() + RepointProfileDomain(&configFile, "acme-work", "https://self.example.com/api") + moved := configFile.Profiles[0] + if moved.OrganizationID != "" || moved.OrganizationName != "" || moved.SubOrganizationID != "" { + t.Fatalf("expected the organization to be cleared, got %+v", moved) + } + }) + + t.Run("the legacy roster entry is kept while another profile still uses the old instance", func(t *testing.T) { + configFile := base() + RepointProfileDomain(&configFile, "acme-work", "https://self.example.com/api") + if configFile.LoggedInUsers[0].Domain != "https://app.infisical.com/api" { + t.Fatalf("roster entry moved while another profile still uses the old instance: %+v", configFile.LoggedInUsers[0]) + } + }) + + t.Run("the legacy roster entry follows the last profile off the old instance", func(t *testing.T) { + configFile := base() + configFile.Profiles = configFile.Profiles[:1] + RepointProfileDomain(&configFile, "acme-work", "https://self.example.com/api") + if configFile.LoggedInUsers[0].Domain != "https://self.example.com/api" { + t.Fatalf("roster entry did not follow: %+v", configFile.LoggedInUsers[0]) + } + }) + + t.Run("an unchanged instance or unknown profile is a no-op", func(t *testing.T) { + configFile := base() + if RepointProfileDomain(&configFile, "acme-work", "https://app.infisical.com/api") { + t.Fatal("expected no move for the same instance") + } + if RepointProfileDomain(&configFile, "missing", "https://self.example.com/api") { + t.Fatal("expected no move for an unknown profile") + } + }) +} + +func TestValidateProfileNameLength(t *testing.T) { + if err := ValidateProfileName(strings.Repeat("a", MaxProfileNameLength)); err != nil { + t.Fatalf("a name at the limit should be valid, got %v", err) + } + if err := ValidateProfileName(strings.Repeat("a", MaxProfileNameLength+1)); err == nil { + t.Fatal("expected an overlong name to be rejected") + } +} + +func TestOrgDisplayNameRoundTrip(t *testing.T) { + if got := JoinOrgDisplayName("Acme", "Research"); got != "Acme / Research" { + t.Fatalf("JoinOrgDisplayName = %q", got) + } + if got := JoinOrgDisplayName("", "Acme"); got != "Acme" { + t.Fatalf("JoinOrgDisplayName without a parent = %q", got) + } + parent, own := SplitOrgDisplayName("Acme / Research") + if parent != "Acme" || own != "Research" { + t.Fatalf("SplitOrgDisplayName = %q, %q", parent, own) + } + parent, own = SplitOrgDisplayName("Acme") + if parent != "" || own != "Acme" { + t.Fatalf("SplitOrgDisplayName of a plain name = %q, %q", parent, own) + } +} + +func TestOrgMatchTierSubOrgOwnName(t *testing.T) { + // A sub-organization displays as "Parent / Child" but is selected by its own name. + if OrgMatchTier("research", "sub-1", "", "Acme / Research") != orgMatchName { + t.Fatal("expected a sub-organization to match on its own name") + } + if OrgMatchTier("Acme / Research", "sub-1", "", "Acme / Research") != orgMatchName { + t.Fatal("expected the full display name to match too") + } + if OrgMatchTier("acme", "sub-1", "", "Acme / Research") != orgMatchNone { + t.Fatal("the parent's name must not select the sub-organization") + } +} + +func TestMatchKnownOrg(t *testing.T) { + profile := models.Profile{ + Name: "work", + OrganizationID: "root-1", + SubOrganizationID: "sub-1", + OrganizationName: "Acme / Research", + OrganizationSlug: "acme-research", + OrgSessions: []models.OrgSessionRef{ + {OrgID: "org-2", OrgName: "Globex", OrgSlug: "globex"}, + {OrgID: "org-3", OrgName: "Initech", OrgSlug: "initech"}, + }, + } + + t.Run("the profile's own organization matches by scoped id, slug, and own name", func(t *testing.T) { + for _, selector := range []string{"sub-1", "acme-research", "research", "Acme / Research"} { + match, ok := MatchKnownOrg(profile, selector) + if !ok || !match.IsProfileDefault || match.OrgID != "sub-1" { + t.Fatalf("selector %q: got %+v, %v", selector, match, ok) + } + } + }) + + t.Run("the root of a sub-organization session is not the profile's own organization", func(t *testing.T) { + if _, ok := MatchKnownOrg(profile, "root-1"); ok { + t.Fatal("the root id must not short-circuit while the session is scoped to a sub-organization") + } + }) + + t.Run("a cached session matches by id, slug, and name", func(t *testing.T) { + for _, selector := range []string{"org-2", "globex", "GLOBEX"} { + match, ok := MatchKnownOrg(profile, selector) + if !ok || match.IsProfileDefault || match.OrgID != "org-2" { + t.Fatalf("selector %q: got %+v, %v", selector, match, ok) + } + } + }) + + t.Run("an unknown selector is left to the server", func(t *testing.T) { + if _, ok := MatchKnownOrg(profile, "umbrella"); ok { + t.Fatal("expected no local match") + } + }) + + t.Run("a name shared by two known organizations is left to the server", func(t *testing.T) { + ambiguous := models.Profile{ + Name: "work", + OrganizationID: "root-1", + OrgSessions: []models.OrgSessionRef{ + {OrgID: "org-2", OrgName: "Globex", OrgSlug: "globex-us"}, + {OrgID: "org-4", OrgName: "Globex", OrgSlug: "globex-eu"}, + }, + } + if _, ok := MatchKnownOrg(ambiguous, "globex"); ok { + t.Fatal("expected the ambiguous name to be left unresolved") + } + match, ok := MatchKnownOrg(ambiguous, "globex-eu") + if !ok || match.OrgID != "org-4" { + t.Fatalf("expected the slug to disambiguate, got %+v, %v", match, ok) + } + }) + + t.Run("a stronger match wins regardless of order", func(t *testing.T) { + // An organization named after another one's id must not shadow it. + tricky := models.Profile{ + Name: "work", + OrgSessions: []models.OrgSessionRef{ + {OrgID: "attacker", OrgName: "org-9"}, + {OrgID: "org-9", OrgName: "Nine"}, + }, + } + match, ok := MatchKnownOrg(tricky, "org-9") + if !ok || match.OrgID != "org-9" { + t.Fatalf("expected the id match to win, got %+v, %v", match, ok) + } + }) +} + +func TestOrgSessionIndex(t *testing.T) { + profile := models.Profile{Name: "work"} + RecordOrgSession(&profile, models.OrgSessionRef{OrgID: "org-2", OrgName: "Globex"}) + RecordOrgSession(&profile, models.OrgSessionRef{OrgID: "org-3", OrgName: "Initech"}) + RecordOrgSession(&profile, models.OrgSessionRef{OrgID: "org-2", OrgName: "Globex Renamed", OrgSlug: "globex"}) + if len(profile.OrgSessions) != 2 { + t.Fatalf("expected re-recording an organization to replace its entry, got %+v", profile.OrgSessions) + } + if profile.OrgSessions[0].OrgName != "Globex Renamed" || profile.OrgSessions[0].OrgSlug != "globex" { + t.Fatalf("expected the entry to be refreshed, got %+v", profile.OrgSessions[0]) + } + if !RemoveOrgSession(&profile, "org-2") || len(profile.OrgSessions) != 1 || profile.OrgSessions[0].OrgID != "org-3" { + t.Fatalf("expected org-2 to be removed, got %+v", profile.OrgSessions) + } + if RemoveOrgSession(&profile, "missing") { + t.Fatal("removing an unknown organization must report false") + } + if got := OrgSessionKeyringKey("work", "org-3"); got != "org-session:work:org-3" { + t.Fatalf("unexpected keyring key %q", got) + } +} + +func TestScopedOrganizationID(t *testing.T) { + root := models.Profile{OrganizationID: "root-1"} + if root.ScopedOrganizationID() != "root-1" { + t.Fatal("a root session acts in the root organization") + } + sub := models.Profile{OrganizationID: "root-1", SubOrganizationID: "sub-1"} + if sub.ScopedOrganizationID() != "sub-1" { + t.Fatal("a sub-organization session acts in the sub-organization") + } +} + +func TestRenameProfile(t *testing.T) { + scopedDir := filepath.Join("/", "home", "scott", "work") + base := func() models.ConfigFile { + return models.ConfigFile{ + ActiveProfile: "scott@example.com", + LoggedInUserEmail: "scott@example.com", + LoggedInUserDomain: "https://app.infisical.com/api", + Profiles: []models.Profile{ + {Name: "scott@example.com", Email: "scott@example.com", Domain: "https://app.infisical.com/api"}, + {Name: "globex", Email: "scott@example.com", Domain: "https://app.infisical.com/api"}, + }, + DirectoryProfiles: map[string]string{scopedDir: "globex"}, + } + } + + t.Run("directory bindings follow the new name", func(t *testing.T) { + configFile := base() + if err := RenameProfile(&configFile, "globex", "globex-work"); err != nil { + t.Fatal(err) + } + if _, found := FindProfile(configFile, "globex"); found { + t.Fatal("old name still present") + } + if _, found := FindProfile(configFile, "globex-work"); !found { + t.Fatal("new name missing") + } + if configFile.DirectoryProfiles[scopedDir] != "globex-work" { + t.Fatalf("directory binding did not follow: %+v", configFile.DirectoryProfiles) + } + if configFile.ActiveProfile != "scott@example.com" { + t.Fatalf("default profile changed unexpectedly: %q", configFile.ActiveProfile) + } + }) + + t.Run("renaming the default profile moves the pointer and re-syncs the legacy fields", func(t *testing.T) { + configFile := base() + if err := RenameProfile(&configFile, "scott@example.com", "personal"); err != nil { + t.Fatal(err) + } + if configFile.ActiveProfile != "personal" { + t.Fatalf("expected the default to follow, got %q", configFile.ActiveProfile) + } + // The legacy pointer is only published for email-named profiles, since + // older binaries load the keyring entry it names. + if configFile.LoggedInUserEmail != "" { + t.Fatalf("expected the legacy pointer to be cleared, got %q", configFile.LoggedInUserEmail) + } + }) + + t.Run("rejects unknown, duplicate, and unchanged names", func(t *testing.T) { + configFile := base() + if err := RenameProfile(&configFile, "missing", "x"); err == nil { + t.Fatal("expected an error for an unknown profile") + } + if err := RenameProfile(&configFile, "globex", "scott@example.com"); err == nil { + t.Fatal("expected an error for a name already in use") + } + if err := RenameProfile(&configFile, "globex", "globex"); err == nil { + t.Fatal("expected an error for an unchanged name") + } + }) +} + +func TestRepointProfileDomainClearsOrgCache(t *testing.T) { + configFile := models.ConfigFile{ + Profiles: []models.Profile{{ + Name: "work", + Email: "scott@example.com", + Domain: "https://app.infisical.com/api", + OrganizationID: "org-a", + OrganizationSlug: "acme", + OrgSessions: []models.OrgSessionRef{{OrgID: "org-b", OrgName: "Globex"}}, + }}, + } + RepointProfileDomain(&configFile, "work", "https://self.example.com/api") + moved := configFile.Profiles[0] + if moved.OrganizationSlug != "" || len(moved.OrgSessions) != 0 { + t.Fatalf("expected the organization slug and cached sessions to be dropped, got %+v", moved) + } +} + +func TestLoginRenewalArgs(t *testing.T) { + t.Run("nothing resolved renews the default login", func(t *testing.T) { + got := LoginRenewalArgs(ResolvedProfile{}, models.Profile{}, false) + if strings.Join(got, " ") != "login --silent" { + t.Fatalf("unexpected args %v", got) + } + }) + + t.Run("an existing profile is signed back in to on its instance", func(t *testing.T) { + got := LoginRenewalArgs(ResolvedProfile{Name: "work"}, models.Profile{Name: "work", Domain: "https://eu.infisical.com/api"}, true) + if strings.Join(got, " ") != "login --silent --profile work --domain https://eu.infisical.com" { + t.Fatalf("unexpected args %v", got) + } + }) + + t.Run("a selected but missing profile is created", func(t *testing.T) { + got := LoginRenewalArgs(ResolvedProfile{Name: "client-b", Source: ProfileSourceDirectory}, models.Profile{}, false) + if strings.Join(got, " ") != "login --silent --save-as client-b" { + t.Fatalf("unexpected args %v", got) + } + }) +}