Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/api/api.go

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated to the file, but some considerations I had while testing this:

  • Why the organization name and not the organization slug in the generated profile name?
  • Why not use the org in the first generated profile name? After you have logged in to a few orgs it can get hard to remember the org without listing
  • With these auto-generated names, a rename command could be useful here, instead of deleting and setting up a new profile.
  • Should login status https://infisical.com/docs/cli/commands/login#infisical-login-status:check-the-active-user-session show profile information?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All four are in now.

Slug, not name. Profiles are named <email>--<org-slug>, e.g. scott@example.com--acme-x4k2. The slug is stable and already URL-safe. If an instance reports no slug we fall back to the slugified name, then to a short piece of the id.

Org in the first name too. Every login gets the suffix now, including the first one, so you can tell the tenant from the name without listing.

Rename. Added infisical profile rename <old> <new>. It moves the stored session and the cached org sessions, and updates the default profile and any directory bindings. A pinned terminal keeps pointing at the old name, so it tells you to pin again.

login status. It now shows the profile and how it was selected, the org name next to its id, and Organization via when --org is in play. Same fields in --json (profile, profileSource, organizationName).

One trade-off worth flagging: a profile that is not named after its email does not publish the legacy loggedInUserEmail field. An older CLI binary reading the same config will ask for a fresh login instead of loading the wrong profile's token. Scott and I decided that is the right call, but it does mean a downgrade after a fresh login is no longer seamless.

Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const (
operationCallGetCertificateBundle = "CallGetCertificateBundle"
operationCallRenewCertificate = "CallRenewCertificate"
operationCallGetCertificateRequest = "CallGetCertificateRequest"
operationCallRevokeUserSession = "CallRevokeUserSession"
)

var ErrNotFound = errors.New("resource not found")
Expand Down Expand Up @@ -160,6 +161,25 @@ func CallLoginV3(httpClient *resty.Client, request GetLoginV3Request) (GetLoginV
return loginV3Response, nil
}

// CallRevokeUserSession revokes a single server-side login session by its id
// (the tokenVersionId claim carried in every session JWT).
func CallRevokeUserSession(httpClient *resty.Client, sessionID string) error {
response, err := httpClient.
R().
SetHeader("User-Agent", USER_AGENT).
Delete(fmt.Sprintf("%v/v2/users/me/sessions/%v", config.INFISICAL_URL, url.PathEscape(sessionID)))

if err != nil {
return NewGenericRequestError(operationCallRevokeUserSession, err)
}

if response.IsError() {
return NewAPIErrorWithResponse(operationCallRevokeUserSession, response, nil)
}

return nil
}

func CallVerifyMfaToken(httpClient *resty.Client, request VerifyMfaTokenRequest) (*VerifyMfaTokenResponse, *VerifyMfaTokenErrorResponse, error) {
var verifyMfaTokenResponse VerifyMfaTokenResponse
var responseError VerifyMfaTokenErrorResponse
Expand Down
157 changes: 106 additions & 51 deletions packages/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -57,64 +58,81 @@ var initCmd = &cobra.Command{
}
httpClient.SetAuthToken(userCreds.UserCredentials.JTWToken)

selectedOrgID, selectedSubOrgName, err := pickOrganization(httpClient, "Which Infisical organization would you like to select a project from?", userCreds.UserCredentials.Email)
if err != nil {
util.HandleError(err, "Unable to select organization")
// The profile already carries an organization (and --org can retarget it
// for this command), so don't ask again. Only fall back to the picker
// when the profile has no organization recorded, which happens for
// sessions migrated from a CLI that predates profiles.
//
// OrganizationID is the organization the session acts in. For a session
// scoped to a sub-organization that is the sub-organization, which is
// also where its projects are filed, not the root from the token.
selectedOrgID := userCreds.OrganizationID

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same issue here, the selectedOrgID wouldn't be the suborgID

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same fix. selectedOrgID is the scoped id now, including on the picker path that still runs for older profiles with no org recorded yet.

var selectedSubOrgName *string
if parent, own := util.SplitOrgDisplayName(userCreds.OrganizationName); parent != "" {
selectedSubOrgName = &own
}

tokenResponse, err := api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrgID})
if tokenResponse.MfaEnabled {
i := 1
for i < 6 {
mfaVerifyCode := askForMFACode(tokenResponse.MfaMethod)
if selectedOrgID == "" {
pickedOrgID, pickedSubOrgName, err := pickOrganization(httpClient, "Which Infisical organization would you like to select a project from?", userCreds.UserCredentials.Email)
if err != nil {
util.HandleError(err, "Unable to select organization")
}
selectedSubOrgName = pickedSubOrgName

httpClient, err := util.GetRestyClientWithCustomHeaders()
if err != nil {
util.HandleError(err, "Unable to get resty client with custom headers")
}
httpClient.SetAuthToken(tokenResponse.Token)
verifyMFAresponse, mfaErrorResponse, requestError := api.CallVerifyMfaToken(httpClient, api.VerifyMfaTokenRequest{
Email: userCreds.UserCredentials.Email,
MFAToken: mfaVerifyCode,
MFAMethod: tokenResponse.MfaMethod,
})
if requestError != nil {
util.HandleError(err)
break
} else if mfaErrorResponse != nil {
if mfaErrorResponse.Context.Code == "mfa_invalid" {
msg := fmt.Sprintf("Incorrect, verification code. You have %v attempts left", 5-i)
util.PrintlnStderr(msg)
if i == 5 {
util.PrintErrorMessageAndExit("No tries left, please try again in a bit")
break
}
}

if mfaErrorResponse.Context.Code == "mfa_expired" {
util.PrintErrorMessageAndExit("Your 2FA verification code has expired, please try logging in again")
break
}
i++
} else {
httpClient.SetAuthToken(verifyMFAresponse.Token)
tokenResponse, err = api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrgID})
break
}
newSessionToken, err := selectOrganizationToken(userCreds.UserCredentials.JTWToken, userCreds.UserCredentials.Email, pickedOrgID)
if err != nil {
util.HandleError(err, "Unable to select organization")
}
}

