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..a0b371ac 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,66 @@ 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. + selectedOrgID := userCreds.OrganizationID + var selectedSubOrgName *string - 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 + } + selectedOrgID = orgID - // set the config jwt token to the new token - userCreds.UserCredentials.JTWToken = tokenResponse.Token - err = util.StoreUserCredsInKeyRing(&userCreds.UserCredentials) - httpClient.SetAuthToken(tokenResponse.Token) + updatedProfile := userCreds.Profile + updatedProfile.OrganizationID = orgID + updatedProfile.SubOrganizationID = subOrgID + updatedProfile.OrganizationName = util.OrgDisplayName(newSessionToken, orgID, subOrgID) - if err != nil { - util.HandleError(err, "Unable to store your user credentials") + // 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 + } + util.PrintlnStderr(fmt.Sprintf("Using organization %s from profile '%s'. Pass --org to pick a different one.", orgDisplay, userCreds.ProfileName)) + + // An --org override is per command, so a project linked under it would + // not resolve on later runs that use the profile's default. + if userCreds.OrganizationSource != util.OrgSourceProfileDefault && userCreds.Profile.OrganizationID != "" && userCreds.OrganizationID != userCreds.Profile.OrganizationID { + profileOrg := userCreds.Profile.OrganizationName + if profileOrg == "" { + profileOrg = userCreds.Profile.OrganizationID + } + util.PrintWarning(fmt.Sprintf("Profile '%s' defaults to organization %s, so later commands here will not find this project unless you pass --org again. Run [infisical profile set-org %s] to make it the default.", userCreds.ProfileName, profileOrg, orgDisplay)) + } } workspaceResponse, err := api.CallGetAllWorkSpacesUserBelongsTo(httpClient) @@ -140,11 +143,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..5d5e8ddc 100644 --- a/packages/cmd/login.go +++ b/packages/cmd/login.go @@ -134,14 +134,17 @@ var loginCmd = &cobra.Command{ } 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")) { + // 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 { + // When a profile is explicitly targeted (flag or env var), the login is + // a deliberate write to that profile; skip the add/override menu. + if config.INFISICAL_PROFILE_OVERRIDE == "" && currentLoggedInUserDetails.IsUserLoggedIn && !currentLoggedInUserDetails.LoginExpired && len(currentLoggedInUserDetails.UserCredentials.PrivateKey) != 0 { shouldOverride, err := userLoginMenu(currentLoggedInUserDetails.UserCredentials.Email) if err != nil { util.HandleError(err) @@ -227,7 +230,40 @@ var loginCmd = &cobra.Command{ cliDefaultLogin(&userCredentialsToBeStored, email, password, organizationId) } - err = util.StoreUserCredsInKeyRing(&userCredentialsToBeStored) + orgID, subOrgID := util.ParseTokenOrgClaims(userCredentialsToBeStored.JTWToken) + orgName := util.OrgDisplayName(userCredentialsToBeStored.JTWToken, orgID, subOrgID) + + existingConfig, err := util.GetMigratedConfigFile() + if err != nil { + util.HandleError(err, "Unable to read the Infisical config file") + } + + profileName := config.INFISICAL_PROFILE_OVERRIDE + if profileName == "" { + profileName = util.DeriveProfileName(existingConfig, userCredentialsToBeStored.Email, config.INFISICAL_URL, orgID, orgName) + } else if err := util.ValidateProfileName(profileName); err != nil { + util.HandleError(err) + } + + if existingProfile, found := util.FindProfile(existingConfig, profileName); found && 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)) + } + + // An explicitly targeted login (--profile flag or INFISICAL_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 := config.INFISICAL_PROFILE_OVERRIDE == "" + + err = util.PersistLoginProfile(models.Profile{ + Name: profileName, + Email: userCredentialsToBeStored.Email, + Domain: config.INFISICAL_URL, + OrganizationID: orgID, + OrganizationName: orgName, + 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 +272,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 +298,17 @@ var loginCmd = &cobra.Command{ boldWhite.Printf(">>>> Welcome to Infisical!") boldWhite.Printf(" You are now logged in as %v <<<< \n", userCredentialsToBeStored.Email) + if profileName != userCredentialsToBeStored.Email { + orgDetail := "" + if orgName != "" { + orgDetail = fmt.Sprintf(" (org %s)", orgName) + } + 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") 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..9c14d1ab --- /dev/null +++ b/packages/cmd/org.go @@ -0,0 +1,279 @@ +/* +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/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) + + currentOrgID := details.OrganizationID + if claimOrgID, _ := util.ParseTokenOrgClaims(details.UserCredentials.JTWToken); 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 + } + orgName := util.OrgDisplayName(newSessionToken, orgID, subOrgID) + + profile := details.Profile + profile.OrganizationID = orgID + profile.OrganizationName = orgName + profile.SubOrganizationID = subOrgID + + 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 < 6 { + 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", 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: 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..8fdaa440 --- /dev/null +++ b/packages/cmd/profile.go @@ -0,0 +1,642 @@ +/* +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], and one per extra organization +with [infisical profile new]. + +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. +func requireShellCapture(invocation 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.", invocation)) + os.Exit(1) +} + +// 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.Name), 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 --profile " + 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. + organization := profile.OrganizationName + if organization != "" && profile.OrganizationID != "" { + organization = fmt.Sprintf("%s (%s)", organization, profile.OrganizationID) + } else if organization == "" { + organization = 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 id:", 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 --profile ] 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) + orgName := util.OrgDisplayName(sessionToken, orgID, subOrgID) + + credentials := details.UserCredentials + credentials.JTWToken = sessionToken + // Cached organization tokens belong to the profile they were minted + // under; a new profile starts with an empty cache. + credentials.OrgTokens = nil + + 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. + err = util.PersistLoginProfile(models.Profile{ + Name: profileName, + Email: details.UserCredentials.Email, + Domain: domain, + OrganizationID: orgID, + OrganizationName: orgName, + 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)\"].", 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].`, + 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)) + + // 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("Pinned this terminal to profile '%s' (%s, org %s). 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)"`, + DisableFlagsInUseLine: true, + Example: "eval \"$(infisical profile unpin)\"", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + requireShellCapture("infisical profile unpin") + + util.PrintlnStdout(fmt.Sprintf("unset %s", util.INFISICAL_PROFILE_ENV_NAME)) + util.PrintlnStderr("Removed this terminal's profile pin. It now 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 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(profileDeleteCmd) + RootCmd.AddCommand(profileCmd) +} diff --git a/packages/cmd/reset.go b/packages/cmd/reset.go index 2af92583..6d2f5b90 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,47 @@ 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 + } // delete from keyring - util.DeleteValueInKeyring(configFile.LoggedInUserEmail) + for key := range keyringKeys { + util.DeleteValueInKeyring(key) + } // delete config _, pathToDir, err := util.GetFullConfigFilePath() @@ -41,5 +72,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..883e022a 100644 --- a/packages/cmd/root.go +++ b/packages/cmd/root.go @@ -123,6 +123,86 @@ 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() +} + +// printActiveProfileNotice surfaces which profile a command will use when the +// selection came from somewhere non-obvious: the --profile flag, the +// INFISICAL_PROFILE env var, or a directory scope. Single-profile setups and +// plain default-profile usage stay quiet. +func printActiveProfileNotice(cmd *cobra.Command, silent bool) { + if silent || isStructuredOutputRequested(cmd) || profileNoticeExemptCommands[topLevelCommandName(cmd)] { + return + } + + orgSelector, orgSource := util.GetOrgOverride() + + resolved, profile, _ := util.ResolveActiveProfileDetails() + if resolved.Name == "" { + return + } + // Quiet when nothing non-obvious happened: the default profile and no + // organization override. + if resolved.Source == util.ProfileSourceDefault && orgSelector == "" { + return + } + + // A provided token supersedes the login session; the token warning above + // already covers that case. + if token, err := util.GetInfisicalToken(cmd); err == nil && token != nil { + return + } + + orgName := profile.OrganizationName + if orgName == "" { + orgName = profile.OrganizationID + } + if orgSelector != "" { + orgName = orgSelector + } + + detail := "" + if orgName != "" { + detail = fmt.Sprintf(" (org %s)", orgName) + } + + via := resolved.Source + if resolved.ScopeDir != "" { + via = fmt.Sprintf("%s %s", via, resolved.ScopeDir) + } + if orgSelector != "" { + if resolved.Source == util.ProfileSourceDefault { + via = orgSource + } else { + via = fmt.Sprintf("%s, org via %s", via, orgSource) + } + } + + shadowed := "" + if resolved.ShadowedName != "" { + shadowed = fmt.Sprintf(", overriding this directory's binding to '%s'", resolved.ShadowedName) + } + + fmt.Fprintf(cmd.ErrOrStderr(), "Using profile '%s'%s via %s%s\n", util.SanitizeDisplay(resolved.Name), util.SanitizeDisplay(detail), via, util.SanitizeDisplay(shadowed)) +} + func init() { util.GetStderrWriter = RootCmdStderrWriter util.GetStdoutWriter = RootCmdStdoutWriter @@ -133,12 +213,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 +265,7 @@ func init() { } } + printActiveProfileNotice(cmd, silent) } isTelemetryOn, _ := RootCmd.PersistentFlags().GetBool("telemetry") diff --git a/packages/cmd/user.go b/packages/cmd/user.go index 0a461bcf..77868a9e 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.Name); 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)) }, } 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..1e57a6d0 100644 --- a/packages/models/cli.go +++ b/packages/models/cli.go @@ -7,10 +7,44 @@ type UserCredentials struct { PrivateKey string `json:"privateKey"` JTWToken string `json:"JTWToken"` RefreshToken string `json:"RefreshToken"` + // OrgTokens caches session tokens for organizations other than the + // profile's default one, keyed by organization ID. Session tokens are + // organization-scoped, so switching organizations means exchanging the + // token; caching the result keeps --org cheap after its first use. + OrgTokens map[string]CachedOrgSession `json:"orgTokens,omitempty"` +} + +// CachedOrgSession is a session token minted for a specific organization, +// stored alongside enough metadata to match an --org selector without calling +// the API again. +type CachedOrgSession struct { + Token string `json:"token"` + OrgID string `json:"orgId"` + 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 profile's default organization. + OrganizationID string `json:"organizationId,omitempty"` + OrganizationName string `json:"organizationName,omitempty"` + SubOrganizationID string `json:"subOrganizationId,omitempty"` } // 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 +58,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..a92d9319 100644 --- a/packages/util/auth.go +++ b/packages/util/auth.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "os/exec" + "strings" infisicalSdk "github.com/infisical/go-sdk" "github.com/rs/zerolog/log" @@ -70,8 +71,18 @@ func EstablishUserLoginSession() LoggedInUserDetails { PrintErrorMessageAndExit(fmt.Sprintf("Failed to determine executable path: %v", err)) } + loginArgs := []string{"login", "--silent"} + // Target the profile this invocation resolved to, so the refreshed session + // lands in the same profile (and on its instance) instead of the default one. + if resolved, profile, _ := ResolveActiveProfileDetails(); resolved.Name != "" { + loginArgs = append(loginArgs, "--profile", resolved.Name) + if profile.Domain != "" { + loginArgs = append(loginArgs, "--domain", strings.TrimSuffix(profile.Domain, "/api")) + } + } + // 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..917a943b 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,8 @@ func StoreUserCredsInKeyRing(userCred *models.UserCredentials) error { return err } -func GetUserCredsFromKeyRing(userEmail string) (credentials models.UserCredentials, err error) { - credentialsValue, err := GetValueInKeyring(userEmail) +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 +103,191 @@ 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 --profile %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 } } + } - 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) - } + // 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, + OrganizationID: profile.OrganizationID, + 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 + } + } + + 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 + + // Already on the requested organization: nothing to do, and no API calls. + // Only an id match is trusted here, since a name or slug could belong to a + // different organization and would skip the exchange wrongly. + if OrgMatchTier(selector, profile.OrganizationID, "", "") == orgMatchID { + details.OrganizationSource = selectorSource + return nil + } + + // A previously minted token for this organization avoids both the lookup + // and the exchange. Pick the strongest match rather than the first, so a + // cached entry matching only by name cannot shadow one matching by id. + bestCached := models.CachedOrgSession{} + bestTier := 0 + for _, cached := range details.UserCredentials.OrgTokens { + tier := OrgMatchTier(selector, cached.OrgID, cached.OrgSlug, cached.OrgName) + if tier > bestTier && !IsJWTExpired(cached.Token) { + bestCached, bestTier = cached, tier } + } + if bestTier != 0 { + details.UserCredentials.JTWToken = bestCached.Token + details.OrganizationID = bestCached.OrgID + details.OrganizationName = bestCached.OrgName + details.OrganizationSource = selectorSource + return nil + } + + resolvedOrg, err := ResolveOrgSelector(details.UserCredentials.JTWToken, selector) + if err != nil { + return err + } + + if resolvedOrg.ID == profile.OrganizationID { + details.OrganizationName = resolvedOrg.Name + details.OrganizationSource = selectorSource + 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, resolvedOrg.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", resolvedOrg.Name, selectorSource, selector, profile.Name) } + return fmt.Errorf("unable to scope your session to organization '%s' [err=%s]", resolvedOrg.Name, err) + } - return LoggedInUserDetails{ - IsUserLoggedIn: true, - LoginExpired: false, - UserCredentials: userCreds, - }, nil - } else { - return LoggedInUserDetails{}, nil + if details.UserCredentials.OrgTokens == nil { + details.UserCredentials.OrgTokens = map[string]models.CachedOrgSession{} + } + details.UserCredentials.OrgTokens[resolvedOrg.ID] = models.CachedOrgSession{ + Token: orgToken, + OrgID: resolvedOrg.ID, + OrgName: resolvedOrg.Name, + OrgSlug: resolvedOrg.Slug, } + + // Persist the new cache entry while leaving the profile's own session token + // alone: --org retargets a single command, so writing the organization + // token as the profile's primary one would silently repoint the profile. + if err := StoreUserCredsInKeyRing(profile.Name, &details.UserCredentials); err != nil { + // The in-memory token is still usable; caching it is best effort. + log.Debug().Err(err).Msg("unable to cache organization-scoped session token") + } + + // Only this invocation runs against the organization-scoped token. + details.UserCredentials.JTWToken = orgToken + + details.OrganizationID = resolvedOrg.ID + details.OrganizationName = resolvedOrg.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..48c19701 --- /dev/null +++ b/packages/util/logout.go @@ -0,0 +1,213 @@ +package util + +import ( + "errors" + "fmt" + + "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 +} + +// collectSessionIDs returns every distinct server-side session id represented +// by a profile's stored credentials, including organization-scoped tokens. +func collectSessionIDs(creds models.UserCredentials) []string { + seen := map[string]bool{} + ids := []string{} + + add := func(token string) { + if id := ParseTokenSessionID(token); id != "" && !seen[id] { + seen[id] = true + ids = append(ids, id) + } + } + + add(creds.JTWToken) + for _, cached := range creds.OrgTokens { + add(cached.Token) + } + + return ids +} + +// liveToken returns a session token that is still valid, preferring the +// profile's own. An organization token cached later can outlive it, and +// revocation needs some live token to authenticate with. +func liveToken(creds models.UserCredentials) string { + if creds.JTWToken != "" && !IsJWTExpired(creds.JTWToken) { + return creds.JTWToken + } + for _, cached := range creds.OrgTokens { + if cached.Token != "" && !IsJWTExpired(cached.Token) { + return cached.Token + } + } + return "" +} + +// ClearStoredSession removes a profile's stored credentials. An entry that is +// already absent counts as success, since the goal is that nothing remains. +func ClearStoredSession(profileName string) error { + err := DeleteValueInKeyring(profileName) + if err == nil || errors.Is(err, keyring.ErrNotFound) { + return nil + } + return err +} + +// 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 + } + creds, err := GetUserCredsFromKeyRing(profile.Name) + if err != nil { + continue + } + for _, id := range collectSessionIDs(creds) { + retained[id] = profile.Name + } + } + + results := make([]LogoutResult, 0, len(targetNames)) + for _, name := range targetNames { + result := LogoutResult{ProfileName: name} + + creds, err := GetUserCredsFromKeyRing(name) + if err != nil { + 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(creds) + for _, sessionID := range collectSessionIDs(creds) { + 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 := DeleteValueInKeyring(name); 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(profileName string) string { + creds, err := GetUserCredsFromKeyRing(profileName) + if err != nil { + return "none" + } + if IsJWTExpired(creds.JTWToken) { + return "expired" + } + if len(creds.OrgTokens) > 0 { + return fmt.Sprintf("active (+%d org)", len(creds.OrgTokens)) + } + 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..69feabf3 --- /dev/null +++ b/packages/util/profile.go @@ -0,0 +1,777 @@ +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 +} + +func ValidateProfileName(name string) error { + 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 +) + +// 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". +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 != "" && strings.EqualFold(selector, name) { + 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 string + Slug string + // matchName is the bare name to match selectors against. Sub-organizations + // display as "Parent / Child" but should still match on their own name. + matchName 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: fmt.Sprintf("%s / %s", org.Name, sub.Name), Slug: sub.Slug, matchName: sub.Name}) + } + } + } + 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 { + matchable := candidate.matchName + if matchable == "" { + matchable = candidate.Name + } + tier := OrgMatchTier(selector, candidate.ID, candidate.Slug, matchable) + 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 +} + +// 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].SubOrganizationID = "" + + 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 +} + +// 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; use +// the bare email when free; otherwise suffix with the organization so a second +// organization never overwrites the first. +func DeriveProfileName(configFile models.ConfigFile, email string, domain string, orgID string, orgName 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 + } + } + + if findProfileIndex(configFile.Profiles, email) < 0 { + return email + } + + suffix := slugifyProfileSuffix(orgName) + if suffix == "" { + if len(orgID) >= 8 { + suffix = orgID[:8] + } else { + suffix = orgID + } + } + if suffix == "" { + suffix = "2" + } + + 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, --save-as), + // 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 +} + +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 +} + +// FetchOrganizationName resolves an organization's display name with the given +// session token. Best effort: returns "" on any error so callers can fall back +// to showing the ID. +func FetchOrganizationName(jwtToken string, orgID string) string { + if orgID == "" || jwtToken == "" { + return "" + } + + httpClient, err := GetRestyClientWithCustomHeaders() + if err != nil { + return "" + } + httpClient.SetAuthToken(jwtToken) + + if orgResp, err := api.CallGetAllOrganizations(httpClient); err == nil { + for _, org := range orgResp.Organizations { + if org.ID == orgID { + return org.Name + } + } + } + + // The ID may belong to a sub-organization, which the flat list omits. + if subOrgsResp, err := api.CallGetAllOrganizationsWithSubOrgs(httpClient); err == nil { + for _, org := range subOrgsResp.Organizations { + if org.ID == orgID { + return org.Name + } + for _, sub := range org.SubOrganizations { + if sub.ID == orgID { + return fmt.Sprintf("%s / %s", org.Name, sub.Name) + } + } + } + } + + return "" +} + +// OrgDisplayName resolves the human-readable organization for a session. When +// the session is scoped to a sub-organization the sub-organization is used, so +// a session inside "Acme / Research" is not reported as plain "Acme", which +// would be indistinguishable from one scoped to the root organization. +func OrgDisplayName(sessionToken string, orgID string, subOrgID string) string { + if subOrgID != "" { + if name := FetchOrganizationName(sessionToken, subOrgID); name != "" { + return SanitizeDisplay(name) + } + } + return SanitizeDisplay(FetchOrganizationName(sessionToken, orgID)) +} + +// 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_test.go b/packages/util/profile_test.go new file mode 100644 index 00000000..7f1c260e --- /dev/null +++ b/packages/util/profile_test.go @@ -0,0 +1,596 @@ +package util + +import ( + "path/filepath" + "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 uses the bare email", func(t *testing.T) { + name := DeriveProfileName(base, "new@example.com", "https://app.infisical.com/api", "org-9", "Acme") + if name != "new@example.com" { + t.Fatalf("expected bare email, got %q", name) + } + }) + + t.Run("a plus-addressed email is used verbatim", func(t *testing.T) { + name := DeriveProfileName(base, "ci+tests@example.com", "https://app.infisical.com/api", "org-9", "Acme") + if name != "ci+tests@example.com" { + t.Fatalf("expected plus-addressed email verbatim, 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 a suffixed name instead of overwriting", 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 org-suffixed name, 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") + } + }) +}