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..47a5ce58 100644 --- a/packages/cmd/login.go +++ b/packages/cmd/login.go @@ -227,6 +227,31 @@ 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) + } + + // Validate that --organization-id and --organization-slug are not both set + if organizationSlug != "" && isDirectUserLoginFlagsAndEnvsSet { + 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.") + } + } + + 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 +430,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 +900,74 @@ 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, 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 { + 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 + 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 + } + } + if matchedOrgId != "" { + break + } + } + + if matchedOrgId == "" { + 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 { + 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 +} + // Manages the browser login flow. // Returns a UserCredentials object on success and an error on failure func browserCliLogin() (models.UserCredentials, error) {