if err != nil {
util.HandleError(err, "Unable to select organization")
}
// The session token is now scoped to the selected organization; record
// it on the profile this invocation resolved to so later commands in
// this project don't have to ask again.
userCreds.UserCredentials.JTWToken = newSessionToken
orgID, subOrgID := util.ParseTokenOrgClaims(newSessionToken)
if orgID == "" {
orgID = pickedOrgID
}
orgInfo := util.DescribeSessionOrg(newSessionToken, orgID, subOrgID)

updatedProfile := userCreds.Profile
updatedProfile.OrganizationID = orgID
updatedProfile.SubOrganizationID = subOrgID
updatedProfile.OrganizationName = orgInfo.DisplayName()
updatedProfile.OrganizationSlug = orgInfo.Slug
selectedOrgID = updatedProfile.ScopedOrganizationID()

// Only move the global default when this invocation was using it; a
// terminal pinned via env var, flag, or directory scope must not switch
// other terminals.
makeActive := userCreds.ProfileSource == util.ProfileSourceDefault
err = util.PersistLoginProfile(updatedProfile, &userCreds.UserCredentials, makeActive)
httpClient.SetAuthToken(newSessionToken)

if err != nil {
util.HandleError(err, "Unable to store your user credentials")
}
} else {
orgDisplay := userCreds.OrganizationName
if orgDisplay == "" {
orgDisplay = selectedOrgID
}

// set the config jwt token to the new token
userCreds.UserCredentials.JTWToken = tokenResponse.Token
err = util.StoreUserCredsInKeyRing(&userCreds.UserCredentials)
httpClient.SetAuthToken(tokenResponse.Token)
// An --org override is per command, so a project linked under it
// would not resolve on later runs that use the profile's default.
// That would surface later as an unrelated-looking "project not
// found", and a warning here can be silenced, so refuse and point
// at the two ways to make the organization stick.
if userCreds.OrganizationSource != util.OrgSourceProfileDefault && userCreds.Profile.OrganizationID != "" && userCreds.OrganizationID != userCreds.Profile.ScopedOrganizationID() {
profileOrg := userCreds.Profile.OrganizationName
if profileOrg == "" {
profileOrg = userCreds.Profile.ScopedOrganizationID()
}
util.PrintErrorMessageAndExit(
fmt.Sprintf("Profile '%s' defaults to organization %s, so a project linked here under %s (selected via %s) would not be found by later commands unless they also pass --org.", userCreds.ProfileName, profileOrg, orgDisplay, userCreds.OrganizationSource),
fmt.Sprintf("Make %s the profile's default with [infisical profile set-org %s], or keep both organizations by running [infisical profile new <name> --org %s] and then [infisical profile bind <name>] in this directory.", orgDisplay, orgDisplay, orgDisplay))
}
Comment on lines +129 to +133

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit concerned that this warning is not enough here.

They can be silenced by --silent, and the errors from the command won't hint that the issue is this mismatch between the profile org and the project from another org.

I think we should raise an error in this case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. It is an error now, not a warning.

init exits 1 without writing anything, and names the two durable fixes: make it the profile's default with profile set-org, or keep both orgs by creating a second profile and profile binding it to the directory.


if err != nil {
util.HandleError(err, "Unable to store your user credentials")
util.PrintlnStderr(fmt.Sprintf("Using organization %s from profile '%s'. Pass --org to pick a different one.", orgDisplay, userCreds.ProfileName))
}

workspaceResponse, err := api.CallGetAllWorkSpacesUserBelongsTo(httpClient)
Expand All @@ -140,11 +158,48 @@ var initCmd = &cobra.Command{
util.HandleError(err)
}

offerDirectoryProfileBinding(userCreds.ProfileName)

Telemetry.CaptureEvent("cli-command:init", posthog.NewProperties().Set("version", util.CLI_VERSION))

},
}

// offerDirectoryProfileBinding asks (only when multiple profiles exist)
// whether this directory should always use the profile init just ran with, so
// commands run here pick the right tenant without flags or env vars.
func offerDirectoryProfileBinding(profileName string) {
configFile, err := util.GetMigratedConfigFile()
if err != nil || profileName == "" || len(configFile.Profiles) < 2 {
return
}

cwd, err := os.Getwd()
if err != nil {
return
}

if boundProfile, _, ok := util.FindGoverningDirectoryProfile(configFile, cwd); ok && boundProfile == profileName {
return
}

prompt := promptui.Select{
Label: fmt.Sprintf("Bind this directory to profile '%s'? Commands run here will then select it automatically. Select[Yes/No]", profileName),
Items: []string{"No", "Yes"},
}
_, result, err := prompt.Run()
if err != nil || result != "Yes" {
return
}

util.SetDirectoryProfile(&configFile, cwd, profileName)
if err := util.WriteConfigFile(&configFile); err != nil {
util.PrintWarning(fmt.Sprintf("Unable to save the directory profile binding [err=%s]", err))
return
}
util.PrintlnStderr(fmt.Sprintf("Directory %s now uses profile '%s'. Manage bindings with [infisical profile bind] and [infisical profile unbind].", cwd, profileName))
}

func init() {
RootCmd.AddCommand(initCmd)
}
Expand Down
Loading
Loading