From 780fb0b8ce909255fafeff421e32081e591432f3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:10:36 +0000 Subject: [PATCH 1/4] feat: support --organization-slug flag for user/browser SSO login Add support for scoping interactive SSO login to a specific sub-organization using the --organization-slug flag, similar to how machine identity login already works. Changes: - Add API types and function for accessible-with-sub-orgs endpoint - Add rescopeTokenToOrgBySlug function to resolve org slug to ID - After user login (browser or CLI), re-scope token if --organization-slug is set - Update flag description to reflect support for both user and machine identity login Co-Authored-By: ashwin --- packages/api/api.go | 20 ++++++++++++++ packages/api/model.go | 17 ++++++++++++ packages/cmd/login.go | 61 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/packages/api/api.go b/packages/api/api.go index d2b454ea..b920e8ce 100644 --- a/packages/api/api.go +++ b/packages/api/api.go @@ -60,6 +60,7 @@ const ( operationCallRetrieveCertificate = "CallRetrieveCertificate" operationCallRenewCertificate = "CallRenewCertificate" operationCallGetCertificateRequest = "CallGetCertificateRequest" + operationCallGetAccessibleOrgsWithSubOrgs = "CallGetAccessibleOrganizationsWithSubOrgs" ) var ErrNotFound = errors.New("resource not found") @@ -217,6 +218,25 @@ func CallLogin2V2(httpClient *resty.Client, request GetLoginTwoV2Request) (GetLo return loginTwoV2Response, nil } +func CallGetAccessibleOrganizationsWithSubOrgs(httpClient *resty.Client) (GetAccessibleOrganizationsWithSubOrgsResponse, error) { + var orgResponse GetAccessibleOrganizationsWithSubOrgsResponse + response, err := httpClient. + R(). + SetResult(&orgResponse). + SetHeader("User-Agent", USER_AGENT). + Get(fmt.Sprintf("%v/v1/organization/accessible-with-sub-orgs", config.INFISICAL_URL)) + + if err != nil { + return GetAccessibleOrganizationsWithSubOrgsResponse{}, NewGenericRequestError(operationCallGetAccessibleOrgsWithSubOrgs, err) + } + + if response.IsError() { + return GetAccessibleOrganizationsWithSubOrgsResponse{}, NewAPIErrorWithResponse(operationCallGetAccessibleOrgsWithSubOrgs, response, nil) + } + + return orgResponse, nil +} + func CallGetAllOrganizations(httpClient *resty.Client) (GetOrganizationsResponse, error) { var orgResponse GetOrganizationsResponse response, err := httpClient. diff --git a/packages/api/model.go b/packages/api/model.go index 26124c03..01ab81c9 100644 --- a/packages/api/model.go +++ b/packages/api/model.go @@ -168,6 +168,23 @@ type SelectOrganizationRequest struct { OrganizationId string `json:"organizationId"` } +type SubOrganization struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +type OrganizationWithSubOrgs struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + SubOrganizations []SubOrganization `json:"subOrganizations"` +} + +type GetAccessibleOrganizationsWithSubOrgsResponse struct { + Organizations []OrganizationWithSubOrgs `json:"organizations"` +} + type Secret struct { SecretKeyCiphertext string `json:"secretKeyCiphertext,omitempty"` SecretKeyIV string `json:"secretKeyIV,omitempty"` diff --git a/packages/cmd/login.go b/packages/cmd/login.go index 863abf95..7ae819ea 100644 --- a/packages/cmd/login.go +++ b/packages/cmd/login.go @@ -227,6 +227,19 @@ var loginCmd = &cobra.Command{ cliDefaultLogin(&userCredentialsToBeStored, email, password, organizationId) } + // If --organization-slug is provided, re-scope the token to the specified organization/sub-organization + organizationSlug, err := cmd.Flags().GetString("organization-slug") + if err != nil { + util.HandleError(err) + } + if organizationSlug != "" { + newToken, rescopeErr := rescopeTokenToOrgBySlug(userCredentialsToBeStored.JTWToken, organizationSlug) + if rescopeErr != nil { + util.HandleError(rescopeErr, "Unable to scope login to the specified organization") + } + userCredentialsToBeStored.JTWToken = newToken + } + err = util.StoreUserCredsInKeyRing(&userCredentialsToBeStored) if err != nil { log.Error().Msgf("Unable to store your credentials in system vault") @@ -405,7 +418,7 @@ func init() { loginCmd.Flags().String("method", "user", "login method [user, universal-auth, kubernetes, azure, gcp-id-token, gcp-iam, aws-iam, oidc-auth]") loginCmd.Flags().String("client-id", "", "client id for universal auth") loginCmd.Flags().String("client-secret", "", "client secret for universal auth") - loginCmd.Flags().String("organization-slug", "", "When set for machine identity login, this will scope the login session to the specified sub-organization the machine identity has access to. If left empty, the session defaults to the organization where the machine identity was created in.") + loginCmd.Flags().String("organization-slug", "", "When set, this will scope the login session to the specified sub-organization. Works for both user login (including browser/SSO) and machine identity login. If left empty, the session defaults to the organization selected during login.") loginCmd.Flags().String("machine-identity-id", "", "machine identity id for these login methods [kubernetes, azure, gcp-id-token, gcp-iam, aws-iam]") loginCmd.Flags().String("service-account-token-path", "", "service account token path for kubernetes auth") loginCmd.Flags().String("service-account-key-file-path", "", "service account key file path for GCP IAM auth") @@ -875,6 +888,52 @@ func decodePastedBase64Token(token string) (*models.UserCredentials, error) { return &loginResponse, nil } +// rescopeTokenToOrgBySlug resolves an organization slug to its ID using the accessible-with-sub-orgs +// endpoint and then calls selectOrganization to get a new token scoped to that org. +func rescopeTokenToOrgBySlug(currentToken string, organizationSlug string) (string, error) { + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + return "", fmt.Errorf("unable to get resty client with custom headers: %w", err) + } + httpClient.SetAuthToken(currentToken) + + // Fetch all accessible organizations including sub-orgs + orgsResponse, err := api.CallGetAccessibleOrganizationsWithSubOrgs(httpClient) + if err != nil { + return "", fmt.Errorf("unable to fetch accessible organizations: %w", err) + } + + // Search for the matching organization by slug (both root orgs and sub-orgs) + var matchedOrgId string + for _, org := range orgsResponse.Organizations { + if org.Slug == organizationSlug { + matchedOrgId = org.ID + break + } + for _, subOrg := range org.SubOrganizations { + if subOrg.Slug == organizationSlug { + matchedOrgId = subOrg.ID + break + } + } + if matchedOrgId != "" { + break + } + } + + if matchedOrgId == "" { + return "", fmt.Errorf("organization with slug '%s' not found or not accessible", organizationSlug) + } + + // Call selectOrganization to get a new token scoped to the matched org + selectedOrgRes, err := api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: matchedOrgId}) + if err != nil { + return "", fmt.Errorf("unable to select organization: %w", err) + } + + return selectedOrgRes.Token, nil +} + // Manages the browser login flow. // Returns a UserCredentials object on success and an error on failure func browserCliLogin() (models.UserCredentials, error) { From 82409071d7f4c6b979c2f6c942d2081498cdd8fa Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:20:23 +0000 Subject: [PATCH 2/4] fix: handle MFA on re-scoped org and reject conflicting --organization-id/--organization-slug flags - Check MfaEnabled on SelectOrganizationResponse and return a clear error if the target org requires MFA verification - Error out early if both --organization-id and --organization-slug are provided to avoid silent override behavior Co-Authored-By: ashwin --- packages/cmd/login.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/cmd/login.go b/packages/cmd/login.go index 7ae819ea..4c8d5978 100644 --- a/packages/cmd/login.go +++ b/packages/cmd/login.go @@ -232,6 +232,15 @@ var loginCmd = &cobra.Command{ if err != nil { util.HandleError(err) } + + // Validate that --organization-id and --organization-slug are not both set + if organizationSlug != "" && isDirectUserLoginFlagsAndEnvsSet { + orgIdFlag, _ := util.GetCmdFlagOrEnv(cmd, "organization-id", []string{"INFISICAL_ORGANIZATION_ID"}) + if orgIdFlag != "" { + util.PrintErrorMessageAndExit("Cannot use both --organization-id and --organization-slug at the same time. Please use only one to specify the target organization.") + } + } + if organizationSlug != "" { newToken, rescopeErr := rescopeTokenToOrgBySlug(userCredentialsToBeStored.JTWToken, organizationSlug) if rescopeErr != nil { @@ -931,6 +940,10 @@ func rescopeTokenToOrgBySlug(currentToken string, organizationSlug string) (stri return "", fmt.Errorf("unable to select organization: %w", err) } + if selectedOrgRes.MfaEnabled { + return "", fmt.Errorf("organization '%s' requires MFA verification; please log in without --organization-slug and complete the MFA challenge during interactive org selection", organizationSlug) + } + return selectedOrgRes.Token, nil } From 003476b1df6b795c8fbe7c10eb5a926e1163069c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:31:42 +0000 Subject: [PATCH 3/4] fix: handle error from GetCmdFlagOrEnv in conflict validation Instead of silently discarding the error with _, properly handle it to ensure the --organization-id/--organization-slug conflict check works correctly even if the flag/env cannot be read. Co-Authored-By: ashwin --- packages/cmd/login.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cmd/login.go b/packages/cmd/login.go index 4c8d5978..06c97c55 100644 --- a/packages/cmd/login.go +++ b/packages/cmd/login.go @@ -235,7 +235,10 @@ var loginCmd = &cobra.Command{ // Validate that --organization-id and --organization-slug are not both set if organizationSlug != "" && isDirectUserLoginFlagsAndEnvsSet { - orgIdFlag, _ := util.GetCmdFlagOrEnv(cmd, "organization-id", []string{"INFISICAL_ORGANIZATION_ID"}) + orgIdFlag, orgIdErr := util.GetCmdFlagOrEnv(cmd, "organization-id", []string{"INFISICAL_ORGANIZATION_ID"}) + if orgIdErr != nil { + util.HandleError(orgIdErr) + } if orgIdFlag != "" { util.PrintErrorMessageAndExit("Cannot use both --organization-id and --organization-slug at the same time. Please use only one to specify the target organization.") } From a108a6bb67b81cf5d6772b9777ad44b03be59307 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:05:34 +0000 Subject: [PATCH 4/4] feat: add user confirmation prompt before re-scoping token to organization Addresses security concern where an attacker could trick a user into running a command with a malicious --organization-slug. Now the CLI shows the matched organization name and slug, and requires the user to explicitly confirm before re-scoping the token. Prompt example: You are about to scope your login to organization "Acme Sub-Org" (slug: acme-sub-org). Do you want to continue? > Yes No Co-Authored-By: ashwin --- packages/cmd/login.go | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/cmd/login.go b/packages/cmd/login.go index 06c97c55..47a5ce58 100644 --- a/packages/cmd/login.go +++ b/packages/cmd/login.go @@ -901,7 +901,8 @@ func decodePastedBase64Token(token string) (*models.UserCredentials, error) { } // rescopeTokenToOrgBySlug resolves an organization slug to its ID using the accessible-with-sub-orgs -// endpoint and then calls selectOrganization to get a new token scoped to that org. +// endpoint, asks the user to confirm the matched organization, and then calls selectOrganization to +// get a new token scoped to that org. func rescopeTokenToOrgBySlug(currentToken string, organizationSlug string) (string, error) { httpClient, err := util.GetRestyClientWithCustomHeaders() if err != nil { @@ -917,14 +918,17 @@ func rescopeTokenToOrgBySlug(currentToken string, organizationSlug string) (stri // Search for the matching organization by slug (both root orgs and sub-orgs) var matchedOrgId string + var matchedOrgName string for _, org := range orgsResponse.Organizations { if org.Slug == organizationSlug { matchedOrgId = org.ID + matchedOrgName = org.Name break } for _, subOrg := range org.SubOrganizations { if subOrg.Slug == organizationSlug { matchedOrgId = subOrg.ID + matchedOrgName = subOrg.Name break } } @@ -937,6 +941,20 @@ func rescopeTokenToOrgBySlug(currentToken string, organizationSlug string) (stri return "", fmt.Errorf("organization with slug '%s' not found or not accessible", organizationSlug) } + // Prompt user to confirm the organization before re-scoping + confirmLabel := fmt.Sprintf("You are about to scope your login to organization \"%s\" (slug: %s). Do you want to continue?", matchedOrgName, organizationSlug) + confirmPrompt := promptui.Select{ + Label: confirmLabel, + Items: []string{"Yes", "No"}, + } + _, confirmResult, promptErr := confirmPrompt.Run() + if promptErr != nil { + return "", fmt.Errorf("confirmation prompt failed: %w", promptErr) + } + if confirmResult != "Yes" { + return "", fmt.Errorf("organization scope selection cancelled by user") + } + // Call selectOrganization to get a new token scoped to the matched org selectedOrgRes, err := api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: matchedOrgId}) if err != nil {