From 77d6522ced56818d917b31f08fdd1d93e22ef48a Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Wed, 26 Aug 2026 11:18:26 -0700 Subject: [PATCH] feat(cli): named login profiles for multi-organization work Switching organizations meant logging out and back in. The CLI stored one session per account, keyed by email in the keyring, and a session token is scoped to a single organization, so a second login destroyed the first. Working across tenants in parallel meant exporting tokens into env vars or .env files. A profile is now one login: an account on one instance, plus the organization it uses by default. Each profile has its own keyring entry, so sessions coexist, and selecting a profile selects the account, instance and organization together. The organization is a field of the profile rather than part of its identity. --org and INFISICAL_ORG retarget a single command by name, slug or id, and the organization-scoped token is cached per organization in the keyring, so the switch costs one exchange and nothing thereafter. Changing the profile's default is `profile set-org` (also reachable as `org switch`). Which profile a command uses is decided by --profile, then INFISICAL_PROFILE, then a bound directory, then the machine default. An explicit override wins over a bound directory, and says so, so that a binding which did not apply is explained rather than silently ignored. Those last three each get their own verb, so all of them are discoverable from `profile --help`: profile use the default for this machine profile pin this terminal only, via eval profile bind [name] [path] a directory and everything under it Sub-organizations are handled throughout: they appear nested in `org list`, `--org` resolves them by name, slug or id, and a profile scoped to one reports it as "Acme / Research" rather than as the root organization it would otherwise be indistinguishable from. Organizations that require MFA prompt during `profile new` and `profile set-org`, which perform their own exchange; `--org` on an ordinary command cannot prompt, so it fails with a message pointing at the command that can. Commands added: profile list | current | new | use | pin | unpin | bind | unbind | set-org | delete org list | switch logout Session handling. Sessions continue to expire at JWT_AUTH_LIFETIME, with expiry sending the user back through login, unchanged from today. Renewal via the stored refresh token stays unimplemented on purpose: the server rotates the refresh token on every refresh and treats a stale one as theft by revoking the session, which several CLI processes sharing one vault entry cannot coordinate safely. The token is also no longer written to the vault, since nothing read it and storing it only widens what a stolen vault yields. `logout` revokes server-side, and so do `profile delete` and `reset`. Because the server keys sessions by user, IP and user agent, several profiles for one account on one machine share a session, so a session another profile still uses is left intact and only local credentials are removed. Integration with existing commands: `init` uses the profile's organization instead of asking again and offers to bind the directory; `user switch` operates on profiles; `vault set` clears them. An explicit --domain now beats a profile's saved domain instead of being silently overridden, `user update domain` only repoints profiles that were on the instance being changed rather than every profile sharing an email, and `reset` removes every stored session instead of orphaning all but the active one. Hardening from review: profile names are shell-quoted where pin prints an export, since a derived name comes from a server-supplied email and would otherwise run as a command under eval; organization selectors match by id, then slug, then name, with ambiguity rejected, so an organization named after another's id cannot be selected in its place; logout authenticates revocation with any live token rather than only the profile's own, which previously let a cached organization token survive locally deleted credentials; a profile's session is refused rather than sent when an explicit --domain names a different instance; `user update domain` selects a profile rather than an account, so profiles sharing an email and instance for different organizations are not moved together, and the moved profile's session is cleared, before the new instance is recorded, since a session that outlived the change would be sent there; server-supplied names are stripped of control characters before reaching a terminal; and the legacy login pointer is published only for email-named profiles, so an older binary cannot load one profile's token while aimed at another's instance. Migration is lazy and requires no re-login. Legacy loggedInUserEmail and loggedInUsers entries become profiles named after the account email, which is also the legacy keyring key, so existing sessions keep working untouched, and those fields stay in sync with the active profile for older binaries and scripts that read them. Single-profile users see no change in behavior. Co-Authored-By: Claude Opus 5 (1M context) --- packages/api/api.go | 20 + packages/cmd/init.go | 144 +++--- packages/cmd/login.go | 60 ++- packages/cmd/logout.go | 98 ++++ packages/cmd/org.go | 279 ++++++++++++ packages/cmd/profile.go | 642 ++++++++++++++++++++++++++ packages/cmd/reset.go | 46 +- packages/cmd/root.go | 110 +++++ packages/cmd/user.go | 149 +++--- packages/cmd/vault.go | 7 + packages/config/config.go | 18 + packages/models/cli.go | 42 ++ packages/telemetry/telemetry.go | 6 +- packages/util/auth.go | 13 +- packages/util/config.go | 62 --- packages/util/constants.go | 8 + packages/util/credentials.go | 273 +++++++++-- packages/util/helper.go | 8 +- packages/util/logout.go | 213 +++++++++ packages/util/profile.go | 777 ++++++++++++++++++++++++++++++++ packages/util/profile_test.go | 596 ++++++++++++++++++++++++ 21 files changed, 3310 insertions(+), 261 deletions(-) create mode 100644 packages/cmd/logout.go create mode 100644 packages/cmd/org.go create mode 100644 packages/cmd/profile.go create mode 100644 packages/util/logout.go create mode 100644 packages/util/profile.go create mode 100644 packages/util/profile_test.go 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") + } + }) +}