From 5f8d6b62b51d50f43f45ebc11d1803b249e1618e Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Thu, 20 Aug 2026 13:12:27 -0700 Subject: [PATCH 1/4] Add named login profiles and directory-scoped tenant selection Switching tenants previously required a full re-login because the CLI held exactly one session per email (keyring key = email) and the session JWT is scoped to a single organization. Working across tenants in parallel meant exporting tokens into .env files. Sessions are now stored as named profiles (account + instance + org), each with its own keyring entry. Selection precedence per invocation: --profile flag > INFISICAL_PROFILE env var > directory scope > global default, so parallel terminals can pin different tenants and directories can be bound to the tenant they belong to. New commands: - infisical profile list/current/use/unlink/delete (profile use --scope binds a directory tree to a profile) - infisical org list / org switch (re-scopes the session via select-organization without re-authenticating; --save-as stores the result as a new profile) Also: - infisical init persists the org re-scope on the resolved profile and offers a directory binding when multiple profiles exist - expired sessions now renew via the stored refresh token when present (previously dead code), falling back to interactive login - an explicit --domain/INFISICAL_DOMAIN now beats the saved login domain instead of being silently overridden - infisical reset removes all profile keyring entries instead of only the active one - infisical user switch operates on profiles (behavior preserved) Migration is lazy and transparent: legacy config fields become profiles named after the account email, which is also the legacy keyring key, so existing sessions keep working without re-login. Legacy fields stay synced with the active profile for older binaries and scripts. Co-Authored-By: Claude Fable 5 --- packages/cmd/init.go | 104 ++++---- packages/cmd/login.go | 50 +++- packages/cmd/org.go | 241 +++++++++++++++++ packages/cmd/profile.go | 284 ++++++++++++++++++++ packages/cmd/reset.go | 20 +- packages/cmd/root.go | 72 ++++++ packages/cmd/user.go | 71 +++-- packages/cmd/vault.go | 7 + packages/config/config.go | 11 + packages/models/cli.go | 25 ++ packages/util/auth.go | 13 +- packages/util/config.go | 62 ----- packages/util/constants.go | 4 + packages/util/credentials.go | 173 +++++++++---- packages/util/helper.go | 8 +- packages/util/profile.go | 473 ++++++++++++++++++++++++++++++++++ packages/util/profile_test.go | 345 +++++++++++++++++++++++++ 17 files changed, 1757 insertions(+), 206 deletions(-) create mode 100644 packages/cmd/org.go create mode 100644 packages/cmd/profile.go create mode 100644 packages/util/profile.go create mode 100644 packages/util/profile_test.go diff --git a/packages/cmd/init.go b/packages/cmd/init.go index 37eaeae4..b5f147bc 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" @@ -62,56 +63,30 @@ var initCmd = &cobra.Command{ util.HandleError(err, "Unable to select organization") } - tokenResponse, err := api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrgID}) - if tokenResponse.MfaEnabled { - i := 1 - for i < 6 { - mfaVerifyCode := askForMFACode(tokenResponse.MfaMethod) - - 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, selectedOrgID) if err != nil { util.HandleError(err, "Unable to select organization") } - // set the config jwt token to the new token - userCreds.UserCredentials.JTWToken = tokenResponse.Token - err = util.StoreUserCredsInKeyRing(&userCreds.UserCredentials) - httpClient.SetAuthToken(tokenResponse.Token) + // The session token is now scoped to the selected organization; persist + // it on the profile this invocation resolved to. + userCreds.UserCredentials.JTWToken = newSessionToken + orgID, subOrgID := util.ParseTokenOrgClaims(newSessionToken) + if orgID == "" { + orgID = selectedOrgID + } + + updatedProfile := userCreds.Profile + updatedProfile.OrganizationID = orgID + updatedProfile.SubOrganizationID = subOrgID + updatedProfile.OrganizationName = util.FetchOrganizationName(newSessionToken, orgID) + + // 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") @@ -140,11 +115,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("Always use profile '%s' in this directory? Commands run here will 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 use --scope] and [infisical profile unlink].", cwd, profileName)) +} + func init() { RootCmd.AddCommand(initCmd) } diff --git a/packages/cmd/login.go b/packages/cmd/login.go index 873efce5..d200ba5b 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) || 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,33 @@ var loginCmd = &cobra.Command{ cliDefaultLogin(&userCredentialsToBeStored, email, password, organizationId) } - err = util.StoreUserCredsInKeyRing(&userCredentialsToBeStored) + orgID, subOrgID := util.ParseTokenOrgClaims(userCredentialsToBeStored.JTWToken) + orgName := util.FetchOrganizationName(userCredentialsToBeStored.JTWToken, orgID) + + 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)) + } + + err = util.PersistLoginProfile(models.Profile{ + Name: profileName, + Email: userCredentialsToBeStored.Email, + Domain: config.INFISICAL_URL, + OrganizationID: orgID, + OrganizationName: orgName, + SubOrganizationID: subOrgID, + }, &userCredentialsToBeStored, true) 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 +265,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 +291,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) + } + util.PrintlnStderr(fmt.Sprintf("Session saved to profile '%s'%s. Select it with --profile %s or INFISICAL_PROFILE=%s.", profileName, orgDetail, profileName, profileName)) + } + plainBold := color.New(color.Bold) plainBold.Println("\nQuick links") diff --git a/packages/cmd/org.go b/packages/cmd/org.go new file mode 100644 index 00000000..6d92e83b --- /dev/null +++ b/packages/cmd/org.go @@ -0,0 +1,241 @@ +/* +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: "Manage which organization your login session is scoped to", + DisableFlagsInUseLine: true, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +var orgListCmd = &cobra.Command{ + Use: "list", + Short: "List the organizations your account belongs to", + 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) + + orgResp, err := api.CallGetAllOrganizations(httpClient) + if err != nil { + util.HandleError(err, "Unable to list your organizations") + } + + currentOrgID, _ := util.ParseTokenOrgClaims(details.UserCredentials.JTWToken) + + writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(writer, "CURRENT\tNAME\tID") + for _, org := range orgResp.Organizations { + marker := "" + if org.ID == currentOrgID { + marker = "*" + } + fmt.Fprintf(writer, "%s\t%s\t%s\n", marker, org.Name, org.ID) + } + writer.Flush() + + Telemetry.CaptureEvent("cli-command:org list", posthog.NewProperties().Set("numberOfOrganizations", len(orgResp.Organizations)).Set("version", util.CLI_VERSION)) + }, +} + +var orgSwitchCmd = &cobra.Command{ + Use: "switch", + Short: "Scope your session to another organization without re-authenticating", + DisableFlagsInUseLine: true, + Example: "infisical org switch\ninfisical org switch --org-id --save-as client-b", + Args: cobra.NoArgs, + PreRun: func(cmd *cobra.Command, args []string) { + util.RequireLogin() + }, + Run: func(cmd *cobra.Command, args []string) { + orgIDFlag, err := cmd.Flags().GetString("org-id") + if err != nil { + util.HandleError(err) + } + saveAs, err := cmd.Flags().GetString("save-as") + if err != nil { + util.HandleError(err) + } + if saveAs != "" { + if err := util.ValidateProfileName(saveAs); err != nil { + util.HandleError(err) + } + } + + details := requireUserSession() + + selectedOrgID := orgIDFlag + if selectedOrgID == "" { + 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, "Which organization would you like to switch to?", 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 switch organization") + } + + orgID, subOrgID := util.ParseTokenOrgClaims(newSessionToken) + if orgID == "" { + orgID = selectedOrgID + } + orgName := util.FetchOrganizationName(newSessionToken, orgID) + + profile := details.Profile + if saveAs != "" { + configFile, err := util.GetMigratedConfigFile() + if err != nil { + util.HandleError(err, "Unable to read the Infisical config file") + } + if existingProfile, found := util.FindProfile(configFile, saveAs); found && existingProfile.Email != details.UserCredentials.Email { + util.PrintWarning(fmt.Sprintf("Profile '%s' previously stored the session for %s and now stores the session for %s.", saveAs, existingProfile.Email, details.UserCredentials.Email)) + } + profile.Name = saveAs + } + 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 + } + + if saveAs != "" && saveAs != details.ProfileName { + util.PrintlnStderr(fmt.Sprintf("Created profile '%s' scoped to organization %s. Profile '%s' is unchanged.", profile.Name, orgDisplay, details.ProfileName)) + } else { + util.PrintlnStderr(fmt.Sprintf("Profile '%s' is now scoped to organization %s.", profile.Name, 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("savedAsNewProfile", saveAs != "").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() { + orgSwitchCmd.Flags().String("org-id", "", "the id of the organization to switch to (skips the picker)") + orgSwitchCmd.Flags().String("save-as", "", "store the switched session as a new profile instead of re-scoping the current one") + + orgCmd.AddCommand(orgListCmd) + orgCmd.AddCommand(orgSwitchCmd) + RootCmd.AddCommand(orgCmd) +} diff --git a/packages/cmd/profile.go b/packages/cmd/profile.go new file mode 100644 index 00000000..1ccbd0e6 --- /dev/null +++ b/packages/cmd/profile.go @@ -0,0 +1,284 @@ +/* +Copyright (c) 2023 Infisical Inc. +*/ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "text/tabwriter" + + "github.com/Infisical/infisical-merge/packages/util" + "github.com/posthog/posthog-go" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +var profileCmd = &cobra.Command{ + Use: "profile", + Short: "Manage login profiles for working across organizations and instances", + DisableFlagsInUseLine: true, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +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\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\n", marker, profile.Name, profile.Email, organization, 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 !found { + util.PrintlnStdout("Status: profile does not exist. Run [infisical login --profile " + resolved.Name + "] to create it.") + return + } + + util.PrintlnStdout("Email:", profile.Email) + organization := profile.OrganizationName + if organization != "" && profile.OrganizationID != "" { + organization = fmt.Sprintf("%s (%s)", organization, profile.OrganizationID) + } else if organization == "" { + organization = profile.OrganizationID + } + if organization != "" { + util.PrintlnStdout("Organization:", organization) + } + if profile.SubOrganizationID != "" { + util.PrintlnStdout("Sub-organization:", profile.SubOrganizationID) + } + util.PrintlnStdout("Domain:", util.DisplayDomain(profile.Domain)) + + Telemetry.CaptureEvent("cli-command:profile current", posthog.NewProperties().Set("version", util.CLI_VERSION)) + }, +} + +var profileUseCmd = &cobra.Command{ + Use: "use [name]", + Short: "Set the default profile, or bind a directory to a profile with --scope", + DisableFlagsInUseLine: true, + Example: "infisical profile use work-eu\ninfisical profile use client-a --scope .", + 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)) + } + + scope, err := cmd.Flags().GetString("scope") + if err != nil { + util.HandleError(err) + } + + if scope == "" { + 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, %s)", profileName, profile.Email, util.DisplayDomain(profile.Domain))) + } else { + scopeDir, err := filepath.Abs(scope) + if err != nil { + util.HandleError(err, "Unable to resolve the scope directory") + } + dirInfo, err := os.Stat(scopeDir) + if err != nil || !dirInfo.IsDir() { + util.PrintErrorMessageAndExit(fmt.Sprintf("The scope path %s is not an existing directory", scopeDir)) + } + + util.SetDirectoryProfile(&configFile, scopeDir, profileName) + if err := util.WriteConfigFile(&configFile); err != nil { + util.HandleError(err, "Unable to save the Infisical config file") + } + util.PrintlnStderr(fmt.Sprintf("Directory %s (and its subdirectories) now uses profile '%s'. Remove with [infisical profile unlink].", scopeDir, profileName)) + } + + Telemetry.CaptureEvent("cli-command:profile use", posthog.NewProperties().Set("scoped", scope != "").Set("version", util.CLI_VERSION)) + }, +} + +var profileUnlinkCmd = &cobra.Command{ + Use: "unlink [path]", + Short: "Remove a directory-to-profile binding (defaults to the one covering the current directory)", + DisableFlagsInUseLine: true, + Example: "infisical profile unlink", + 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 unlink", 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)) + } + + // Best effort: the keyring entry may already be gone (e.g. after a vault + // backend switch). + if err := util.DeleteValueInKeyring(profileName); err != nil { + log.Debug().Err(err).Msg("unable to delete keyring entry for profile") + } + + 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)") + profileUseCmd.Flags().String("scope", "", "bind this directory (and its subdirectories) to the profile instead of changing the global default") + + profileCmd.AddCommand(profileListCmd) + profileCmd.AddCommand(profileCurrentCmd) + profileCmd.AddCommand(profileUseCmd) + profileCmd.AddCommand(profileUnlinkCmd) + profileCmd.AddCommand(profileDeleteCmd) + RootCmd.AddCommand(profileCmd) +} diff --git a/packages/cmd/reset.go b/packages/cmd/reset.go index 2af92583..5fbcba3c 100644 --- a/packages/cmd/reset.go +++ b/packages/cmd/reset.go @@ -18,11 +18,27 @@ var resetCmd = &cobra.Command{ Example: "infisical reset", Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { - // delete keyring item of current logged in user + // delete the keyring entries of all stored login sessions configFile, _ := util.GetConfigFile() + util.MigrateConfigProfiles(&configFile) + + 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() diff --git a/packages/cmd/root.go b/packages/cmd/root.go index fa103824..2932dee1 100644 --- a/packages/cmd/root.go +++ b/packages/cmd/root.go @@ -123,6 +123,61 @@ 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 + } + + resolved, profile, _ := util.ResolveActiveProfileDetails() + if resolved.Name == "" || resolved.Source == util.ProfileSourceDefault { + 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 + } + + detail := "" + if profile.OrganizationName != "" { + detail = fmt.Sprintf(" (org %s)", profile.OrganizationName) + } else if profile.OrganizationID != "" { + detail = fmt.Sprintf(" (org %s)", profile.OrganizationID) + } + + via := resolved.Source + if resolved.ScopeDir != "" { + via = fmt.Sprintf("%s %s", via, resolved.ScopeDir) + } + + fmt.Fprintf(cmd.ErrOrStderr(), "Using profile '%s'%s via %s\n", resolved.Name, detail, via) +} + func init() { util.GetStderrWriter = RootCmdStderrWriter util.GetStdoutWriter = RootCmdStdoutWriter @@ -133,12 +188,28 @@ 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.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 + } + + _, 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 +227,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..afdfd0e7 100644 --- a/packages/cmd/user.go +++ b/packages/cmd/user.go @@ -30,54 +30,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 +75,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)) }, } @@ -221,7 +213,7 @@ var domainCmd = &cobra.Command{ } //write to config file - configFile, err := util.GetConfigFile() + configFile, err := util.GetMigratedConfigFile() if err != nil { util.HandleError(err, "[infisical user update domain]: Unable to get config file") } @@ -252,6 +244,13 @@ var domainCmd = &cobra.Command{ configFile.LoggedInUserDomain = domain } + // keep profile entries for this account in sync with the new domain + for idx := range configFile.Profiles { + if configFile.Profiles[idx].Email == profile { + configFile.Profiles[idx].Domain = domain + } + } + err = util.WriteConfigFile(&configFile) if err != nil { util.HandleError(err, "") 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..8890bf3b 100644 --- a/packages/config/config.go +++ b/packages/config/config.go @@ -3,3 +3,14 @@ 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_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..7bdfcab1 100644 --- a/packages/models/cli.go +++ b/packages/models/cli.go @@ -9,8 +9,25 @@ type UserCredentials struct { RefreshToken string `json:"RefreshToken"` } +// Profile is a named login session: one account on one instance, scoped to one +// organization (the session JWT is org-scoped). 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 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 +41,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/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..c2916ff2 100644 --- a/packages/util/constants.go +++ b/packages/util/constants.go @@ -53,6 +53,10 @@ 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" + // 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..8e8e830b 100644 --- a/packages/util/credentials.go +++ b/packages/util/credentials.go @@ -5,29 +5,51 @@ 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 } 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 { +// 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") + +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 { userCredMarshalled, err := json.Marshal(userCred) 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 +57,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 +80,118 @@ 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 { + // The user explicitly picked a domain (flag or env var): honor it, + // since it may be a proxy or tunnel for the same instance. Surface + // the mismatch once so a wrong-instance 401 is explainable. + if profileURL != config.INFISICAL_URL { + domainMismatchNoticeOnce.Do(func() { + PrintWarning(fmt.Sprintf("The explicitly set domain '%s' differs from profile '%s' domain '%s'. Requests will go to the explicitly set domain.", DisplayDomain(config.INFISICAL_URL), profile.Name, DisplayDomain(profileURL))) + }) + } } 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) + isAuthenticated := !IsJWTExpired(userCreds.JTWToken) + + // The session expired: try the stored refresh token before falling back to + // an interactive re-login. Only on setConfigVariables paths, so read-only + // probes (e.g. the root pre-run warning) never mutate the keyring, and so + // the refresh request targets the profile's own domain (set above). + if !isAuthenticated && setConfigVariables && userCreds.RefreshToken != "" { + if refreshedCreds, ok := tryRefreshSession(userCreds, profile); ok { + userCreds = refreshedCreds + isAuthenticated = true + if err := StoreUserCredsInKeyRing(profile.Name, &userCreds); err != nil { + log.Debug().Err(err).Msg("unable to persist refreshed session token") } } + } + + return LoggedInUserDetails{ + IsUserLoggedIn: true, // was logged in + LoginExpired: !isAuthenticated, + ProfileName: profile.Name, + ProfileSource: resolved.Source, + Profile: profile, + UserCredentials: userCreds, + }, nil +} + +// tryRefreshSession exchanges the stored refresh token for a fresh access +// token, re-scoping it to the profile's organization when the exchange returns +// an unscoped token. Returns ok=false on any failure (including an org that +// now requires MFA) so the caller falls back to the interactive login flow. +func tryRefreshSession(userCreds models.UserCredentials, profile models.Profile) (models.UserCredentials, bool) { + httpClient, err := GetRestyClientWithCustomHeaders() + if err != nil { + return userCreds, false + } + + accessTokenResponse, err := api.CallGetNewAccessTokenWithRefreshToken(httpClient, userCreds.RefreshToken) + if err != nil || accessTokenResponse.Token == "" { + log.Debug().Err(err).Msg("session refresh via refresh token failed") + return userCreds, false + } + + newToken := accessTokenResponse.Token - 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 + orgID, _ := ParseTokenOrgClaims(newToken) + if orgID == "" && profile.OrganizationID != "" { + httpClient.SetAuthToken(newToken) + selectOrgRes, err := api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: profile.OrganizationID}) + if err != nil || selectOrgRes.MfaEnabled || selectOrgRes.Token == "" { + log.Debug().Err(err).Msg("unable to re-scope refreshed session token to the profile's organization") + return userCreds, false } + newToken = selectOrgRes.Token + } - return LoggedInUserDetails{ - IsUserLoggedIn: true, - LoginExpired: false, - UserCredentials: userCreds, - }, nil - } else { - return LoggedInUserDetails{}, nil + if IsJWTExpired(newToken) { + return userCreds, false } + + userCreds.JTWToken = newToken + return userCreds, true } 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/profile.go b/packages/util/profile.go new file mode 100644 index 00000000..310d440b --- /dev/null +++ b/packages/util/profile.go @@ -0,0 +1,473 @@ +package util + +import ( + "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" +) + +// Profile names double as keyring keys, so keep them to the character set +// already proven safe there (emails are the historical keys). +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 +} + +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 + + for _, user := range configFile.LoggedInUsers { + if user.Email == "" || findProfileIndex(configFile.Profiles, user.Email) >= 0 { + continue + } + configFile.Profiles = append(configFile.Profiles, models.Profile{ + Name: user.Email, + Email: user.Email, + Domain: user.Domain, + }) + changed = true + } + + if configFile.LoggedInUserEmail != "" && findProfileIndex(configFile.Profiles, configFile.LoggedInUserEmail) < 0 { + 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. + if configFile.LoggedInUserEmail != "" { + activeIdx := findProfileIndex(configFile.Profiles, configFile.ActiveProfile) + if activeIdx < 0 || configFile.Profiles[activeIdx].Email != configFile.LoggedInUserEmail { + if findProfileIndex(configFile.Profiles, configFile.LoggedInUserEmail) >= 0 && configFile.ActiveProfile != configFile.LoggedInUserEmail { + configFile.ActiveProfile = configFile.LoggedInUserEmail + 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 +} + +// 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 + } + return ResolvedProfile{Name: override, Source: overrideSource} + } + + 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) { + 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 + } + } +} + +// 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 { + if err := ValidateProfileName(profile.Name); err != nil { + return err + } + + 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"` + 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 +} + +// 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 "" +} + +// 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..3635ec8a --- /dev/null +++ b/packages/util/profile_test.go @@ -0,0 +1,345 @@ +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("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("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) { + configFile := models.ConfigFile{ + 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 != "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) + } + + 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) { + valid := []string{"scott@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) + } + } +} From c0a430e47d147a193f4bdc697cef7b3be534ca0d Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Thu, 20 Aug 2026 18:19:22 -0700 Subject: [PATCH 2/4] Fix login for emails with RFC-legal special chars, scope domain updates, wire SRP refresh token Three fixes from PR review and CI root-causing: 1. Derived profile names (raw emails) are no longer validated in PersistLoginProfile. The name pattern rejected characters like '+' that are legal in emails, which made login fail after successful authentication for plus-addressed accounts and hung the CI pty harness (init auto-triggered an interactive login whose prompts the harness does not answer). Validation now applies only to user-typed names (--profile, --save-as), and the allowed charset includes '+' so email-named profiles can be targeted explicitly. 2. `user update domain` only repoints profiles whose domain matched the roster entry's previous domain. The same email can be a different account on another instance, and its session token must never be sent to the new domain. (greptile/veria review finding) 3. The password/SRP login path now stores the refresh session scraped from the `jid` cookie (login2/MFA responses, and login v3 which now scrapes it too), so expired sessions renew silently instead of always falling back to interactive re-login. (greptile review finding) Co-Authored-By: Claude Fable 5 --- packages/api/api.go | 15 +++++++++++++++ packages/api/model.go | 3 +++ packages/cmd/login.go | 7 +++++++ packages/cmd/user.go | 14 ++++++++++---- packages/util/profile.go | 17 ++++++++++------- packages/util/profile_test.go | 11 ++++++++++- 6 files changed, 55 insertions(+), 12 deletions(-) diff --git a/packages/api/api.go b/packages/api/api.go index 9bce7e93..c61e5825 100644 --- a/packages/api/api.go +++ b/packages/api/api.go @@ -149,6 +149,21 @@ func CallLoginV3(httpClient *resty.Client, request GetLoginV3Request) (GetLoginV SetBody(request). Post(fmt.Sprintf("%v/v3/auth/login", config.INFISICAL_URL)) + cookies := response.Cookies() + // Find a cookie by name + cookieName := "jid" + var refreshToken *http.Cookie + for _, cookie := range cookies { + if cookie.Name == cookieName { + refreshToken = cookie + break + } + } + + if refreshToken != nil { + loginV3Response.RefreshToken = refreshToken.Value + } + if err != nil { return GetLoginV3Response{}, NewGenericRequestError(operationCallLoginV3, err) } diff --git a/packages/api/model.go b/packages/api/model.go index ec9aeddb..e03d2e0b 100644 --- a/packages/api/model.go +++ b/packages/api/model.go @@ -675,6 +675,9 @@ type GetLoginV3Request struct { type GetLoginV3Response struct { AccessToken string `json:"accessToken"` + // RefreshToken is not part of the JSON body; it is populated from the + // `jid` response cookie, mirroring GetLoginTwoV2Response. + RefreshToken string `json:"-"` } type GetSecretsV4Request struct { diff --git a/packages/cmd/login.go b/packages/cmd/login.go index d200ba5b..cae7f934 100644 --- a/packages/cmd/login.go +++ b/packages/cmd/login.go @@ -353,9 +353,11 @@ var loginCmd = &cobra.Command{ func cliDefaultLogin(userCredentialsToBeStored *models.UserCredentials, email string, password string, organizationId string) { loginV3Response, err := getFreshUserCredentials(email, password) var getOrganizationIdAccessToken string + var refreshToken string if err == nil { getOrganizationIdAccessToken = loginV3Response.AccessToken + refreshToken = loginV3Response.RefreshToken } else { log.Info().Msg("Unable to authenticate with the provided credentials, falling back to SRP authentication") @@ -411,6 +413,7 @@ func cliDefaultLogin(userCredentialsToBeStored *models.UserCredentials, email st loginTwoResponse.Tag = verifyMFAresponse.Tag loginTwoResponse.Token = verifyMFAresponse.Token loginTwoResponse.EncryptionVersion = verifyMFAresponse.EncryptionVersion + loginTwoResponse.RefreshToken = verifyMFAresponse.RefreshToken break } @@ -418,6 +421,7 @@ func cliDefaultLogin(userCredentialsToBeStored *models.UserCredentials, email st } getOrganizationIdAccessToken = loginTwoResponse.Token + refreshToken = loginTwoResponse.RefreshToken } // TODO(daniel): At a later time we should re-add this check, but we don't want to break older Infisical instances that doesn't have the latest SRP removal initiative on them. @@ -432,6 +436,9 @@ func cliDefaultLogin(userCredentialsToBeStored *models.UserCredentials, email st userCredentialsToBeStored.Email = email userCredentialsToBeStored.PrivateKey = "" userCredentialsToBeStored.JTWToken = newJwtToken + // Store the refresh session (scraped from the `jid` cookie) so expired + // access tokens can be renewed without an interactive re-login. + userCredentialsToBeStored.RefreshToken = refreshToken } func setDomainConfig(domain string) { diff --git a/packages/cmd/user.go b/packages/cmd/user.go index afdfd0e7..9e33b33d 100644 --- a/packages/cmd/user.go +++ b/packages/cmd/user.go @@ -222,6 +222,7 @@ var domainCmd = &cobra.Command{ //if not add new profile loggedInUsers //else update profile from loggedinUsers slice + var previousDomain string ok := util.ConfigContainsEmail(configFile.LoggedInUsers, profile) if !ok { configFile.LoggedInUsers = append(configFile.LoggedInUsers, models.LoggedInUser{ @@ -232,6 +233,7 @@ var domainCmd = &cobra.Command{ //exists, set logged in user domain for idx, v := range configFile.LoggedInUsers { if profile == v.Email { + previousDomain = v.Domain configFile.LoggedInUsers[idx].Domain = domain //inplace break } @@ -244,10 +246,14 @@ var domainCmd = &cobra.Command{ configFile.LoggedInUserDomain = domain } - // keep profile entries for this account in sync with the new domain - for idx := range configFile.Profiles { - if configFile.Profiles[idx].Email == profile { - configFile.Profiles[idx].Domain = domain + // Repoint only the profiles that were on the instance this roster entry + // referred to. The same email can be a different account on another + // instance, and its session token must never be sent to the new domain. + if previousDomain != "" { + for idx := range configFile.Profiles { + if configFile.Profiles[idx].Email == profile && configFile.Profiles[idx].Domain == previousDomain { + configFile.Profiles[idx].Domain = domain + } } } diff --git a/packages/util/profile.go b/packages/util/profile.go index 310d440b..2c4ded12 100644 --- a/packages/util/profile.go +++ b/packages/util/profile.go @@ -23,8 +23,10 @@ const ( ) // Profile names double as keyring keys, so keep them to the character set -// already proven safe there (emails are the historical keys). -var profileNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9@._-]*$`) +// 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 { @@ -37,7 +39,7 @@ type ResolvedProfile struct { 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 fmt.Errorf("invalid profile name '%s': use letters, digits, and the characters @ . _ + - (must start with a letter or digit)", name) } return nil } @@ -368,10 +370,11 @@ func slugifyProfileSuffix(value string) string { // 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 { - if err := ValidateProfileName(profile.Name); err != nil { - return err - } - + // 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 } diff --git a/packages/util/profile_test.go b/packages/util/profile_test.go index 3635ec8a..2a14198d 100644 --- a/packages/util/profile_test.go +++ b/packages/util/profile_test.go @@ -202,6 +202,13 @@ func TestDeriveProfileName(t *testing.T) { } }) + 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" { @@ -329,7 +336,9 @@ func TestRemoveProfile(t *testing.T) { } func TestValidateProfileName(t *testing.T) { - valid := []string{"scott@example.com", "client-a", "work.eu", "a", "A1_b-c", "scott@example.com--beta-2"} + // 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) From c6b438ddefeb5b8acc8b636e65ec18e3e64a40d0 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Fri, 21 Aug 2026 10:49:24 -0700 Subject: [PATCH 3/4] Targeted logins no longer move the global default profile `login --profile ` (or with INFISICAL_PROFILE set) is a scoped write to that profile. Making it the global default yanked every unpinned terminal onto the new tenant, which is exactly the cross-terminal interference profiles exist to prevent, and it made expired-session renewals (which re-exec login with --profile) steal the default as a side effect. Explicitly targeted logins now only create/update their profile, in line with the source-aware rule org switch and init already follow. Untargeted logins keep the familiar last-login-wins behavior, and a targeted login prints which profile remains the default and how to switch. Co-Authored-By: Claude Fable 5 --- packages/cmd/login.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/cmd/login.go b/packages/cmd/login.go index cae7f934..bdc84d06 100644 --- a/packages/cmd/login.go +++ b/packages/cmd/login.go @@ -249,6 +249,13 @@ var loginCmd = &cobra.Command{ 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, @@ -256,7 +263,7 @@ var loginCmd = &cobra.Command{ OrganizationID: orgID, OrganizationName: orgName, SubOrganizationID: subOrgID, - }, &userCredentialsToBeStored, true) + }, &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") @@ -298,6 +305,9 @@ var loginCmd = &cobra.Command{ } 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) From 4a6e8b6021608119256604ca8311fce9f68de267 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Fri, 21 Aug 2026 11:09:56 -0700 Subject: [PATCH 4/4] Stop migration from synthesizing phantom profiles for roster mirrors The legacy loggedInUsers roster is kept in sync with profiles for old-binary compatibility. The migration treated every roster entry without a same-named profile as a legacy session and synthesized a profile for it, so a targeted first login (login --profile x) produced a phantom email-named profile with no org and no keyring session behind it. Migration now only synthesizes a profile when no existing profile covers that account's email, and the legacy-switch reconciliation falls back to any profile for the account when no email-named one exists. Co-Authored-By: Claude Fable 5 --- packages/util/profile.go | 34 +++++++++++++++++++---- packages/util/profile_test.go | 52 +++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/packages/util/profile.go b/packages/util/profile.go index 2c4ded12..3e85f317 100644 --- a/packages/util/profile.go +++ b/packages/util/profile.go @@ -52,8 +52,22 @@ func ValidateProfileName(name string) error { 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 == "" || findProfileIndex(configFile.Profiles, user.Email) >= 0 { + if user.Email == "" || anyProfileForEmail(user.Email) { continue } configFile.Profiles = append(configFile.Profiles, models.Profile{ @@ -64,7 +78,7 @@ func MigrateConfigProfiles(configFile *models.ConfigFile) bool { changed = true } - if configFile.LoggedInUserEmail != "" && findProfileIndex(configFile.Profiles, configFile.LoggedInUserEmail) < 0 { + if configFile.LoggedInUserEmail != "" && !anyProfileForEmail(configFile.LoggedInUserEmail) { configFile.Profiles = append(configFile.Profiles, models.Profile{ Name: configFile.LoggedInUserEmail, Email: configFile.LoggedInUserEmail, @@ -75,12 +89,22 @@ func MigrateConfigProfiles(configFile *models.ConfigFile) bool { // 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. + // 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 { - if findProfileIndex(configFile.Profiles, configFile.LoggedInUserEmail) >= 0 && configFile.ActiveProfile != configFile.LoggedInUserEmail { - configFile.ActiveProfile = 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 } } diff --git a/packages/util/profile_test.go b/packages/util/profile_test.go index 2a14198d..54b1b6fe 100644 --- a/packages/util/profile_test.go +++ b/packages/util/profile_test.go @@ -99,6 +99,58 @@ func TestMigrateConfigProfiles(t *testing.T) { } }) + 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",