From 77d6522ced56818d917b31f08fdd1d93e22ef48a Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Wed, 26 Aug 2026 11:18:26 -0700 Subject: [PATCH 1/2] 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") + } + }) +} From 8455339a09eb917e936196018b9868196a77ff6d Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Fri, 11 Sep 2026 17:19:04 -0700 Subject: [PATCH 2/2] fix(cli): address review feedback on named login profiles Sub-organizations were the main correctness problem. A session scoped to one carries the root organization in its token and acts in the sub-organization, so filtering projects by the token's id found nothing and init aborted even when projects existed. Profiles now record both ids and expose the scoped one, which init, org list and --org all use. --org is resolved against what the profile already knows before any API call. Profiles store the organization slug, and organizations used before are kept in a small index, so a repeated selector costs nothing. Their tokens moved out of the profile's keyring entry into one entry each, keeping that entry well under the platform size limit no matter how many organizations are used. Generated profile names now carry the organization slug from the first login, so they say which tenant they belong to, and profile rename can change them. A profile not named after its email no longer publishes the legacy loggedInUserEmail pointer, so an older binary asks for a fresh login rather than loading another profile's session. login gains --save-as to store a login under a chosen name, creating that profile or replacing its session. --profile now only signs back in to an existing profile, keeping its account, instance and organization, so --organization-id is not needed again. Also: init refuses instead of warning when --org would link a project later commands could not find; login status reports the profile, its selection source and the organization name; the "Using profile" notice moved into the session loader so commands that never authenticate stay quiet; pin explains the PowerShell and CI alternatives and no longer claims a pin took effect before a shell evaluates it; typed profile names are length checked; the MFA attempt limit is shared between the login and organization flows; and the dead legacy user helpers are removed. Co-Authored-By: Claude Fable 5.1 --- packages/cmd/init.go | 31 ++- packages/cmd/login.go | 259 ++++++++++++++------- packages/cmd/login_status.go | 71 ++++-- packages/cmd/login_status_test.go | 68 ++++++ packages/cmd/login_target_test.go | 96 ++++++++ packages/cmd/org.go | 27 ++- packages/cmd/profile.go | 173 +++++++++++--- packages/cmd/reset.go | 3 + packages/cmd/root.go | 68 +----- packages/cmd/user.go | 44 +--- packages/models/cli.go | 54 +++-- packages/util/auth.go | 11 +- packages/util/credentials.go | 182 ++++++++++----- packages/util/logout.go | 106 +++++---- packages/util/profile.go | 326 ++++++++++++++++++++++----- packages/util/profile_notice.go | 76 +++++++ packages/util/profile_notice_test.go | 41 ++++ packages/util/profile_test.go | 310 +++++++++++++++++++++++-- 18 files changed, 1511 insertions(+), 435 deletions(-) create mode 100644 packages/cmd/login_target_test.go create mode 100644 packages/util/profile_notice.go create mode 100644 packages/util/profile_notice_test.go diff --git a/packages/cmd/init.go b/packages/cmd/init.go index a0b371ac..78f1fd96 100644 --- a/packages/cmd/init.go +++ b/packages/cmd/init.go @@ -62,8 +62,15 @@ var initCmd = &cobra.Command{ // for this command), so don't ask again. Only fall back to the picker // when the profile has no organization recorded, which happens for // sessions migrated from a CLI that predates profiles. + // + // OrganizationID is the organization the session acts in. For a session + // scoped to a sub-organization that is the sub-organization, which is + // also where its projects are filed, not the root from the token. selectedOrgID := userCreds.OrganizationID var selectedSubOrgName *string + if parent, own := util.SplitOrgDisplayName(userCreds.OrganizationName); parent != "" { + selectedSubOrgName = &own + } if selectedOrgID == "" { pickedOrgID, pickedSubOrgName, err := pickOrganization(httpClient, "Which Infisical organization would you like to select a project from?", userCreds.UserCredentials.Email) @@ -85,12 +92,14 @@ var initCmd = &cobra.Command{ if orgID == "" { orgID = pickedOrgID } - selectedOrgID = orgID + orgInfo := util.DescribeSessionOrg(newSessionToken, orgID, subOrgID) updatedProfile := userCreds.Profile updatedProfile.OrganizationID = orgID updatedProfile.SubOrganizationID = subOrgID - updatedProfile.OrganizationName = util.OrgDisplayName(newSessionToken, orgID, subOrgID) + updatedProfile.OrganizationName = orgInfo.DisplayName() + updatedProfile.OrganizationSlug = orgInfo.Slug + selectedOrgID = updatedProfile.ScopedOrganizationID() // Only move the global default when this invocation was using it; a // terminal pinned via env var, flag, or directory scope must not switch @@ -107,17 +116,23 @@ var initCmd = &cobra.Command{ 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 { + // An --org override is per command, so a project linked under it + // would not resolve on later runs that use the profile's default. + // That would surface later as an unrelated-looking "project not + // found", and a warning here can be silenced, so refuse and point + // at the two ways to make the organization stick. + if userCreds.OrganizationSource != util.OrgSourceProfileDefault && userCreds.Profile.OrganizationID != "" && userCreds.OrganizationID != userCreds.Profile.ScopedOrganizationID() { profileOrg := userCreds.Profile.OrganizationName if profileOrg == "" { - profileOrg = userCreds.Profile.OrganizationID + profileOrg = userCreds.Profile.ScopedOrganizationID() } - 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)) + util.PrintErrorMessageAndExit( + fmt.Sprintf("Profile '%s' defaults to organization %s, so a project linked here under %s (selected via %s) would not be found by later commands unless they also pass --org.", userCreds.ProfileName, profileOrg, orgDisplay, userCreds.OrganizationSource), + fmt.Sprintf("Make %s the profile's default with [infisical profile set-org %s], or keep both organizations by running [infisical profile new --org %s] and then [infisical profile bind ] in this directory.", orgDisplay, orgDisplay, orgDisplay)) } + + util.PrintlnStderr(fmt.Sprintf("Using organization %s from profile '%s'. Pass --org to pick a different one.", orgDisplay, userCreds.ProfileName)) } workspaceResponse, err := api.CallGetAllWorkSpacesUserBelongsTo(httpClient) diff --git a/packages/cmd/login.go b/packages/cmd/login.go index 5d5e8ddc..d2f288bb 100644 --- a/packages/cmd/login.go +++ b/packages/cmd/login.go @@ -48,10 +48,21 @@ const REPLACE_USER = "Override current logged in user" const EXIT_USER_MENU = "Exit" const QUIT_BROWSER_LOGIN = "q" +// mfaMaxAttempts is how many verification codes a user may try before a +// command gives up. Every MFA prompt in the CLI uses it, so they stay in step. +const mfaMaxAttempts = 5 + // loginCmd represents the login command var loginCmd = &cobra.Command{ - Use: "login", - Short: "Login into your Infisical account", + Use: "login", + Short: "Login into your Infisical account", + Long: `Login into your Infisical account. + +Sessions are stored in login profiles. Without options the profile is named +after the account and organization, as in scott@example.com--acme-x4k2. Use +--save-as to store the login under a name of your choice, creating that +profile or replacing its session, and --profile to sign back in to a +profile that already exists.`, DisableFlagsInUseLine: true, PreRunE: func(cmd *cobra.Command, args []string) error { // daniel: oidc-jwt is deprecated in favor of `jwt`. we backfill the `jwt` flag with the value of `oidc-jwt` if it's set. @@ -127,13 +138,40 @@ var loginCmd = &cobra.Command{ // standalone user auth if loginMethod == "user" { - isDirectUserLoginFlagsAndEnvsSet, err := validateDirectUserLoginFlagsAndEnvsSet(cmd, presetDomain) + existingConfig, err := util.GetMigratedConfigFile() + if err != nil { + util.HandleError(err, "Unable to read the Infisical config file") + } + + saveAs, err := cmd.Flags().GetString("save-as") + if err != nil { + util.HandleError(err) + } + profileOverride, profileOverrideSource := util.GetProfileOverride() + target, err := resolveLoginTarget(saveAs, profileOverride, profileOverrideSource, existingConfig) + if err != nil { + util.HandleError(err) + } + + // Signing back in to a profile takes its organization, so + // --organization-id is not required to drive the login + // non-interactively the way it is for a fresh one. + isDirectUserLoginFlagsAndEnvsSet, err := validateDirectUserLoginFlagsAndEnvsSet(cmd, presetDomain, target.reauth && target.profile.ScopedOrganizationID() != "") if err != nil { util.HandleError(err) } + // Login creates or refreshes a session and never scopes it through + // --org, so resolve the existing session with the override suspended: + // applying it here could only fail (unknown organization, MFA) and + // abort the login. + if _, orgSource := util.GetOrgOverride(); orgSource == util.OrgSourceFlag && !silentMode { + util.PrintWarning("--org does not apply to login. Pick the organization in the prompt, or pass --organization-id.") + } + restoreOrgOverride := util.SuspendOrgOverride() currentLoggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) + restoreOrgOverride() // if the key can't be found, the selected profile doesn't exist yet, or // there is an error getting current credentials from key ring, allow them to override if err != nil && (errors.Is(err, util.ErrProfileNotFound) || errors.Is(err, util.ErrProfileDomainMismatch) || strings.Contains(err.Error(), "we couldn't find your logged in details")) { @@ -142,9 +180,10 @@ var loginCmd = &cobra.Command{ util.HandleError(err) } - // 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 { + // A login that names its profile (--save-as, or --profile for an + // existing one) is a deliberate write to that profile; skip the + // add/override menu. + if !target.explicit && currentLoggedInUserDetails.IsUserLoggedIn && !currentLoggedInUserDetails.LoginExpired && len(currentLoggedInUserDetails.UserCredentials.PrivateKey) != 0 { shouldOverride, err := userLoginMenu(currentLoggedInUserDetails.UserCredentials.Email) if err != nil { util.HandleError(err) @@ -157,7 +196,27 @@ var loginCmd = &cobra.Command{ domainFlagExplicitlySet := cmd.Flags().Changed("domain") shouldPrintInfo := !silentMode && !plainOutput - usePresetDomain, err := usePresetDomain(presetDomain, domainFlagExplicitlySet, shouldPrintInfo) + printPresetDomainInfo := shouldPrintInfo + + // Signing back in to a profile keeps it on its instance: the stored + // domain stands in for the flag, so no hosting prompt appears, and an + // explicitly requested different instance is refused rather than + // silently moving the profile there. + if target.reauth && target.profile.Domain != "" { + profileDomain := util.AppendAPIEndpoint(target.profile.Domain) + if config.INFISICAL_DOMAIN_EXPLICITLY_SET && util.AppendAPIEndpoint(presetDomain) != profileDomain { + util.PrintErrorMessageAndExit(fmt.Sprintf("Profile '%s' belongs to %s, but %s was requested. Sign in there as a new profile with --save-as , or move this profile with [infisical user update domain].", + target.name, util.DisplayDomain(profileDomain), util.DisplayDomain(presetDomain))) + } + presetDomain = profileDomain + domainFlagExplicitlySet = true + printPresetDomainInfo = false + if shouldPrintInfo { + util.PrintlnStderr(fmt.Sprintf("Signing back in to profile '%s' on %s.", target.name, util.DisplayDomain(profileDomain))) + } + } + + usePresetDomain, err := usePresetDomain(presetDomain, domainFlagExplicitlySet, printPresetDomainInfo) if err != nil { util.HandleError(err) @@ -220,41 +279,72 @@ var loginCmd = &cobra.Command{ var organizationId string + // Signing back in to a profile keeps its organization, so the + // profile supplies the default and neither the flag nor the + // picker is needed; an explicit --organization-id still wins. + profileOrgID := "" + if target.reauth { + profileOrgID = target.profile.ScopedOrganizationID() + } + if isDirectUserLoginFlagsAndEnvsSet { - organizationId, err = util.GetCmdFlagOrEnv(cmd, "organization-id", []string{"INFISICAL_ORGANIZATION_ID"}) + if profileOrgID != "" { + organizationId, err = util.GetCmdFlagOrEnvWithDefaultValue(cmd, "organization-id", []string{"INFISICAL_ORGANIZATION_ID"}, profileOrgID) + } else { + organizationId, err = util.GetCmdFlagOrEnv(cmd, "organization-id", []string{"INFISICAL_ORGANIZATION_ID"}) + } if err != nil { util.HandleError(err) } + } else if organizationId == "" { + organizationId = profileOrgID } cliDefaultLogin(&userCredentialsToBeStored, email, password, organizationId) } - 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") + // The browser flow scopes the session to whatever organization the + // browser had selected. Signing back in to a profile keeps its + // organization, so re-scope the session when the two differ. + if wantOrg := target.profile.ScopedOrganizationID(); target.reauth && wantOrg != "" { + gotOrg, gotSubOrg := util.ParseTokenOrgClaims(userCredentialsToBeStored.JTWToken) + if gotSubOrg != "" { + gotOrg = gotSubOrg + } + if gotOrg != wantOrg { + rescopedToken, err := selectOrganizationToken(userCredentialsToBeStored.JTWToken, userCredentialsToBeStored.Email, wantOrg) + if err != nil { + util.HandleError(err, fmt.Sprintf("Unable to scope the session to the organization of profile '%s'. Run [infisical login] without --profile to pick another organization.", target.name)) + } + userCredentialsToBeStored.JTWToken = rescopedToken + } } - profileName := config.INFISICAL_PROFILE_OVERRIDE + orgID, subOrgID := util.ParseTokenOrgClaims(userCredentialsToBeStored.JTWToken) + orgInfo := util.DescribeSessionOrg(userCredentialsToBeStored.JTWToken, orgID, subOrgID) + orgName := orgInfo.DisplayName() + + profileName := target.name if profileName == "" { - profileName = util.DeriveProfileName(existingConfig, userCredentialsToBeStored.Email, config.INFISICAL_URL, orgID, orgName) - } else if err := util.ValidateProfileName(profileName); err != nil { - util.HandleError(err) + profileName = util.DeriveProfileName(existingConfig, userCredentialsToBeStored.Email, config.INFISICAL_URL, orgID, orgName, orgInfo.Slug) } if existingProfile, found := util.FindProfile(existingConfig, profileName); found && existingProfile.Email != userCredentialsToBeStored.Email { + if target.reauth { + // Signing back in means the same account. A different one is + // almost certainly a browser signed in as someone else, so do + // not quietly hand the profile to that account. + util.PrintErrorMessageAndExit(fmt.Sprintf("Profile '%s' belongs to %s, but you signed in as %s. Nothing was stored. To keep this login, run [infisical login --save-as ] with another name.", profileName, existingProfile.Email, userCredentialsToBeStored.Email)) + } util.PrintWarning(fmt.Sprintf("Profile '%s' previously stored the session for %s and now stores the session for %s.", profileName, existingProfile.Email, userCredentialsToBeStored.Email)) } - // An explicitly targeted login (--profile flag or INFISICAL_PROFILE) is a - // scoped write: it must not move the global default out from under other + // A login that names its profile (--save-as, or --profile) is a scoped + // write: it must not move the global default out from under other // terminals that rely on it. This also keeps expired-session renewals // (which re-exec login with --profile) from stealing the default. // Untargeted logins keep the familiar "last login wins" behavior. - makeActive := config.INFISICAL_PROFILE_OVERRIDE == "" + makeActive := !target.explicit err = util.PersistLoginProfile(models.Profile{ Name: profileName, @@ -262,6 +352,7 @@ var loginCmd = &cobra.Command{ Domain: config.INFISICAL_URL, OrganizationID: orgID, OrganizationName: orgName, + OrganizationSlug: orgInfo.Slug, SubOrganizationID: subOrgID, }, &userCredentialsToBeStored, makeActive) if err != nil { @@ -298,11 +389,14 @@ 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) - } + orgDetail := "" + if orgName != "" { + orgDetail = fmt.Sprintf(" (org %s)", orgName) + } + switch { + case target.reauth: + util.PrintlnStderr(fmt.Sprintf("Signed back in to profile '%s'%s.", profileName, orgDetail)) + case profileName != userCredentialsToBeStored.Email: util.PrintlnStderr(fmt.Sprintf("Session saved to profile '%s'%s. Select it with --profile %s or INFISICAL_PROFILE=%s.", profileName, orgDetail, profileName, profileName)) } if configAfterLogin, err := util.GetConfigFile(); err == nil && configAfterLogin.ActiveProfile != "" && configAfterLogin.ActiveProfile != profileName { @@ -379,7 +473,7 @@ func cliDefaultLogin(userCredentialsToBeStored *models.UserCredentials, email st if loginTwoResponse.MfaEnabled { i := 1 - for i < 6 { + for i <= mfaMaxAttempts { mfaVerifyCode := askForMFACode("email") httpClient, err := util.GetRestyClientWithCustomHeaders() @@ -397,9 +491,9 @@ func cliDefaultLogin(userCredentialsToBeStored *models.UserCredentials, email st break } else if mfaErrorResponse != nil { if mfaErrorResponse.Context.Code == "mfa_invalid" { - msg := fmt.Sprintf("Incorrect, verification code. You have %v attempts left", 5-i) + msg := fmt.Sprintf("Incorrect, verification code. You have %v attempts left", mfaMaxAttempts-i) util.PrintlnStderr(msg) - if i == 5 { + if i == mfaMaxAttempts { util.PrintErrorMessageAndExit("No tries left, please try again in a bit") break } @@ -466,6 +560,7 @@ func init() { loginCmd.Flags().String("email", "", "email for 'user' login method") loginCmd.Flags().String("password", "", "password for 'user' login method") loginCmd.Flags().String("organization-id", "", "organization id for 'user' login method") + loginCmd.Flags().String("save-as", "", "store this login as the named profile, creating it or replacing its session ('user' login method). Use --profile to sign back in to an existing profile instead.") loginCmd.Flags().MarkDeprecated("oidc-jwt", "use --jwt instead") @@ -749,73 +844,71 @@ func getFreshUserCredentialsWithSrp(email string, password string) (*api.GetLogi func GetJwtTokenWithOrganizationId(oldJwtToken string, email string, organizationId string) string { log.Debug().Msg(fmt.Sprint("GetJwtTokenWithOrganizationId: ", "oldJwtToken", oldJwtToken)) - httpClient, err := util.GetRestyClientWithCustomHeaders() - if err != nil { - util.HandleError(err, "Unable to get resty client with custom headers") - } - httpClient.SetAuthToken(oldJwtToken) - selectedOrganizationId := organizationId if selectedOrganizationId == "" { + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + util.HandleError(err, "Unable to get resty client with custom headers") + } + httpClient.SetAuthToken(oldJwtToken) + selectedOrganizationId, _, err = pickOrganization(httpClient, "Which Infisical organization would you like to log into?", email) if err != nil { util.HandleError(err, "Unable to select organization") } } - selectedOrgRes, err := api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrganizationId}) + // The exchange, MFA prompt included, is shared with [profile set-org] and + // [profile new], so the flows cannot drift apart. + token, err := selectOrganizationToken(oldJwtToken, email, selectedOrganizationId) if err != nil { - util.HandleError(err) + util.HandleError(err, "Unable to select organization") } - if selectedOrgRes.MfaEnabled { - i := 1 - for i < 6 { - mfaVerifyCode := askForMFACode(selectedOrgRes.MfaMethod) + return token +} - httpClient, err := util.GetRestyClientWithCustomHeaders() - if err != nil { - util.HandleError(err, "Unable to get resty client with custom headers") - } - httpClient.SetAuthToken(selectedOrgRes.Token) - verifyMFAresponse, mfaErrorResponse, requestError := api.CallVerifyMfaToken(httpClient, api.VerifyMfaTokenRequest{ - Email: email, - MFAToken: mfaVerifyCode, - MFAMethod: selectedOrgRes.MfaMethod, - }) - if requestError != nil { - util.HandleError(err) - break - } else if mfaErrorResponse != nil { - if mfaErrorResponse.Context.Code == "mfa_invalid" { - msg := fmt.Sprintf("Incorrect, verification code. You have %v attempts left", 5-i) - util.PrintlnStderr(msg) - if i == 5 { - util.PrintErrorMessageAndExit("No tries left, please try again in a bit") - break - } - } +// loginTarget says which profile a login writes to and how it was chosen. +type loginTarget struct { + // name is empty when the profile is derived from the account after login. + name string + // reauth is true when --profile named an existing profile, which is signed + // back in to: same account, same instance, same organization. + reauth bool + profile models.Profile + // explicit is true when the user named the profile with either flag, which + // makes the login a scoped write that leaves the machine default alone. + explicit bool +} - if mfaErrorResponse.Context.Code == "mfa_expired" { - util.PrintErrorMessageAndExit("Your 2FA verification code has expired, please try logging in again") - break - } - i++ - } else { - httpClient.SetAuthToken(verifyMFAresponse.Token) - selectedOrgRes, err = api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrganizationId}) - break - } +// resolveLoginTarget applies the two ways to name a profile on login. --save-as +// stores the login under that name, creating the profile or replacing its +// session. --profile (or INFISICAL_PROFILE) signs back in to a profile that +// already exists; naming a missing one is refused rather than quietly creating +// it, since everywhere else --profile only selects. An ambient INFISICAL_PROFILE +// does not conflict with --save-as, but the two flags naming different +// profiles do. +func resolveLoginTarget(saveAs string, override string, overrideSource string, configFile models.ConfigFile) (loginTarget, error) { + if saveAs != "" { + if err := util.ValidateProfileName(saveAs); err != nil { + return loginTarget{}, err + } + if overrideSource == util.ProfileSourceFlag && override != saveAs { + return loginTarget{}, fmt.Errorf("--profile and --save-as name different profiles ('%s' and '%s'). Use --profile to sign back in to an existing profile, or --save-as to store this login under a name, not both", override, saveAs) } + return loginTarget{name: saveAs, explicit: true}, nil } - if err != nil { - util.HandleError(err, "Unable to select organization") + if override == "" { + return loginTarget{}, nil } - return selectedOrgRes.Token - + profile, found := util.FindProfile(configFile, override) + if !found { + return loginTarget{}, fmt.Errorf("profile '%s' (selected via %s) does not exist. --profile signs back in to an existing profile; to store this login under that name run [infisical login --save-as %s]", override, overrideSource, override) + } + return loginTarget{name: override, reauth: true, profile: profile, explicit: true}, nil } func userLoginMenu(currentLoggedInUserEmail string) (bool, error) { @@ -1033,11 +1126,15 @@ func browserLoginHandler(success chan models.UserCredentials, failure chan error } // check if one of the flag or all the envs are set -func validateDirectUserLoginFlagsAndEnvsSet(cmd *cobra.Command, domain string) (isDirectUserLogin bool, err error) { +// orgIDOptional is set when the login already knows which organization to use, +// which is the case when signing back in to an existing profile. +func validateDirectUserLoginFlagsAndEnvsSet(cmd *cobra.Command, domain string, orgIDOptional bool) (isDirectUserLogin bool, err error) { requiredFlagsEnvs := map[string]string{ - "email": "INFISICAL_EMAIL", - "password": "INFISICAL_PASSWORD", - "organization-id": "INFISICAL_ORGANIZATION_ID", + "email": "INFISICAL_EMAIL", + "password": "INFISICAL_PASSWORD", + } + if !orgIDOptional { + requiredFlagsEnvs["organization-id"] = "INFISICAL_ORGANIZATION_ID" } var missingFlagsEnvs []string diff --git a/packages/cmd/login_status.go b/packages/cmd/login_status.go index 1c269f6a..fc9c2b8b 100644 --- a/packages/cmd/login_status.go +++ b/packages/cmd/login_status.go @@ -261,18 +261,23 @@ type loginStatusJSONOutput struct { } type loginStatusSessionJSON struct { - PrincipalType string `json:"principalType,omitempty"` - Status string `json:"status,omitempty"` - Domain string `json:"domain,omitempty"` - Email string `json:"email,omitempty"` - UserID string `json:"userId,omitempty"` - AuthMethod string `json:"authMethod,omitempty"` - TokenSource string `json:"tokenSource,omitempty"` - Identity *loginStatusIdentityJSON `json:"identity,omitempty"` - Token *loginStatusTokenJSON `json:"token,omitempty"` - Organization *string `json:"organization,omitempty"` - SubOrganization *string `json:"subOrganization,omitempty"` - Verification *loginStatusVerificationJSON `json:"verification,omitempty"` + PrincipalType string `json:"principalType,omitempty"` + Status string `json:"status,omitempty"` + Domain string `json:"domain,omitempty"` + // Profile and ProfileSource describe which login profile the user session + // came from and how it was selected (flag, env var, directory, default). + Profile string `json:"profile,omitempty"` + ProfileSource string `json:"profileSource,omitempty"` + Email string `json:"email,omitempty"` + UserID string `json:"userId,omitempty"` + AuthMethod string `json:"authMethod,omitempty"` + TokenSource string `json:"tokenSource,omitempty"` + Identity *loginStatusIdentityJSON `json:"identity,omitempty"` + Token *loginStatusTokenJSON `json:"token,omitempty"` + Organization *string `json:"organization,omitempty"` + OrganizationName string `json:"organizationName,omitempty"` + SubOrganization *string `json:"subOrganization,omitempty"` + Verification *loginStatusVerificationJSON `json:"verification,omitempty"` } type loginStatusTokenJSON struct { @@ -310,6 +315,9 @@ func buildSessionJSON(ctx loginStatusContext) loginStatusSessionJSON { switch ctx.kind { case principalKindUser: session.Email = ctx.loggedInUser.UserCredentials.Email + session.Profile = ctx.loggedInUser.ProfileName + session.ProfileSource = ctx.loggedInUser.ProfileSource + session.OrganizationName = ctx.loggedInUser.OrganizationName if ctx.claimsErr != nil { return session } @@ -385,6 +393,9 @@ func renderHuman(ctx loginStatusContext) { if ctx.domain != "" { printStatusItem("Domain", ctx.domain) } + if ctx.kind == principalKindUser && ctx.loggedInUser.ProfileName != "" { + printStatusItem("Profile", profileLine(ctx.loggedInUser)) + } if method := authMethodLabel(ctx); method != "" { printStatusItem("Auth method", method) } @@ -403,8 +414,11 @@ func renderHuman(ctx loginStatusContext) { if org := organizationLineFor(ctx); org != "" { printStatusItem("Organization", org) } + if ctx.kind == principalKindUser && ctx.loggedInUser.OrganizationSource != "" && ctx.loggedInUser.OrganizationSource != util.OrgSourceProfileDefault { + printStatusItem("Organization via", ctx.loggedInUser.OrganizationSource) + } if ctx.kind == principalKindUser && ctx.claimsErr == nil && ctx.claims.SubOrganizationID != "" { - printStatusItem("Sub-organization", ctx.claims.SubOrganizationID) + printStatusItem("Sub-organization", subOrganizationLineFor(ctx)) } if status != statusAuthenticated { @@ -471,16 +485,45 @@ func tokenSourceLabel(ctx loginStatusContext) string { return "" } +// profileLine renders the profile a user session came from and how it was +// selected, e.g. "globex (via --profile flag)". +func profileLine(details util.LoggedInUserDetails) string { + if details.ProfileSource == "" { + return details.ProfileName + } + return fmt.Sprintf("%s (via %s)", details.ProfileName, details.ProfileSource) +} + +// userOrgNames splits the organization name the profile recorded into the +// root and sub-organization parts that match the token's organizationId and +// subOrganizationId claims. The token only carries ids; the profile knows the +// names, as "Parent / Child" when the session is scoped to a sub-organization. +func userOrgNames(ctx loginStatusContext) (rootName string, subName string) { + return splitScopedOrgNames(ctx.loggedInUser.OrganizationName, ctx.claims.SubOrganizationID != "") +} + func organizationLineFor(ctx loginStatusContext) string { switch ctx.kind { case principalKindUser: - return orgStatusLine(ctx.claims.OrganizationID, ctx.claimsErr) + line := orgStatusLine(ctx.claims.OrganizationID, ctx.claimsErr) + if rootName, _ := userOrgNames(ctx); ctx.claimsErr == nil && ctx.claims.OrganizationID != "" && rootName != "" { + line = fmt.Sprintf("%s (%s)", rootName, line) + } + return line case principalKindMachineIdentity: return orgStatusLine(ctx.claims.OrgID, ctx.claimsErr) } return "" } +func subOrganizationLineFor(ctx loginStatusContext) string { + line := ctx.claims.SubOrganizationID + if _, subName := userOrgNames(ctx); subName != "" { + line = fmt.Sprintf("%s (%s)", subName, line) + } + return line +} + func tokenStatusLine(claims loginTokenClaims, claimsErr error) string { if claimsErr != nil { return "unknown (could not parse token)" diff --git a/packages/cmd/login_status_test.go b/packages/cmd/login_status_test.go index d5e3dc9d..a2574253 100644 --- a/packages/cmd/login_status_test.go +++ b/packages/cmd/login_status_test.go @@ -468,3 +468,71 @@ func makeUnsignedJWT(t *testing.T, claims map[string]any) string { enc := base64.RawURLEncoding return enc.EncodeToString(headerJSON) + "." + enc.EncodeToString(payloadJSON) + "." } + +func TestBuildSessionJSON_UserIncludesProfile(t *testing.T) { + ctx := loginStatusContext{ + kind: principalKindUser, + loggedInUser: util.LoggedInUserDetails{ + ProfileName: "globex", + ProfileSource: util.ProfileSourceFlag, + OrganizationName: "Globex", + }, + } + + session := buildSessionJSON(ctx) + if session.Profile != "globex" || session.ProfileSource != util.ProfileSourceFlag { + t.Fatalf("profile fields missing from JSON: %+v", session) + } + if session.OrganizationName != "Globex" { + t.Fatalf("organization name missing from JSON: %+v", session) + } + + machine := buildSessionJSON(loginStatusContext{kind: principalKindMachineIdentity}) + if machine.Profile != "" || machine.ProfileSource != "" { + t.Fatalf("machine identity sessions have no profile: %+v", machine) + } +} + +func TestProfileLine(t *testing.T) { + withSource := profileLine(util.LoggedInUserDetails{ProfileName: "globex", ProfileSource: util.ProfileSourceEnv}) + if withSource != "globex (via INFISICAL_PROFILE environment variable)" { + t.Fatalf("unexpected profile line %q", withSource) + } + if got := profileLine(util.LoggedInUserDetails{ProfileName: "globex"}); got != "globex" { + t.Fatalf("expected the bare name without a source, got %q", got) + } +} + +func TestUserOrgNames(t *testing.T) { + root := loginStatusContext{ + kind: principalKindUser, + loggedInUser: util.LoggedInUserDetails{OrganizationName: "Acme"}, + claims: loginTokenClaims{OrganizationID: "root-1"}, + } + if rootName, subName := userOrgNames(root); rootName != "Acme" || subName != "" { + t.Fatalf("root session: got %q, %q", rootName, subName) + } + + sub := loginStatusContext{ + kind: principalKindUser, + loggedInUser: util.LoggedInUserDetails{OrganizationName: "Acme / Research"}, + claims: loginTokenClaims{OrganizationID: "root-1", SubOrganizationID: "sub-1"}, + } + if rootName, subName := userOrgNames(sub); rootName != "Acme" || subName != "Research" { + t.Fatalf("sub-organization session: got %q, %q", rootName, subName) + } + if got := organizationLineFor(sub); got != "Acme (root-1)" { + t.Fatalf("organization line = %q", got) + } + if got := subOrganizationLineFor(sub); got != "Research (sub-1)" { + t.Fatalf("sub-organization line = %q", got) + } + + // A sub-organization session whose recorded name is not split cannot be + // attributed to either id, so only the ids are shown. + unsplit := sub + unsplit.loggedInUser.OrganizationName = "Research" + if got := organizationLineFor(unsplit); got != "root-1" { + t.Fatalf("organization line without a split name = %q", got) + } +} diff --git a/packages/cmd/login_target_test.go b/packages/cmd/login_target_test.go new file mode 100644 index 00000000..f3f3a616 --- /dev/null +++ b/packages/cmd/login_target_test.go @@ -0,0 +1,96 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/Infisical/infisical-merge/packages/models" + "github.com/Infisical/infisical-merge/packages/util" +) + +func TestResolveLoginTarget(t *testing.T) { + configFile := models.ConfigFile{ + Profiles: []models.Profile{ + {Name: "work", Email: "a@x.com", Domain: "https://eu.infisical.com/api", OrganizationID: "org-1"}, + }, + } + + t.Run("no flags derive the name after login and may move the default", func(t *testing.T) { + target, err := resolveLoginTarget("", "", "", configFile) + if err != nil { + t.Fatal(err) + } + if target.name != "" || target.explicit || target.reauth { + t.Fatalf("expected an untargeted login, got %+v", target) + } + }) + + t.Run("--save-as names a new profile without moving the default", func(t *testing.T) { + target, err := resolveLoginTarget("client-b", "", "", configFile) + if err != nil { + t.Fatal(err) + } + if target.name != "client-b" || !target.explicit || target.reauth { + t.Fatalf("unexpected target %+v", target) + } + }) + + t.Run("--save-as an existing profile replaces it rather than signing back in", func(t *testing.T) { + target, err := resolveLoginTarget("work", "", "", configFile) + if err != nil { + t.Fatal(err) + } + if target.name != "work" || target.reauth { + t.Fatalf("unexpected target %+v", target) + } + }) + + t.Run("--save-as validates the name", func(t *testing.T) { + if _, err := resolveLoginTarget("bad name!", "", "", configFile); err == nil { + t.Fatal("expected an invalid name to be rejected") + } + }) + + t.Run("--profile signs back in to an existing profile", func(t *testing.T) { + target, err := resolveLoginTarget("", "work", util.ProfileSourceFlag, configFile) + if err != nil { + t.Fatal(err) + } + if !target.reauth || !target.explicit || target.name != "work" || target.profile.Domain != "https://eu.infisical.com/api" { + t.Fatalf("unexpected target %+v", target) + } + }) + + t.Run("--profile for a missing profile is refused with a --save-as hint", func(t *testing.T) { + _, err := resolveLoginTarget("", "missing", util.ProfileSourceEnv, configFile) + if err == nil || !strings.Contains(err.Error(), "--save-as missing") || !strings.Contains(err.Error(), util.ProfileSourceEnv) { + t.Fatalf("expected a refusal naming the source and the fix, got %v", err) + } + }) + + t.Run("the --profile flag and --save-as naming different profiles conflict", func(t *testing.T) { + if _, err := resolveLoginTarget("other", "work", util.ProfileSourceFlag, configFile); err == nil { + t.Fatal("expected a conflict error") + } + }) + + t.Run("an ambient INFISICAL_PROFILE does not conflict with --save-as", func(t *testing.T) { + target, err := resolveLoginTarget("other", "work", util.ProfileSourceEnv, configFile) + if err != nil { + t.Fatal(err) + } + if target.name != "other" || target.reauth { + t.Fatalf("expected --save-as to win over the pinned profile, got %+v", target) + } + }) + + t.Run("both flags naming the same profile save it", func(t *testing.T) { + target, err := resolveLoginTarget("work", "work", util.ProfileSourceFlag, configFile) + if err != nil { + t.Fatal(err) + } + if target.name != "work" || target.reauth || !target.explicit { + t.Fatalf("unexpected target %+v", target) + } + }) +} diff --git a/packages/cmd/org.go b/packages/cmd/org.go index 9c14d1ab..e0f900fd 100644 --- a/packages/cmd/org.go +++ b/packages/cmd/org.go @@ -10,6 +10,7 @@ import ( "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/util" "github.com/posthog/posthog-go" + "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) @@ -46,8 +47,14 @@ var orgListCmd = &cobra.Command{ } httpClient.SetAuthToken(details.UserCredentials.JTWToken) + // The token's own claims say what it is scoped to. A sub-organization + // session carries the root in organizationId and the sub-organization + // in subOrganizationId, and acts in the latter, so that is the one to + // mark as current. currentOrgID := details.OrganizationID - if claimOrgID, _ := util.ParseTokenOrgClaims(details.UserCredentials.JTWToken); claimOrgID != "" { + if claimOrgID, claimSubOrgID := util.ParseTokenOrgClaims(details.UserCredentials.JTWToken); claimSubOrgID != "" { + currentOrgID = claimSubOrgID + } else if claimOrgID != "" { currentOrgID = claimOrgID } @@ -167,13 +174,23 @@ func runSetOrg(cmd *cobra.Command, args []string) { if orgID == "" { orgID = selectedOrgID } - orgName := util.OrgDisplayName(newSessionToken, orgID, subOrgID) + orgInfo := util.DescribeSessionOrg(newSessionToken, orgID, subOrgID) + orgName := orgInfo.DisplayName() profile := details.Profile profile.OrganizationID = orgID profile.OrganizationName = orgName + profile.OrganizationSlug = orgInfo.Slug profile.SubOrganizationID = subOrgID + // A session cached for this organization by --org is redundant now that it + // is the profile's own, so drop it together with its keyring entry. + if util.RemoveOrgSession(&profile, profile.ScopedOrganizationID()) { + if err := util.DeleteOrgSessionToken(profile.Name, profile.ScopedOrganizationID()); err != nil { + log.Debug().Err(err).Msg("unable to remove the now redundant cached organization session") + } + } + credentials := details.UserCredentials credentials.JTWToken = newSessionToken @@ -228,7 +245,7 @@ func selectOrganizationToken(sessionToken string, email string, orgID string) (s if tokenResponse.MfaEnabled { i := 1 - for i < 6 { + for i <= mfaMaxAttempts { mfaVerifyCode := askForMFACode(tokenResponse.MfaMethod) httpClient, err := util.GetRestyClientWithCustomHeaders() @@ -245,9 +262,9 @@ func selectOrganizationToken(sessionToken string, email string, orgID string) (s 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) + msg := fmt.Sprintf("Incorrect, verification code. You have %v attempts left", mfaMaxAttempts-i) util.PrintlnStderr(msg) - if i == 5 { + if i == mfaMaxAttempts { util.PrintErrorMessageAndExit("No tries left, please try again in a bit") break } diff --git a/packages/cmd/profile.go b/packages/cmd/profile.go index 8fdaa440..6070f223 100644 --- a/packages/cmd/profile.go +++ b/packages/cmd/profile.go @@ -28,8 +28,10 @@ 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]. +Create the first one with [infisical login], one per extra organization with +[infisical profile new], and one per extra account or instance with +[infisical login --save-as ]. Sign back in to an existing profile with +[infisical login --profile ]. Which profile a command uses is decided in this order: 1. --profile on the command @@ -56,15 +58,57 @@ func shellOutputIsCaptured() bool { // 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) { +// not happen. manualHint covers shells that cannot evaluate the statement. +func requireShellCapture(invocation string, manualHint string) { if shellOutputIsCaptured() { return } - util.PrintlnStderr(fmt.Sprintf("This command works by printing a shell statement, so it only takes effect when the shell reads it:\n\n eval \"$(%s)\"\n\nNothing has been changed. Tip: add a shell alias if you use this often.", invocation)) + util.PrintlnStderr(fmt.Sprintf("This command works by printing a shell statement, so it only takes effect when the shell reads it:\n\n eval \"$(%s)\"\n\nNothing has been changed. Tip: add a shell alias if you use this often.\n%s", invocation, manualHint)) os.Exit(1) } +// manualPinHint explains how to select a profile without eval: PowerShell and +// cmd.exe cannot evaluate the printed statement, and in scripts or CI setting +// the variable directly is the reliable choice. +func manualPinHint(profileName string) string { + env := util.INFISICAL_PROFILE_ENV_NAME + return fmt.Sprintf("Without eval, set the variable yourself: in PowerShell run [$env:%s = %s], in cmd.exe [set %s=%s], and in scripts or CI export %s=%s (or pass --profile %s) before running commands.", + env, util.ShellQuote(profileName), env, profileName, env, profileName, profileName) +} + +// manualUnpinHint is the counterpart of manualPinHint for removing a pin. +func manualUnpinHint() string { + env := util.INFISICAL_PROFILE_ENV_NAME + return fmt.Sprintf("Without eval, clear the variable yourself: in PowerShell run [Remove-Item Env:%s], in cmd.exe [set %s=], and in scripts unset %s.", env, env, env) +} + +// splitScopedOrgNames attributes a recorded organization display name to the +// root and sub-organization ids of a session. Sub-organization sessions record +// "Parent / Child"; a name that is not split cannot be attributed to either id +// with confidence, so nothing is returned for it. +func splitScopedOrgNames(displayName string, scopedToSubOrg bool) (rootName string, subName string) { + parent, own := util.SplitOrgDisplayName(displayName) + if !scopedToSubOrg { + return own, "" + } + if parent == "" { + return "", "" + } + return parent, own +} + +// labelWithID renders "Name (id)", falling back to whichever part is known. +func labelWithID(name string, id string) string { + switch { + case name != "" && id != "": + return fmt.Sprintf("%s (%s)", name, id) + case name != "": + return name + default: + return id + } +} + // orgLabel renders a profile's default organization for humans. func orgLabel(profile models.Profile) string { if profile.OrganizationName != "" { @@ -124,7 +168,7 @@ var profileListCmd = &cobra.Command{ } 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) + util.SanitizeDisplay(organization), util.SessionStatus(profile), util.SanitizeDisplay(util.DisplayDomain(profile.Domain)), scopesDisplay) } writer.Flush() @@ -166,7 +210,7 @@ var profileCurrentCmd = &cobra.Command{ } if !found { - util.PrintlnStdout("Status: profile does not exist. Run [infisical login --profile " + resolved.Name + "] to create it.") + util.PrintlnStdout("Status: profile does not exist. Run [infisical login --save-as " + resolved.Name + "] to create it.") return } @@ -174,12 +218,8 @@ var profileCurrentCmd = &cobra.Command{ // 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 - } + rootName, subName := splitScopedOrgNames(profile.OrganizationName, profile.SubOrganizationID != "") + organization := labelWithID(rootName, profile.OrganizationID) orgSelector, orgSource := util.GetOrgOverride() if orgSelector != "" { @@ -193,7 +233,7 @@ var profileCurrentCmd = &cobra.Command{ util.PrintlnStdout("Organization via:", util.OrgSourceProfileDefault) } if profile.SubOrganizationID != "" { - util.PrintlnStdout("Sub-organization id:", profile.SubOrganizationID) + util.PrintlnStdout("Sub-organization:", labelWithID(subName, profile.SubOrganizationID)) } util.PrintlnStdout("Domain:", util.DisplayDomain(profile.Domain)) @@ -216,7 +256,7 @@ 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.`, +[infisical login --save-as ] instead.`, DisableFlagsInUseLine: true, Example: "infisical profile new client-b --org globex\neval \"$(infisical profile new client-b --org globex --pin)\"\ninfisical profile new client-b --org globex --use", Args: cobra.ExactArgs(1), @@ -280,13 +320,11 @@ To add a different account, or an account on another instance, use } orgID, subOrgID := util.ParseTokenOrgClaims(sessionToken) - orgName := util.OrgDisplayName(sessionToken, orgID, subOrgID) + orgInfo := util.DescribeSessionOrg(sessionToken, orgID, subOrgID) + orgName := orgInfo.DisplayName() 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 == "" { @@ -294,13 +332,16 @@ To add a different account, or an account on another instance, use } // Creating a profile does not take over the machine default unless asked, - // since other terminals may be relying on it. + // since other terminals may be relying on it. Organization sessions + // cached by --org belong to the profile they were minted under, so the + // new profile starts without any. err = util.PersistLoginProfile(models.Profile{ Name: profileName, Email: details.UserCredentials.Email, Domain: domain, OrganizationID: orgID, OrganizationName: orgName, + OrganizationSlug: orgInfo.Slug, SubOrganizationID: subOrgID, }, &credentials, useAsDefault) if err != nil { @@ -330,7 +371,7 @@ To add a different account, or an account on another instance, use 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)) + util.PrintWarning(fmt.Sprintf("--pin had no effect because the shell did not read the output. Pin this terminal with [eval \"$(infisical profile pin %s)\"]. %s", profileName, manualPinHint(profileName))) default: util.PrintlnStderr(fmt.Sprintf("Start using it here with [eval \"$(infisical profile pin %s)\"], in a directory with [infisical profile bind %s], or everywhere with [infisical profile use %s].", profileName, profileName, profileName)) } @@ -387,7 +428,16 @@ the shell you are in: 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].`, +several organizations at once. Undo with [infisical profile unpin]. + +The statement is written for POSIX shells such as bash and zsh. PowerShell +cannot evaluate it, so set the variable directly there: + + $env:INFISICAL_PROFILE = 'globex' + +In scripts and CI, set INFISICAL_PROFILE (or pass --profile) instead of calling +pin: with output redirected the statement is only printed, and nothing changes +until a shell evaluates it.`, DisableFlagsInUseLine: true, Example: "eval \"$(infisical profile pin globex)\"", Args: cobra.ExactArgs(1), @@ -404,11 +454,13 @@ several organizations at once. Undo with [infisical profile unpin].`, 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)) + requireShellCapture(fmt.Sprintf("infisical profile pin %s", profileName), manualPinHint(profileName)) - // stdout carries only the export so the output stays eval-safe. + // stdout carries only the export so the output stays eval-safe. The + // output being captured does not prove a shell evaluated it (it could be + // a file or a pipe), so say what happens rather than claiming success. util.PrintlnStdout(fmt.Sprintf("export %s=%s", util.INFISICAL_PROFILE_ENV_NAME, util.ShellQuote(profileName))) - util.PrintlnStderr(fmt.Sprintf("Pinned this terminal to profile '%s' (%s, org %s). Other terminals and the default profile are unaffected.", profileName, profile.Email, orgLabel(profile))) + util.PrintlnStderr(fmt.Sprintf("Profile '%s' (%s, org %s) is pinned in this terminal once the shell evaluates the printed export. Other terminals and the default profile are unaffected.", profileName, profile.Email, orgLabel(profile))) Telemetry.CaptureEvent("cli-command:profile pin", posthog.NewProperties().Set("version", util.CLI_VERSION)) }, @@ -422,15 +474,18 @@ directory or the default profile. Prints an unset statement, so run it through eval: - eval "$(infisical profile unpin)"`, + eval "$(infisical profile unpin)" + +In PowerShell, which cannot evaluate it, run [Remove-Item Env:INFISICAL_PROFILE] +instead.`, DisableFlagsInUseLine: true, Example: "eval \"$(infisical profile unpin)\"", Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { - requireShellCapture("infisical profile unpin") + requireShellCapture("infisical profile unpin", manualUnpinHint()) 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.") + util.PrintlnStderr("This terminal's profile pin is removed once the shell evaluates the printed statement. It then follows a bound directory, or the default profile.") Telemetry.CaptureEvent("cli-command:profile unpin", posthog.NewProperties().Set("version", util.CLI_VERSION)) }, @@ -572,6 +627,67 @@ inside a bound tree undoes that binding.`, }, } +var profileRenameCmd = &cobra.Command{ + Use: "rename [name] [new-name]", + Short: "Rename a profile", + Long: `Rename a profile. + +Everything that refers to the profile follows the new name: its stored session, +the default-profile setting, and directory bindings. Terminals pinned to the old +name with [infisical profile pin] keep pointing at it and need pinning again.`, + DisableFlagsInUseLine: true, + Example: "infisical profile rename scott@example.com--globex globex", + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + oldName, newName := args[0], args[1] + if err := util.ValidateProfileName(newName); err != nil { + util.HandleError(err) + } + + configFile, err := util.GetMigratedConfigFile() + if err != nil { + util.HandleError(err, "Unable to read the Infisical config file") + } + profile, found := util.FindProfile(configFile, oldName) + if !found { + util.PrintErrorMessageAndExit(fmt.Sprintf("Profile '%s' does not exist. Run [infisical profile list] to see available profiles.", oldName)) + } + if _, exists := util.FindProfile(configFile, newName); exists { + util.PrintErrorMessageAndExit(fmt.Sprintf("Profile '%s' already exists. Pick another name.", newName)) + } + + // Keyring entries are keyed by name, so copy them under the new name + // first and remove the old ones only once the config is saved. If + // anything in between fails the old profile is left intact. + renamed := profile + renamed.Name = newName + if err := util.CopyStoredSession(profile, newName); err != nil { + util.HandleError(err, "Unable to store the session under the new name") + } + if err := util.RenameProfile(&configFile, oldName, newName); err != nil { + _ = util.ClearStoredSession(renamed) + util.HandleError(err) + } + if err := util.WriteConfigFile(&configFile); err != nil { + _ = util.ClearStoredSession(renamed) + util.HandleError(err, "Unable to save the Infisical config file") + } + if err := util.ClearStoredSession(profile); err != nil { + util.PrintWarning(fmt.Sprintf("Renamed, but the session stored under the old name could not be removed [err=%s].", err)) + } + + util.PrintlnStderr(fmt.Sprintf("Renamed profile '%s' to '%s'.", oldName, newName)) + if profile.Name == profile.Email { + util.PrintlnStderr("Older CLI versions only recognize profiles named after their email, so they will ask you to log in again.") + } + if os.Getenv(util.INFISICAL_PROFILE_ENV_NAME) == oldName { + util.PrintlnStderr(fmt.Sprintf("This terminal is pinned to the old name. Run [eval \"$(infisical profile pin %s)\"] to pin it again.", newName)) + } + + Telemetry.CaptureEvent("cli-command:profile rename", posthog.NewProperties().Set("version", util.CLI_VERSION)) + }, +} + var profileDeleteCmd = &cobra.Command{ Use: "delete [name]", Short: "Delete a profile and its stored session credentials", @@ -637,6 +753,7 @@ func init() { profileCmd.AddCommand(profileUnpinCmd) profileCmd.AddCommand(profileBindCmd) profileCmd.AddCommand(profileUnbindCmd) + profileCmd.AddCommand(profileRenameCmd) profileCmd.AddCommand(profileDeleteCmd) RootCmd.AddCommand(profileCmd) } diff --git a/packages/cmd/reset.go b/packages/cmd/reset.go index 6d2f5b90..53e33dce 100644 --- a/packages/cmd/reset.go +++ b/packages/cmd/reset.go @@ -48,6 +48,9 @@ var resetCmd = &cobra.Command{ } for _, profile := range configFile.Profiles { keyringKeys[profile.Name] = true + for _, ref := range profile.OrgSessions { + keyringKeys[util.OrgSessionKeyringKey(profile.Name, ref.OrgID)] = true + } } // delete from keyring diff --git a/packages/cmd/root.go b/packages/cmd/root.go index 883e022a..a566160b 100644 --- a/packages/cmd/root.go +++ b/packages/cmd/root.go @@ -143,66 +143,6 @@ func topLevelCommandName(cmd *cobra.Command) string { 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 @@ -265,7 +205,13 @@ func init() { } } - printActiveProfileNotice(cmd, silent) + // The "Using profile ..." notice is printed by the session loader the + // first time a command actually uses a login session, so commands that + // never do (scan, agent, gateway, ...) stay quiet even in a pinned + // terminal. Profile-management commands print their own outcome. + if !silent && !isStructuredOutputRequested(cmd) && !profileNoticeExemptCommands[topLevelCommandName(cmd)] { + util.EnableProfileNotice(cmd.ErrOrStderr()) + } } isTelemetryOn, _ := RootCmd.PersistentFlags().GetBool("telemetry") diff --git a/packages/cmd/user.go b/packages/cmd/user.go index 77868a9e..f5969ed5 100644 --- a/packages/cmd/user.go +++ b/packages/cmd/user.go @@ -238,7 +238,7 @@ cleared and you are asked to sign in again.`, // 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 { + if err := util.ClearStoredSession(selected); err != nil { util.HandleError(err, fmt.Sprintf("Unable to clear the stored session of profile '%s'. It still points at %s, because its existing session must not be sent to %s.", selected.Name, util.DisplayDomain(selected.Domain), util.DisplayDomain(domain))) } @@ -271,34 +271,6 @@ func init() { RootCmd.AddCommand(userCmd) } -// This returns all logged in user emails from the config file. -// If none, it returns the current logged in user in a slice -func getLoggedInUsers() ([]string, error) { - loggedInProfiles := []string{} - - if util.ConfigFileExists() { - configFile, err := util.GetConfigFile() - if err != nil { - return loggedInProfiles, err - } - - //get logged in profiles - // - if len(configFile.LoggedInUsers) > 0 { - for _, v := range configFile.LoggedInUsers { - loggedInProfiles = append(loggedInProfiles, v.Email) - } - } else { - - loggedInProfiles = append(loggedInProfiles, configFile.LoggedInUserEmail) - } - return loggedInProfiles, nil - } else { - //empty - return loggedInProfiles, errors.New("couldn't retrieve config file") - } -} - func NewDomainPrompt() (string, error) { urlValidation := func(input string) error { _, err := url.ParseRequestURI(input) @@ -322,17 +294,3 @@ func NewDomainPrompt() (string, error) { return util.AppendAPIEndpoint(domain), nil } - -func LoggedInUsersPrompt(profiles []string) (string, error) { - prompt := promptui.Select{Label: "Which of your Infisical profiles would you like to use", - Items: profiles, - Size: 7, - } - - idx, _, err := prompt.Run() - if err != nil { - return "", err - } - - return profiles[idx], nil -} diff --git a/packages/models/cli.go b/packages/models/cli.go index 1e57a6d0..7004d513 100644 --- a/packages/models/cli.go +++ b/packages/models/cli.go @@ -7,19 +7,18 @@ 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"` +} + +// OrgSessionRef records an organization-scoped session cached for a profile +// after an --org/INFISICAL_ORG exchange. Only metadata lives here, enough to +// match a later selector by id, slug, or name without listing organizations. +// The token itself is a separate keyring entry (see util.OrgSessionKeyringKey), +// so the profile's main credential entry never grows: platform keyrings cap a +// single entry at a few kilobytes, which two or three extra session tokens +// would exceed. +type OrgSessionRef struct { + OrgID string `json:"orgId"` + // OrgName is the display name, "Parent / Child" for a sub-organization. OrgName string `json:"orgName,omitempty"` OrgSlug string `json:"orgSlug,omitempty"` } @@ -34,10 +33,33 @@ 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"` + // OrganizationID is the root organization of the profile's default + // session (the JWT organizationId claim). When the session is scoped to a + // sub-organization, SubOrganizationID names it and is the organization the + // session actually acts in; use ScopedOrganizationID for that. + OrganizationID string `json:"organizationId,omitempty"` + // OrganizationName is the display name of the scoped organization, + // "Parent / Child" for a sub-organization. + OrganizationName string `json:"organizationName,omitempty"` + // OrganizationSlug is the slug of the scoped organization, so --org by slug + // resolves locally. Empty for profiles written before it was recorded or by + // instances without the sub-organization aware listing. + OrganizationSlug string `json:"organizationSlug,omitempty"` SubOrganizationID string `json:"subOrganizationId,omitempty"` + // OrgSessions indexes the organization-scoped sessions cached for this + // profile by --org/INFISICAL_ORG. See OrgSessionRef. + OrgSessions []OrgSessionRef `json:"orgSessions,omitempty"` +} + +// ScopedOrganizationID returns the organization the profile's session acts in: +// the sub-organization when scoped to one, otherwise the root organization. +// Projects and API permissions belong to this organization, not the root, so +// callers filtering or comparing organizations must use it. +func (p Profile) ScopedOrganizationID() string { + if p.SubOrganizationID != "" { + return p.SubOrganizationID + } + return p.OrganizationID } // The file struct for Infisical config file diff --git a/packages/util/auth.go b/packages/util/auth.go index a92d9319..325952ed 100644 --- a/packages/util/auth.go +++ b/packages/util/auth.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "os/exec" - "strings" infisicalSdk "github.com/infisical/go-sdk" "github.com/rs/zerolog/log" @@ -71,15 +70,7 @@ 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")) - } - } + loginArgs := LoginRenewalArgs(ResolveActiveProfileDetails()) // Spawn infisical login command loginCmd := exec.Command(exePath, loginArgs...) diff --git a/packages/util/credentials.go b/packages/util/credentials.go index 917a943b..913ce34a 100644 --- a/packages/util/credentials.go +++ b/packages/util/credentials.go @@ -80,6 +80,59 @@ func StoreUserCredsInKeyRing(keyName string, userCred *models.UserCredentials) e return err } +// StoreOrgSessionToken caches a session token minted for one organization in +// its own keyring entry. See models.OrgSessionRef for why it is kept apart from +// the profile's main credentials. +func StoreOrgSessionToken(profileName string, orgID string, token string) error { + return SetValueInKeyring(OrgSessionKeyringKey(profileName, orgID), token) +} + +// GetOrgSessionToken loads a cached organization session token. ok is false +// when no entry exists or it cannot be read. +func GetOrgSessionToken(profileName string, orgID string) (token string, ok bool) { + token, err := GetValueInKeyring(OrgSessionKeyringKey(profileName, orgID)) + if err != nil || token == "" { + return "", false + } + return token, true +} + +// DeleteOrgSessionToken removes a cached organization session token. A missing +// entry counts as success. +func DeleteOrgSessionToken(profileName string, orgID string) error { + err := DeleteValueInKeyring(OrgSessionKeyringKey(profileName, orgID)) + if err == nil || IsKeyringEntryAbsent(err) { + return nil + } + return err +} + +// CopyStoredSession stores a copy of a profile's credentials, including its +// cached organization sessions, under another profile name. A profile with no +// stored session copies nothing. Rename uses it, then clears the old entries. +func CopyStoredSession(profile models.Profile, newName string) error { + creds, err := GetUserCredsFromKeyRing(profile.Name) + if err != nil { + if strings.Contains(err.Error(), "credentials not found in system keyring") { + return nil + } + return err + } + if err := StoreUserCredsInKeyRing(newName, &creds); err != nil { + return err + } + for _, ref := range profile.OrgSessions { + token, ok := GetOrgSessionToken(profile.Name, ref.OrgID) + if !ok { + continue + } + if err := StoreOrgSessionToken(newName, ref.OrgID, token); err != nil { + return err + } + } + return nil +} + func GetUserCredsFromKeyRing(keyName string) (credentials models.UserCredentials, err error) { credentialsValue, err := GetValueInKeyring(keyName) if err != nil { @@ -120,7 +173,7 @@ func GetCurrentLoggedInUserDetails(setConfigVariables bool) (LoggedInUserDetails 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) + return LoggedInUserDetails{}, fmt.Errorf("%w: profile '%s' (selected via %s) does not exist. Run [infisical profile list] to see available profiles, or [infisical login --save-as %s] to create it", ErrProfileNotFound, resolved.Name, resolved.Source, resolved.Name) } // Unmigrated legacy state: treat the email as an implicit profile. profile = models.Profile{Name: resolved.Name, Email: resolved.Name, Domain: configFile.LoggedInUserDomain} @@ -164,13 +217,16 @@ func GetCurrentLoggedInUserDetails(setConfigVariables bool) (LoggedInUserDetails 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, + IsUserLoggedIn: true, // was logged in + LoginExpired: !isAuthenticated, + ProfileName: profile.Name, + ProfileSource: resolved.Source, + Profile: profile, + UserCredentials: userCreds, + // A session scoped to a sub-organization acts in that sub-organization, + // which is where its projects and permissions live, so that is the + // organization reported here rather than the root from the token. + OrganizationID: profile.ScopedOrganizationID(), OrganizationName: profile.OrganizationName, OrganizationSource: OrgSourceProfileDefault, } @@ -185,6 +241,13 @@ func GetCurrentLoggedInUserDetails(setConfigVariables bool) (LoggedInUserDetails } } + // This is the point where a command actually uses the session, so it is + // where the "Using profile ..." notice belongs. An expired session is about + // to be renewed, and the renewal loads it again, so stay quiet until then. + if setConfigVariables && !details.LoginExpired { + printProfileNotice(resolved, details) + } + return details, nil } @@ -193,75 +256,74 @@ func GetCurrentLoggedInUserDetails(setConfigVariables bool) (LoggedInUserDetails 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 + // What the profile already knows resolves most selectors with no API call: + // its own organization needs no exchange, and a cached organization + // session only needs its token read back. + var target ResolvedOrg + if known, ok := MatchKnownOrg(profile, selector); ok { + if known.IsProfileDefault { + details.OrganizationSource = selectorSource + return nil } - } - if 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 token, ok := GetOrgSessionToken(profile.Name, known.OrgID); ok && !IsJWTExpired(token) { + details.UserCredentials.JTWToken = token + details.OrganizationID = known.OrgID + details.OrganizationName = known.OrgName + details.OrganizationSource = selectorSource + return nil + } + // The cached token is gone or expired, but the organization is known, + // so skip the listing and go straight to a fresh exchange. + target = ResolvedOrg{ID: known.OrgID, Name: known.OrgName, Slug: known.OrgSlug} + } else { + resolved, err := ResolveOrgSelector(details.UserCredentials.JTWToken, selector) + if err != nil { + return err + } + target = resolved } - if resolvedOrg.ID == profile.OrganizationID { - details.OrganizationName = resolvedOrg.Name + if target.ID == profile.ScopedOrganizationID() { + // The selector named the profile's own organization in a way it could + // not match locally, usually a slug recorded as empty by an older + // build. Remember the slug so the next run needs no listing. + details.OrganizationName = target.Name details.OrganizationSource = selectorSource + if target.Slug != "" && profile.OrganizationSlug == "" { + if err := UpdateStoredProfile(profile.Name, func(p *models.Profile) { p.OrganizationSlug = target.Slug }); err != nil { + log.Debug().Err(err).Msg("unable to record the organization slug on the profile") + } + } return nil } - orgToken, err := ExchangeSessionForOrganization(details.UserCredentials.JTWToken, resolvedOrg.ID) + orgToken, err := ExchangeSessionForOrganization(details.UserCredentials.JTWToken, target.ID) if err != nil { if errors.Is(err, ErrOrgSwitchNeedsMFA) { - return fmt.Errorf("organization '%s' requires MFA, which cannot be completed with %s. Run [infisical profile set-org %s --profile %s] once to verify and cache the session", resolvedOrg.Name, selectorSource, selector, profile.Name) + return fmt.Errorf("organization '%s' requires MFA, which cannot be completed with %s. Run [infisical profile set-org %s --profile %s] once to verify and cache the session", target.Name, selectorSource, selector, profile.Name) } - return fmt.Errorf("unable to scope your session to organization '%s' [err=%s]", resolvedOrg.Name, err) + return fmt.Errorf("unable to scope your session to organization '%s' [err=%s]", target.Name, err) } - 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. + // Cache the token in its own keyring entry and index it on the profile, so + // later runs skip both the listing and the exchange. The profile's own + // session token is left alone: --org retargets a single command, and + // writing the organization token as the primary one would silently repoint + // the profile. Both writes are best effort; this run already holds the + // token in memory. + if err := StoreOrgSessionToken(profile.Name, target.ID, orgToken); err != nil { log.Debug().Err(err).Msg("unable to cache organization-scoped session token") + } else { + ref := models.OrgSessionRef{OrgID: target.ID, OrgName: target.Name, OrgSlug: target.Slug} + if err := UpdateStoredProfile(profile.Name, func(p *models.Profile) { RecordOrgSession(p, ref) }); err != nil { + log.Debug().Err(err).Msg("unable to index the cached organization session on the profile") + } } // Only this invocation runs against the organization-scoped token. details.UserCredentials.JTWToken = orgToken - - details.OrganizationID = resolvedOrg.ID - details.OrganizationName = resolvedOrg.Name + details.OrganizationID = target.ID + details.OrganizationName = target.Name details.OrganizationSource = selectorSource return nil } diff --git a/packages/util/logout.go b/packages/util/logout.go index 48c19701..76d9fde0 100644 --- a/packages/util/logout.go +++ b/packages/util/logout.go @@ -3,6 +3,7 @@ package util import ( "errors" "fmt" + "os" "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/config" @@ -30,50 +31,72 @@ type LogoutResult struct { LocalErr error } +// profileTokens loads every session token stored for a profile: its own and +// the cached organization-scoped ones listed in its index. ok is false when the +// profile has no stored session at all. +func profileTokens(profile models.Profile) (tokens []string, ok bool) { + creds, err := GetUserCredsFromKeyRing(profile.Name) + if err != nil { + return nil, false + } + if creds.JTWToken != "" { + tokens = append(tokens, creds.JTWToken) + } + for _, ref := range profile.OrgSessions { + if token, found := GetOrgSessionToken(profile.Name, ref.OrgID); found { + tokens = append(tokens, token) + } + } + return tokens, true +} + // collectSessionIDs returns every distinct server-side session id represented -// by a profile's stored credentials, including organization-scoped tokens. -func collectSessionIDs(creds models.UserCredentials) []string { +// by the given tokens. +func collectSessionIDs(tokens []string) []string { seen := map[string]bool{} ids := []string{} - - add := func(token string) { + for _, token := range tokens { 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 +// liveToken returns the first token that is still valid. Tokens are ordered +// with the profile's own first; an organization token cached later can outlive +// it, and revocation needs some live token to authenticate with. +func liveToken(tokens []string) string { + for _, token := range tokens { + if token != "" && !IsJWTExpired(token) { + return token } } return "" } -// 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 +// IsKeyringEntryAbsent reports whether a keyring delete or read failed only +// because the entry does not exist. The system keyrings report +// keyring.ErrNotFound; the encrypted file backend surfaces the missing file. +func IsKeyringEntryAbsent(err error) bool { + return errors.Is(err, keyring.ErrNotFound) || errors.Is(err, os.ErrNotExist) +} + +// ClearStoredSession removes a profile's stored credentials, including its +// cached organization sessions. Entries that are already absent count as +// success, since the goal is that nothing remains. +func ClearStoredSession(profile models.Profile) error { + var firstErr error + if err := DeleteValueInKeyring(profile.Name); err != nil && !IsKeyringEntryAbsent(err) { + firstErr = err } - return err + for _, ref := range profile.OrgSessions { + if err := DeleteOrgSessionToken(profile.Name, ref.OrgID); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr } // RevokeSession ends a server-side session by id, authenticating with a token @@ -108,11 +131,11 @@ func LogoutProfiles(configFile models.ConfigFile, targetNames []string, localOnl if targets[profile.Name] { continue } - creds, err := GetUserCredsFromKeyRing(profile.Name) - if err != nil { + tokens, ok := profileTokens(profile) + if !ok { continue } - for _, id := range collectSessionIDs(creds) { + for _, id := range collectSessionIDs(tokens) { retained[id] = profile.Name } } @@ -121,8 +144,13 @@ func LogoutProfiles(configFile models.ConfigFile, targetNames []string, localOnl for _, name := range targetNames { result := LogoutResult{ProfileName: name} - creds, err := GetUserCredsFromKeyRing(name) - if err != nil { + profile, found := FindProfile(configFile, name) + if !found { + profile = models.Profile{Name: name} + } + + tokens, ok := profileTokens(profile) + if !ok { results = append(results, result) continue } @@ -133,8 +161,8 @@ func LogoutProfiles(configFile models.ConfigFile, targetNames []string, localOnl // 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) { + authToken := liveToken(tokens) + for _, sessionID := range collectSessionIDs(tokens) { if owner, shared := retained[sessionID]; shared { result.SharedWith = owner continue @@ -152,7 +180,7 @@ func LogoutProfiles(configFile models.ConfigFile, targetNames []string, localOnl } } - if err := DeleteValueInKeyring(name); err != nil { + if err := ClearStoredSession(profile); err != nil { result.LocalErr = err log.Debug().Err(err).Str("profile", name).Msg("unable to remove stored credentials") } @@ -164,16 +192,16 @@ func LogoutProfiles(configFile models.ConfigFile, targetNames []string, localOnl } // SessionStatus describes whether a profile currently holds usable credentials. -func SessionStatus(profileName string) string { - creds, err := GetUserCredsFromKeyRing(profileName) +func SessionStatus(profile models.Profile) string { + creds, err := GetUserCredsFromKeyRing(profile.Name) 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)) + if cached := len(profile.OrgSessions); cached > 0 { + return fmt.Sprintf("active (+%d org)", cached) } return "active" } diff --git a/packages/util/profile.go b/packages/util/profile.go index 69feabf3..83ae8606 100644 --- a/packages/util/profile.go +++ b/packages/util/profile.go @@ -50,7 +50,16 @@ type ResolvedProfile struct { ShadowedScopeDir string } +// MaxProfileNameLength bounds user-typed profile names. Names double as +// keyring keys, and platform keyrings cap the size of one entry (about 4 KB on +// macOS for key and value together), so an overlong name could make storing +// the session fail after the login itself succeeded. +const MaxProfileNameLength = 64 + func ValidateProfileName(name string) error { + if len(name) > MaxProfileNameLength { + return fmt.Errorf("invalid profile name: %d characters is too long, use at most %d", len(name), MaxProfileNameLength) + } if !profileNamePattern.MatchString(name) { return fmt.Errorf("invalid profile name '%s': use letters, digits, and the characters @ . _ + - (must start with a letter or digit)", name) } @@ -220,9 +229,34 @@ const ( orgMatchID = 3 ) +// orgDisplaySeparator joins a parent organization and a sub-organization in +// display names, as in "Acme / Research". +const orgDisplaySeparator = " / " + +// JoinOrgDisplayName renders a sub-organization as "Parent / Child". With no +// parent the name is returned as-is. +func JoinOrgDisplayName(parent, own string) string { + if parent == "" { + return own + } + return parent + orgDisplaySeparator + own +} + +// SplitOrgDisplayName is the inverse of JoinOrgDisplayName. A plain name comes +// back with an empty parent. +func SplitOrgDisplayName(display string) (parent, own string) { + idx := strings.LastIndex(display, orgDisplaySeparator) + if idx < 0 { + return "", display + } + return display[:idx], display[idx+len(orgDisplaySeparator):] +} + // OrgMatchTier reports how strongly a selector matches an organization, using // the tiers above. Slug and name comparisons are case-insensitive so -// `--org globex` matches an organization named "Globex". +// `--org globex` matches an organization named "Globex". name may be a display +// name of the form "Parent / Child", in which case the sub-organization also +// matches on its own name, so `--org research` finds "Acme / Research". func OrgMatchTier(selector, id, slug, name string) int { if selector == "" { return orgMatchNone @@ -233,8 +267,13 @@ func OrgMatchTier(selector, id, slug, name string) int { if slug != "" && strings.EqualFold(selector, slug) { return orgMatchSlug } - if name != "" && strings.EqualFold(selector, name) { - return orgMatchName + if name != "" { + if strings.EqualFold(selector, name) { + return orgMatchName + } + if _, own := SplitOrgDisplayName(name); own != name && strings.EqualFold(selector, own) { + return orgMatchName + } } return orgMatchNone } @@ -248,12 +287,10 @@ func OrgMatchesSelector(selector, id, slug, name string) bool { // ResolvedOrg is an organization selector resolved against the account. type ResolvedOrg struct { - ID string + ID string + // Name is the display name, "Parent / Child" for a sub-organization. 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) @@ -277,7 +314,7 @@ func ResolveOrgSelector(sessionToken string, selector string) (ResolvedOrg, erro 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}) + candidates = append(candidates, ResolvedOrg{ID: sub.ID, Name: JoinOrgDisplayName(org.Name, sub.Name), Slug: sub.Slug}) } } } @@ -294,11 +331,7 @@ func ResolveOrgSelector(sessionToken string, selector string) (ResolvedOrg, erro bestTier := orgMatchNone ambiguous := false for _, candidate := range candidates { - matchable := candidate.matchName - if matchable == "" { - matchable = candidate.Name - } - tier := OrgMatchTier(selector, candidate.ID, candidate.Slug, matchable) + tier := OrgMatchTier(selector, candidate.ID, candidate.Slug, candidate.Name) switch { case tier > bestTier: best, bestTier, ambiguous = candidate, tier, false @@ -317,6 +350,102 @@ func ResolveOrgSelector(sessionToken string, selector string) (ResolvedOrg, erro return best, nil } +// KnownOrgMatch is an organization the profile already knows about: its own +// default organization or a cached organization session. +type KnownOrgMatch struct { + OrgID string + OrgName string + OrgSlug string + // IsProfileDefault is true when the match is the profile's own + // organization, which needs no exchange at all. + IsProfileDefault bool +} + +// MatchKnownOrg resolves an --org/INFISICAL_ORG selector against what the +// profile already knows, so repeated use of the same organization makes no +// API calls: the profile's default organization and every cached organization +// session are compared by id, slug, and name, and the strongest match wins. A +// tie between two different organizations at the strongest tier is not a +// match, so the server-side listing, which reports the ambiguity, decides. +func MatchKnownOrg(profile models.Profile, selector string) (KnownOrgMatch, bool) { + best := KnownOrgMatch{} + bestTier := orgMatchNone + ambiguous := false + + consider := func(candidate KnownOrgMatch) { + tier := OrgMatchTier(selector, candidate.OrgID, candidate.OrgSlug, candidate.OrgName) + switch { + case tier > bestTier: + best, bestTier, ambiguous = candidate, tier, false + case tier == bestTier && tier != orgMatchNone && candidate.OrgID != best.OrgID: + ambiguous = true + } + } + + if scopedID := profile.ScopedOrganizationID(); scopedID != "" { + consider(KnownOrgMatch{OrgID: scopedID, OrgName: profile.OrganizationName, OrgSlug: profile.OrganizationSlug, IsProfileDefault: true}) + } + for _, ref := range profile.OrgSessions { + consider(KnownOrgMatch{OrgID: ref.OrgID, OrgName: ref.OrgName, OrgSlug: ref.OrgSlug}) + } + + if bestTier == orgMatchNone || ambiguous { + return KnownOrgMatch{}, false + } + return best, true +} + +// OrgSessionKeyringKey is the keyring entry holding the session token cached +// for one organization under a profile. Profile names cannot contain ':' (see +// ValidateProfileName; emails do not either), so the key cannot collide with a +// profile's own entry. +func OrgSessionKeyringKey(profileName string, orgID string) string { + return "org-session:" + profileName + ":" + orgID +} + +// RecordOrgSession adds or refreshes the index entry for a cached organization +// session on the profile, in memory. +func RecordOrgSession(profile *models.Profile, ref models.OrgSessionRef) { + for idx := range profile.OrgSessions { + if profile.OrgSessions[idx].OrgID == ref.OrgID { + profile.OrgSessions[idx] = ref + return + } + } + profile.OrgSessions = append(profile.OrgSessions, ref) +} + +// RemoveOrgSession drops the index entry for an organization, in memory, and +// reports whether one existed. The caller deletes the keyring entry. +func RemoveOrgSession(profile *models.Profile, orgID string) bool { + for idx, ref := range profile.OrgSessions { + if ref.OrgID == orgID { + profile.OrgSessions = append(profile.OrgSessions[:idx], profile.OrgSessions[idx+1:]...) + return true + } + } + return false +} + +// UpdateStoredProfile applies mutate to the named profile in the config file +// and saves it. The file is reloaded first, so only that profile changes and +// edits made by other commands in the meantime are kept. +func UpdateStoredProfile(name string, mutate func(profile *models.Profile)) error { + configFile, err := GetMigratedConfigFile() + if err != nil { + return err + } + idx := findProfileIndex(configFile.Profiles, name) + if idx < 0 { + return fmt.Errorf("profile '%s' does not exist", name) + } + mutate(&configFile.Profiles[idx]) + if configFile.ActiveProfile == name { + syncLegacyLoginFields(&configFile, configFile.Profiles[idx]) + } + return WriteConfigFile(&configFile) +} + // ResolveProfile determines which profile this invocation should use: // --profile flag > INFISICAL_PROFILE env var > directory scope > global default. func ResolveProfile(configFile models.ConfigFile) ResolvedProfile { @@ -508,7 +637,9 @@ func RepointProfileDomain(configFile *models.ConfigFile, profileName string, new configFile.Profiles[idx].Domain = newDomain configFile.Profiles[idx].OrganizationID = "" configFile.Profiles[idx].OrganizationName = "" + configFile.Profiles[idx].OrganizationSlug = "" configFile.Profiles[idx].SubOrganizationID = "" + configFile.Profiles[idx].OrgSessions = nil stillOnPreviousDomain := false for _, profile := range configFile.Profiles { @@ -534,6 +665,35 @@ func RepointProfileDomain(configFile *models.ConfigFile, profileName string, new return true } +// RenameProfile changes a profile's name everywhere the config refers to it: +// the entry itself, the default-profile pointer, and directory bindings. The +// legacy fields are re-synced, since they are only published for profiles +// named after their email. The caller moves the keyring entries, which are +// keyed by name. +func RenameProfile(configFile *models.ConfigFile, oldName string, newName string) error { + if oldName == newName { + return fmt.Errorf("profile is already named '%s'", oldName) + } + idx := findProfileIndex(configFile.Profiles, oldName) + if idx < 0 { + return fmt.Errorf("profile '%s' does not exist", oldName) + } + if findProfileIndex(configFile.Profiles, newName) >= 0 { + return fmt.Errorf("profile '%s' already exists", newName) + } + + configFile.Profiles[idx].Name = newName + for dir, name := range configFile.DirectoryProfiles { + if name == oldName { + configFile.DirectoryProfiles[dir] = newName + } + } + if configFile.ActiveProfile == oldName { + return SetActiveProfile(configFile, newName) + } + return nil +} + // RemoveProfile deletes the profile, any directory bindings pointing at it, // and reconciles the active pointer and legacy fields. The caller is // responsible for deleting the keyring entry. @@ -582,10 +742,18 @@ func RemoveProfile(configFile *models.ConfigFile, name string) bool { // 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 { +// entry for the same account+instance whose organization is still unknown; +// otherwise name the profile after the account and organization, as in +// "scott@example.com--acme-x4k2", so the organization is visible without +// listing and a second organization never overwrites the first. The suffix is +// the organization slug, which is stable and already URL-safe; instances that +// report no slugs fall back to the slugified name, then to a prefix of the id. +// Only with no organization information at all is the bare email used. +// +// Names that are not the bare email leave the legacy loggedInUserEmail pointer +// unset (see syncLegacyLoginFields), so a CLI build that predates profiles +// asks for a fresh login rather than loading another profile's session. +func DeriveProfileName(configFile models.ConfigFile, email string, domain string, orgID string, orgName string, orgSlug string) string { for _, profile := range configFile.Profiles { if profile.Email == email && profile.Domain == domain && profile.OrganizationID == orgID { return profile.Name @@ -597,11 +765,10 @@ func DeriveProfileName(configFile models.ConfigFile, email string, domain string } } - if findProfileIndex(configFile.Profiles, email) < 0 { - return email + suffix := slugifyProfileSuffix(orgSlug) + if suffix == "" { + suffix = slugifyProfileSuffix(orgName) } - - suffix := slugifyProfileSuffix(orgName) if suffix == "" { if len(orgID) >= 8 { suffix = orgID[:8] @@ -609,11 +776,11 @@ func DeriveProfileName(configFile models.ConfigFile, email string, domain string suffix = orgID } } - if suffix == "" { - suffix = "2" - } - base := fmt.Sprintf("%s--%s", email, suffix) + base := email + if suffix != "" { + base = fmt.Sprintf("%s--%s", email, suffix) + } candidate := base for i := 2; findProfileIndex(configFile.Profiles, candidate) >= 0; i++ { candidate = fmt.Sprintf("%s-%d", base, i) @@ -647,8 +814,8 @@ func PersistLoginProfile(profile models.Profile, userCred *models.UserCredential // 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. + // validation applies only where users type a name (--profile, profile new, + // profile rename), at the command layer. if err := StoreUserCredsInKeyRing(profile.Name, userCred); err != nil { return err } @@ -687,6 +854,28 @@ func ResolveActiveProfileDetails() (resolved ResolvedProfile, profile models.Pro return resolved, profile, found } +// LoginRenewalArgs builds the arguments for re-running login to restore the +// session of the profile this invocation resolved to. An existing profile is +// signed back in to with --profile, which keeps its account, instance, and +// organization; a profile that was selected but never created (a pinned +// terminal or bound directory pointing at it) is created with --save-as, so +// the selection starts working. The instance is passed explicitly as well so +// the login never has to ask. +func LoginRenewalArgs(resolved ResolvedProfile, profile models.Profile, found bool) []string { + args := []string{"login", "--silent"} + if resolved.Name == "" { + return args + } + if !found { + return append(args, "--save-as", resolved.Name) + } + args = append(args, "--profile", resolved.Name) + if profile.Domain != "" { + args = append(args, "--domain", DisplayDomain(profile.Domain)) + } + return args +} + type userTokenOrgClaims struct { OrganizationID string `json:"organizationId"` SubOrganizationID string `json:"subOrganizationId"` @@ -718,56 +907,81 @@ func ParseTokenSessionID(token string) string { 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 "" +// OrgInfo describes one organization the account can use. +type OrgInfo struct { + ID string + Name string + Slug string + // ParentName is set for a sub-organization. + ParentName string +} + +// DisplayName renders the organization for humans, "Parent / Child" for a +// sub-organization, so a session inside "Acme / Research" is not reported as +// plain "Acme", which would be indistinguishable from the root organization. +func (o OrgInfo) DisplayName() string { + return JoinOrgDisplayName(o.ParentName, o.Name) +} + +// LookupOrganization finds an organization, root or sub, by id with the given +// session token. Best effort: found is false on any error, so callers can fall +// back to showing the id. The sub-organization aware listing is preferred +// because it carries slugs and nested organizations; instances without it fall +// back to the flat list, which has names only. +func LookupOrganization(sessionToken string, orgID string) (OrgInfo, bool) { + if orgID == "" || sessionToken == "" { + return OrgInfo{}, false } httpClient, err := GetRestyClientWithCustomHeaders() if err != nil { - return "" - } - httpClient.SetAuthToken(jwtToken) - - if orgResp, err := api.CallGetAllOrganizations(httpClient); err == nil { - for _, org := range orgResp.Organizations { - if org.ID == orgID { - return org.Name - } - } + return OrgInfo{}, false } + httpClient.SetAuthToken(sessionToken) - // 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 + return OrgInfo{ID: org.ID, Name: org.Name, Slug: org.Slug}, true } for _, sub := range org.SubOrganizations { if sub.ID == orgID { - return fmt.Sprintf("%s / %s", org.Name, sub.Name) + return OrgInfo{ID: sub.ID, Name: sub.Name, Slug: sub.Slug, ParentName: org.Name}, true } } } } - return "" + if orgResp, err := api.CallGetAllOrganizations(httpClient); err == nil { + for _, org := range orgResp.Organizations { + if org.ID == orgID { + return OrgInfo{ID: org.ID, Name: org.Name}, true + } + } + } + + return OrgInfo{}, false } -// 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 { +// DescribeSessionOrg describes the organization a session acts in, given the +// organizationId and subOrganizationId claims of its token: the +// sub-organization when there is one, otherwise the root. Names are sanitized +// for display. When the lookup fails only the id is set, so callers can still +// show something. +func DescribeSessionOrg(sessionToken string, orgID string, subOrgID string) OrgInfo { + scopedID := orgID if subOrgID != "" { - if name := FetchOrganizationName(sessionToken, subOrgID); name != "" { - return SanitizeDisplay(name) - } + scopedID = subOrgID + } + + info, found := LookupOrganization(sessionToken, scopedID) + if !found { + return OrgInfo{ID: scopedID} } - return SanitizeDisplay(FetchOrganizationName(sessionToken, orgID)) + info.Name = SanitizeDisplay(info.Name) + info.Slug = SanitizeDisplay(info.Slug) + info.ParentName = SanitizeDisplay(info.ParentName) + return info } // DisplayDomain renders a stored domain (which includes the /api suffix) the diff --git a/packages/util/profile_notice.go b/packages/util/profile_notice.go new file mode 100644 index 00000000..ee20c346 --- /dev/null +++ b/packages/util/profile_notice.go @@ -0,0 +1,76 @@ +package util + +import ( + "fmt" + "io" + "sync" +) + +var ( + profileNoticeWriter io.Writer + profileNoticeOnce sync.Once +) + +// EnableProfileNotice turns on the one-time "Using profile ..." notice, written +// to w the first time a command actually loads a login session. The root +// command enables it for ordinary commands; profile-management commands print +// their own outcome, and silent or structured-output runs stay quiet, so they +// leave it off. Commands that never load a session, such as scan or agent, +// therefore never print it even when a profile is pinned. +func EnableProfileNotice(w io.Writer) { + profileNoticeWriter = w +} + +// printProfileNotice reports which profile and organization a session load +// resolved to, once per process. See FormatProfileNotice for when it is quiet. +func printProfileNotice(resolved ResolvedProfile, details LoggedInUserDetails) { + if profileNoticeWriter == nil { + return + } + notice := FormatProfileNotice(resolved, details) + if notice == "" { + return + } + profileNoticeOnce.Do(func() { + fmt.Fprintln(profileNoticeWriter, notice) + }) +} + +// FormatProfileNotice renders the notice for a session load whose selection +// came from somewhere non-obvious: the --profile flag, INFISICAL_PROFILE, a +// directory binding, or an --org/INFISICAL_ORG override. Plain default-profile +// usage yields "", so single-profile setups never see it. +func FormatProfileNotice(resolved ResolvedProfile, details LoggedInUserDetails) string { + overridden := details.OrganizationSource != "" && details.OrganizationSource != OrgSourceProfileDefault + if resolved.Source == ProfileSourceDefault && !overridden { + return "" + } + + orgName := details.OrganizationName + if orgName == "" { + orgName = details.OrganizationID + } + detail := "" + if orgName != "" { + detail = fmt.Sprintf(" (org %s)", orgName) + } + + via := resolved.Source + if resolved.ScopeDir != "" { + via = fmt.Sprintf("%s %s", via, resolved.ScopeDir) + } + if overridden { + if resolved.Source == ProfileSourceDefault { + via = details.OrganizationSource + } else { + via = fmt.Sprintf("%s, org via %s", via, details.OrganizationSource) + } + } + + shadowed := "" + if resolved.ShadowedName != "" { + shadowed = fmt.Sprintf(", overriding this directory's binding to '%s'", resolved.ShadowedName) + } + + return fmt.Sprintf("Using profile '%s'%s via %s%s", SanitizeDisplay(resolved.Name), SanitizeDisplay(detail), via, SanitizeDisplay(shadowed)) +} diff --git a/packages/util/profile_notice_test.go b/packages/util/profile_notice_test.go new file mode 100644 index 00000000..41284b26 --- /dev/null +++ b/packages/util/profile_notice_test.go @@ -0,0 +1,41 @@ +package util + +import "testing" + +func TestFormatProfileNotice(t *testing.T) { + t.Run("the default profile with no override is quiet", func(t *testing.T) { + got := FormatProfileNotice(ResolvedProfile{Name: "a@x.com", Source: ProfileSourceDefault}, LoggedInUserDetails{OrganizationName: "Acme", OrganizationSource: OrgSourceProfileDefault}) + if got != "" { + t.Fatalf("expected no notice, got %q", got) + } + }) + + t.Run("a pinned profile names the source and organization", func(t *testing.T) { + got := FormatProfileNotice(ResolvedProfile{Name: "work", Source: ProfileSourceEnv}, LoggedInUserDetails{OrganizationName: "Acme", OrganizationSource: OrgSourceProfileDefault}) + if got != "Using profile 'work' (org Acme) via INFISICAL_PROFILE environment variable" { + t.Fatalf("unexpected notice %q", got) + } + }) + + t.Run("a directory binding includes the directory", func(t *testing.T) { + got := FormatProfileNotice(ResolvedProfile{Name: "work", Source: ProfileSourceDirectory, ScopeDir: "/repo"}, LoggedInUserDetails{OrganizationID: "org-1", OrganizationSource: OrgSourceProfileDefault}) + if got != "Using profile 'work' (org org-1) via directory scope /repo" { + t.Fatalf("unexpected notice %q", got) + } + }) + + t.Run("an org override on the default profile reports the override", func(t *testing.T) { + got := FormatProfileNotice(ResolvedProfile{Name: "a@x.com", Source: ProfileSourceDefault}, LoggedInUserDetails{OrganizationName: "Globex", OrganizationSource: OrgSourceFlag}) + if got != "Using profile 'a@x.com' (org Globex) via --org flag" { + t.Fatalf("unexpected notice %q", got) + } + }) + + t.Run("an org override on a pinned profile reports both", func(t *testing.T) { + got := FormatProfileNotice(ResolvedProfile{Name: "work", Source: ProfileSourceFlag, ShadowedName: "client-a"}, LoggedInUserDetails{OrganizationName: "Globex", OrganizationSource: OrgSourceEnv}) + want := "Using profile 'work' (org Globex) via --profile flag, org via INFISICAL_ORG environment variable, overriding this directory's binding to 'client-a'" + if got != want { + t.Fatalf("unexpected notice %q", got) + } + }) +} diff --git a/packages/util/profile_test.go b/packages/util/profile_test.go index 7f1c260e..8701fc63 100644 --- a/packages/util/profile_test.go +++ b/packages/util/profile_test.go @@ -2,6 +2,7 @@ package util import ( "path/filepath" + "strings" "testing" "github.com/Infisical/infisical-merge/packages/models" @@ -247,22 +248,36 @@ func TestDeriveProfileName(t *testing.T) { }, } - 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") + t.Run("a new account is named after the account and organization slug", func(t *testing.T) { + name := DeriveProfileName(base, "new@example.com", "https://app.infisical.com/api", "org-9", "Acme", "acme-x4k2") + if name != "new@example.com--acme-x4k2" { + t.Fatalf("expected email--slug, got %q", name) + } + }) + + t.Run("a plus-addressed email is kept verbatim", func(t *testing.T) { + name := DeriveProfileName(base, "ci+tests@example.com", "https://app.infisical.com/api", "org-9", "Acme", "acme-x4k2") + if name != "ci+tests@example.com--acme-x4k2" { + t.Fatalf("expected plus-addressed email verbatim, got %q", name) + } + }) + + t.Run("with no organization information at all the bare email is used", func(t *testing.T) { + name := DeriveProfileName(base, "new@example.com", "https://app.infisical.com/api", "", "", "") if name != "new@example.com" { t.Fatalf("expected bare email, got %q", name) } }) - t.Run("a 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("a bare email that is taken is numbered", func(t *testing.T) { + name := DeriveProfileName(base, "scott@example.com", "https://app.infisical.com/api", "", "", "") + if name != "scott@example.com-2" { + t.Fatalf("expected numbered bare email, got %q", name) } }) t.Run("relogin into the same account, instance, and org reuses the profile", func(t *testing.T) { - name := DeriveProfileName(base, "scott@example.com", "https://app.infisical.com/api", "org-1", "Acme") + 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) } @@ -274,7 +289,7 @@ func TestDeriveProfileName(t *testing.T) { {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") + 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) } @@ -286,21 +301,35 @@ func TestDeriveProfileName(t *testing.T) { {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") + 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") + t.Run("a second organization gets its slug as a suffix instead of overwriting", func(t *testing.T) { + name := DeriveProfileName(base, "scott@example.com", "https://app.infisical.com/api", "org-2", "Beta Corp", "beta-corp-x4k2") + if name != "scott@example.com--beta-corp-x4k2" { + t.Fatalf("expected slug-suffixed name, got %q", name) + } + }) + + t.Run("a slug is normalized to the profile name character set", func(t *testing.T) { + name := DeriveProfileName(base, "scott@example.com", "https://app.infisical.com/api", "org-2", "Beta Corp", "Beta_Corp/EU") + if name != "scott@example.com--beta-corp-eu" { + t.Fatalf("expected a normalized slug suffix, got %q", name) + } + }) + + t.Run("without a slug the organization name is used", func(t *testing.T) { + name := DeriveProfileName(base, "scott@example.com", "https://app.infisical.com/api", "org-2", "Beta Corp", "") if name != "scott@example.com--beta-corp" { - t.Fatalf("expected org-suffixed name, got %q", name) + t.Fatalf("expected name-suffixed fallback, got %q", name) } }) t.Run("falls back to the org id when the org name is unavailable", func(t *testing.T) { - name := DeriveProfileName(base, "scott@example.com", "https://app.infisical.com/api", "1234567890ab", "") + 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) } @@ -313,7 +342,7 @@ func TestDeriveProfileName(t *testing.T) { {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") + 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) } @@ -594,3 +623,256 @@ func TestRepointProfileDomain(t *testing.T) { } }) } + +func TestValidateProfileNameLength(t *testing.T) { + if err := ValidateProfileName(strings.Repeat("a", MaxProfileNameLength)); err != nil { + t.Fatalf("a name at the limit should be valid, got %v", err) + } + if err := ValidateProfileName(strings.Repeat("a", MaxProfileNameLength+1)); err == nil { + t.Fatal("expected an overlong name to be rejected") + } +} + +func TestOrgDisplayNameRoundTrip(t *testing.T) { + if got := JoinOrgDisplayName("Acme", "Research"); got != "Acme / Research" { + t.Fatalf("JoinOrgDisplayName = %q", got) + } + if got := JoinOrgDisplayName("", "Acme"); got != "Acme" { + t.Fatalf("JoinOrgDisplayName without a parent = %q", got) + } + parent, own := SplitOrgDisplayName("Acme / Research") + if parent != "Acme" || own != "Research" { + t.Fatalf("SplitOrgDisplayName = %q, %q", parent, own) + } + parent, own = SplitOrgDisplayName("Acme") + if parent != "" || own != "Acme" { + t.Fatalf("SplitOrgDisplayName of a plain name = %q, %q", parent, own) + } +} + +func TestOrgMatchTierSubOrgOwnName(t *testing.T) { + // A sub-organization displays as "Parent / Child" but is selected by its own name. + if OrgMatchTier("research", "sub-1", "", "Acme / Research") != orgMatchName { + t.Fatal("expected a sub-organization to match on its own name") + } + if OrgMatchTier("Acme / Research", "sub-1", "", "Acme / Research") != orgMatchName { + t.Fatal("expected the full display name to match too") + } + if OrgMatchTier("acme", "sub-1", "", "Acme / Research") != orgMatchNone { + t.Fatal("the parent's name must not select the sub-organization") + } +} + +func TestMatchKnownOrg(t *testing.T) { + profile := models.Profile{ + Name: "work", + OrganizationID: "root-1", + SubOrganizationID: "sub-1", + OrganizationName: "Acme / Research", + OrganizationSlug: "acme-research", + OrgSessions: []models.OrgSessionRef{ + {OrgID: "org-2", OrgName: "Globex", OrgSlug: "globex"}, + {OrgID: "org-3", OrgName: "Initech", OrgSlug: "initech"}, + }, + } + + t.Run("the profile's own organization matches by scoped id, slug, and own name", func(t *testing.T) { + for _, selector := range []string{"sub-1", "acme-research", "research", "Acme / Research"} { + match, ok := MatchKnownOrg(profile, selector) + if !ok || !match.IsProfileDefault || match.OrgID != "sub-1" { + t.Fatalf("selector %q: got %+v, %v", selector, match, ok) + } + } + }) + + t.Run("the root of a sub-organization session is not the profile's own organization", func(t *testing.T) { + if _, ok := MatchKnownOrg(profile, "root-1"); ok { + t.Fatal("the root id must not short-circuit while the session is scoped to a sub-organization") + } + }) + + t.Run("a cached session matches by id, slug, and name", func(t *testing.T) { + for _, selector := range []string{"org-2", "globex", "GLOBEX"} { + match, ok := MatchKnownOrg(profile, selector) + if !ok || match.IsProfileDefault || match.OrgID != "org-2" { + t.Fatalf("selector %q: got %+v, %v", selector, match, ok) + } + } + }) + + t.Run("an unknown selector is left to the server", func(t *testing.T) { + if _, ok := MatchKnownOrg(profile, "umbrella"); ok { + t.Fatal("expected no local match") + } + }) + + t.Run("a name shared by two known organizations is left to the server", func(t *testing.T) { + ambiguous := models.Profile{ + Name: "work", + OrganizationID: "root-1", + OrgSessions: []models.OrgSessionRef{ + {OrgID: "org-2", OrgName: "Globex", OrgSlug: "globex-us"}, + {OrgID: "org-4", OrgName: "Globex", OrgSlug: "globex-eu"}, + }, + } + if _, ok := MatchKnownOrg(ambiguous, "globex"); ok { + t.Fatal("expected the ambiguous name to be left unresolved") + } + match, ok := MatchKnownOrg(ambiguous, "globex-eu") + if !ok || match.OrgID != "org-4" { + t.Fatalf("expected the slug to disambiguate, got %+v, %v", match, ok) + } + }) + + t.Run("a stronger match wins regardless of order", func(t *testing.T) { + // An organization named after another one's id must not shadow it. + tricky := models.Profile{ + Name: "work", + OrgSessions: []models.OrgSessionRef{ + {OrgID: "attacker", OrgName: "org-9"}, + {OrgID: "org-9", OrgName: "Nine"}, + }, + } + match, ok := MatchKnownOrg(tricky, "org-9") + if !ok || match.OrgID != "org-9" { + t.Fatalf("expected the id match to win, got %+v, %v", match, ok) + } + }) +} + +func TestOrgSessionIndex(t *testing.T) { + profile := models.Profile{Name: "work"} + RecordOrgSession(&profile, models.OrgSessionRef{OrgID: "org-2", OrgName: "Globex"}) + RecordOrgSession(&profile, models.OrgSessionRef{OrgID: "org-3", OrgName: "Initech"}) + RecordOrgSession(&profile, models.OrgSessionRef{OrgID: "org-2", OrgName: "Globex Renamed", OrgSlug: "globex"}) + if len(profile.OrgSessions) != 2 { + t.Fatalf("expected re-recording an organization to replace its entry, got %+v", profile.OrgSessions) + } + if profile.OrgSessions[0].OrgName != "Globex Renamed" || profile.OrgSessions[0].OrgSlug != "globex" { + t.Fatalf("expected the entry to be refreshed, got %+v", profile.OrgSessions[0]) + } + if !RemoveOrgSession(&profile, "org-2") || len(profile.OrgSessions) != 1 || profile.OrgSessions[0].OrgID != "org-3" { + t.Fatalf("expected org-2 to be removed, got %+v", profile.OrgSessions) + } + if RemoveOrgSession(&profile, "missing") { + t.Fatal("removing an unknown organization must report false") + } + if got := OrgSessionKeyringKey("work", "org-3"); got != "org-session:work:org-3" { + t.Fatalf("unexpected keyring key %q", got) + } +} + +func TestScopedOrganizationID(t *testing.T) { + root := models.Profile{OrganizationID: "root-1"} + if root.ScopedOrganizationID() != "root-1" { + t.Fatal("a root session acts in the root organization") + } + sub := models.Profile{OrganizationID: "root-1", SubOrganizationID: "sub-1"} + if sub.ScopedOrganizationID() != "sub-1" { + t.Fatal("a sub-organization session acts in the sub-organization") + } +} + +func TestRenameProfile(t *testing.T) { + scopedDir := filepath.Join("/", "home", "scott", "work") + base := func() models.ConfigFile { + return models.ConfigFile{ + ActiveProfile: "scott@example.com", + LoggedInUserEmail: "scott@example.com", + LoggedInUserDomain: "https://app.infisical.com/api", + Profiles: []models.Profile{ + {Name: "scott@example.com", Email: "scott@example.com", Domain: "https://app.infisical.com/api"}, + {Name: "globex", Email: "scott@example.com", Domain: "https://app.infisical.com/api"}, + }, + DirectoryProfiles: map[string]string{scopedDir: "globex"}, + } + } + + t.Run("directory bindings follow the new name", func(t *testing.T) { + configFile := base() + if err := RenameProfile(&configFile, "globex", "globex-work"); err != nil { + t.Fatal(err) + } + if _, found := FindProfile(configFile, "globex"); found { + t.Fatal("old name still present") + } + if _, found := FindProfile(configFile, "globex-work"); !found { + t.Fatal("new name missing") + } + if configFile.DirectoryProfiles[scopedDir] != "globex-work" { + t.Fatalf("directory binding did not follow: %+v", configFile.DirectoryProfiles) + } + if configFile.ActiveProfile != "scott@example.com" { + t.Fatalf("default profile changed unexpectedly: %q", configFile.ActiveProfile) + } + }) + + t.Run("renaming the default profile moves the pointer and re-syncs the legacy fields", func(t *testing.T) { + configFile := base() + if err := RenameProfile(&configFile, "scott@example.com", "personal"); err != nil { + t.Fatal(err) + } + if configFile.ActiveProfile != "personal" { + t.Fatalf("expected the default to follow, got %q", configFile.ActiveProfile) + } + // The legacy pointer is only published for email-named profiles, since + // older binaries load the keyring entry it names. + if configFile.LoggedInUserEmail != "" { + t.Fatalf("expected the legacy pointer to be cleared, got %q", configFile.LoggedInUserEmail) + } + }) + + t.Run("rejects unknown, duplicate, and unchanged names", func(t *testing.T) { + configFile := base() + if err := RenameProfile(&configFile, "missing", "x"); err == nil { + t.Fatal("expected an error for an unknown profile") + } + if err := RenameProfile(&configFile, "globex", "scott@example.com"); err == nil { + t.Fatal("expected an error for a name already in use") + } + if err := RenameProfile(&configFile, "globex", "globex"); err == nil { + t.Fatal("expected an error for an unchanged name") + } + }) +} + +func TestRepointProfileDomainClearsOrgCache(t *testing.T) { + configFile := models.ConfigFile{ + Profiles: []models.Profile{{ + Name: "work", + Email: "scott@example.com", + Domain: "https://app.infisical.com/api", + OrganizationID: "org-a", + OrganizationSlug: "acme", + OrgSessions: []models.OrgSessionRef{{OrgID: "org-b", OrgName: "Globex"}}, + }}, + } + RepointProfileDomain(&configFile, "work", "https://self.example.com/api") + moved := configFile.Profiles[0] + if moved.OrganizationSlug != "" || len(moved.OrgSessions) != 0 { + t.Fatalf("expected the organization slug and cached sessions to be dropped, got %+v", moved) + } +} + +func TestLoginRenewalArgs(t *testing.T) { + t.Run("nothing resolved renews the default login", func(t *testing.T) { + got := LoginRenewalArgs(ResolvedProfile{}, models.Profile{}, false) + if strings.Join(got, " ") != "login --silent" { + t.Fatalf("unexpected args %v", got) + } + }) + + t.Run("an existing profile is signed back in to on its instance", func(t *testing.T) { + got := LoginRenewalArgs(ResolvedProfile{Name: "work"}, models.Profile{Name: "work", Domain: "https://eu.infisical.com/api"}, true) + if strings.Join(got, " ") != "login --silent --profile work --domain https://eu.infisical.com" { + t.Fatalf("unexpected args %v", got) + } + }) + + t.Run("a selected but missing profile is created", func(t *testing.T) { + got := LoginRenewalArgs(ResolvedProfile{Name: "client-b", Source: ProfileSourceDirectory}, models.Profile{}, false) + if strings.Join(got, " ") != "login --silent --save-as client-b" { + t.Fatalf("unexpected args %v", got) + } + }) +}