From 4c16b35ccee9847330e1ece85e2ccddc78828832 Mon Sep 17 00:00:00 2001 From: Farrel Darian <62016900+fdarian@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:07:23 +0700 Subject: [PATCH 1/3] feat(run): support fetching secrets from a non-default organization The CLI holds a single org-scoped JWT and uses it against whatever workspace .infisical.json names, so `infisical run` against a project in a different organization than the one selected at login is rejected by the backend, even when the account has access to both organizations. Resolve a target organization id from a new --organization-id flag, the INFISICAL_ORGANIZATION_ID env var, or a new optional organizationId field that `infisical init` now writes to .infisical.json. When it differs from the current token's organizationId claim, mint a token scoped to that org via the existing select-organization endpoint (the same call `infisical init` already uses) and use it for that invocation only, without touching the stored credential. When no target org is set, behavior is unchanged and no extra request is made. --- packages/cmd/init.go | 7 +- packages/cmd/run.go | 21 +++-- packages/models/cli.go | 2 + packages/util/secrets.go | 76 ++++++++++++++-- packages/util/secrets_org_scope_test.go | 114 ++++++++++++++++++++++++ 5 files changed, 204 insertions(+), 16 deletions(-) create mode 100644 packages/util/secrets_org_scope_test.go diff --git a/packages/cmd/init.go b/packages/cmd/init.go index 37eaeae4..d9c1f011 100644 --- a/packages/cmd/init.go +++ b/packages/cmd/init.go @@ -135,7 +135,7 @@ var initCmd = &cobra.Command{ util.HandleError(err) } - err = writeWorkspaceFile(filteredWorkspaces[index]) + err = writeWorkspaceFile(filteredWorkspaces[index], selectedOrgID) if err != nil { util.HandleError(err) } @@ -211,9 +211,10 @@ func pickOrganization(httpClient *resty.Client, label string, username string) ( return subItems[subIndex].ID, &selectedSubOrgName, nil } -func writeWorkspaceFile(selectedWorkspace models.Workspace) error { +func writeWorkspaceFile(selectedWorkspace models.Workspace, organizationId string) error { workspaceFileToSave := models.WorkspaceConfigFile{ - WorkspaceId: selectedWorkspace.ID, + WorkspaceId: selectedWorkspace.ID, + OrganizationId: organizationId, } marshalledWorkspaceFile, err := json.MarshalIndent(workspaceFileToSave, "", " ") diff --git a/packages/cmd/run.go b/packages/cmd/run.go index db98bf4d..eb1360ff 100644 --- a/packages/cmd/run.go +++ b/packages/cmd/run.go @@ -82,6 +82,11 @@ var runCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } + organizationId, err := cmd.Flags().GetString("organization-id") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + command, err := cmd.Flags().GetString("command") if err != nil { util.HandleError(err, "Unable to parse flag") @@ -146,7 +151,7 @@ var runCmd = &cobra.Command{ ExpandSecretReferences: shouldExpandSecrets, } - injectableEnvironment, err := fetchAndFormatSecretsForShell(request, projectConfigDir, secretOverriding, token) + injectableEnvironment, err := fetchAndFormatSecretsForShell(request, projectConfigDir, secretOverriding, token, organizationId) if err != nil { util.HandleError(err, "Could not fetch secrets", "If you are using a service token to fetch secrets, please ensure it is valid") } @@ -154,7 +159,7 @@ var runCmd = &cobra.Command{ log.Debug().Msgf("injecting the following environment variables into shell: %v", injectableEnvironment.Variables) if watchMode { - executeCommandWithWatchMode(command, args, watchModeInterval, request, projectConfigDir, secretOverriding, token) + executeCommandWithWatchMode(command, args, watchModeInterval, request, projectConfigDir, secretOverriding, token, organizationId) } else { if cmd.Flags().Changed("command") { command := cmd.Flag("command").Value.String() @@ -211,6 +216,7 @@ func init() { RootCmd.AddCommand(runCmd) runCmd.Flags().String("token", "", "fetch secrets using service token or machine identity access token") runCmd.Flags().String("projectId", "", "manually set the project ID to fetch secrets from when using machine identity based auth") + runCmd.Flags().String("organization-id", "", "manually set the organization ID to fetch secrets from") runCmd.Flags().StringP("env", "e", "dev", "set the environment (dev, prod, etc.) from which your secrets should be pulled from") runCmd.Flags().Bool("expand", true, "parse shell parameter expansions in your secrets") runCmd.Flags().Bool("include-imports", true, "import linked secrets ") @@ -307,7 +313,7 @@ func waitForExitCommand(cmd *exec.Cmd) (int, error) { return waitStatus.ExitStatus(), nil } -func executeCommandWithWatchMode(commandFlag string, args []string, watchModeInterval int, request models.GetMultiPathSecretsParameters, projectConfigDir string, secretOverriding bool, token *models.TokenDetails) { +func executeCommandWithWatchMode(commandFlag string, args []string, watchModeInterval int, request models.GetMultiPathSecretsParameters, projectConfigDir string, secretOverriding bool, token *models.TokenDetails, organizationId string) { var cmd *exec.Cmd var err error @@ -423,7 +429,7 @@ func executeCommandWithWatchMode(commandFlag string, args []string, watchModeInt watchMutex.Lock() defer watchMutex.Unlock() - newEnvironmentVariables, err := fetchAndFormatSecretsForShell(request, projectConfigDir, secretOverriding, token) + newEnvironmentVariables, err := fetchAndFormatSecretsForShell(request, projectConfigDir, secretOverriding, token, organizationId) if err != nil { log.Error().Err(err).Msg("[HOT RELOAD] Failed to fetch secrets") return @@ -439,12 +445,13 @@ func executeCommandWithWatchMode(commandFlag string, args []string, watchModeInt } } -func fetchSecrets(request models.GetMultiPathSecretsParameters, projectConfigDir string, secretOverriding bool, token *models.TokenDetails) ([]models.SingleEnvironmentVariable, error) { +func fetchSecrets(request models.GetMultiPathSecretsParameters, projectConfigDir string, secretOverriding bool, token *models.TokenDetails, organizationId string) ([]models.SingleEnvironmentVariable, error) { var allSecrets []models.SingleEnvironmentVariable for _, path := range request.SecretsPaths { params := models.GetAllSecretsParameters{ Environment: request.Environment, + OrganizationId: organizationId, WorkspaceId: request.WorkspaceId, TagSlugs: request.TagSlugs, SecretsPath: path, @@ -503,8 +510,8 @@ func formatSecretsForShell(secrets []models.SingleEnvironmentVariable) models.In } } -func fetchAndFormatSecretsForShell(request models.GetMultiPathSecretsParameters, projectConfigDir string, secretOverriding bool, token *models.TokenDetails) (models.InjectableEnvironmentResult, error) { - secrets, err := fetchSecrets(request, projectConfigDir, secretOverriding, token) +func fetchAndFormatSecretsForShell(request models.GetMultiPathSecretsParameters, projectConfigDir string, secretOverriding bool, token *models.TokenDetails, organizationId string) (models.InjectableEnvironmentResult, error) { + secrets, err := fetchSecrets(request, projectConfigDir, secretOverriding, token, organizationId) if err != nil { return models.InjectableEnvironmentResult{}, err } diff --git a/packages/models/cli.go b/packages/models/cli.go index a739d63c..ce9a15d3 100644 --- a/packages/models/cli.go +++ b/packages/models/cli.go @@ -110,6 +110,7 @@ type Workspace struct { type WorkspaceConfigFile struct { WorkspaceId string `json:"workspaceId"` + OrganizationId string `json:"organizationId,omitempty"` DefaultEnvironment string `json:"defaultEnvironment"` GitBranchToEnvironmentMapping map[string]string `json:"gitBranchToEnvironmentMapping"` Domain string `json:"domain,omitempty"` @@ -136,6 +137,7 @@ type GetAllSecretsParameters struct { EnvironmentPassedViaFlag bool InfisicalToken string UniversalAuthAccessToken string + OrganizationId string TagSlugs string WorkspaceId string SecretsPath string diff --git a/packages/util/secrets.go b/packages/util/secrets.go index 5ad4338c..13a29573 100644 --- a/packages/util/secrets.go +++ b/packages/util/secrets.go @@ -15,6 +15,7 @@ import ( "github.com/Infisical/infisical-merge/packages/crypto" "github.com/Infisical/infisical-merge/packages/models" "github.com/go-resty/resty/v2" + jwt "github.com/golang-jwt/jwt/v5" "github.com/rs/zerolog/log" "github.com/zalando/go-keyring" "gopkg.in/yaml.v3" @@ -280,6 +281,7 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo var secretsToReturn []models.SingleEnvironmentVariable // var serviceTokenDetails api.GetServiceTokenDetailsResponse var errorToReturn error + var workspaceConfigFile models.WorkspaceConfigFile if params.InfisicalToken == "" && params.UniversalAuthAccessToken == "" { if params.WorkspaceId == "" { @@ -313,27 +315,40 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo } if params.WorkspaceId == "" { - var infisicalDotJson models.WorkspaceConfigFile - if projectConfigFilePath == "" { projectConfig, err := GetWorkSpaceFromFile() if err != nil { PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") } - infisicalDotJson = projectConfig + workspaceConfigFile = projectConfig } else { projectConfig, err := GetWorkSpaceFromFilePath(projectConfigFilePath) if err != nil { return nil, err } - infisicalDotJson = projectConfig + workspaceConfigFile = projectConfig + } + params.WorkspaceId = workspaceConfigFile.WorkspaceId + } else if projectConfigFilePath == "" { + projectConfig, err := GetWorkSpaceFromFile() + if err == nil { + workspaceConfigFile = projectConfig + } + } else { + projectConfig, err := GetWorkSpaceFromFilePath(projectConfigFilePath) + if err == nil { + workspaceConfigFile = projectConfig } - params.WorkspaceId = infisicalDotJson.WorkspaceId } - res, err := GetPlainTextSecretsV4(loggedInUserDetails.UserCredentials.JTWToken, params.WorkspaceId, + resolvedToken, err := resolveOrgScopedToken(loggedInUserDetails, params.OrganizationId, workspaceConfigFile.OrganizationId) + if err != nil { + return nil, err + } + + res, err := GetPlainTextSecretsV4(resolvedToken, params.WorkspaceId, params.Environment, params.SecretsPath, params.IncludeImport, params.Recursive, params.TagSlugs, true, params.IncludePersonalOverrides) log.Debug().Msgf("GetAllEnvironmentVariables: Trying to fetch secrets JTW token [err=%s]", err) @@ -381,6 +396,55 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo return secretsToReturn, errorToReturn } +type tokenOrganizationClaims struct { + OrganizationId string `json:"organizationId"` + jwt.RegisteredClaims +} + +func getTokenOrganizationId(token string) (string, error) { + var claims tokenOrganizationClaims + parser := jwt.NewParser() + if _, _, err := parser.ParseUnverified(token, &claims); err != nil { + return "", err + } + + return claims.OrganizationId, nil +} + +func resolveOrgScopedToken(loggedInUserDetails LoggedInUserDetails, flagOrganizationId string, configOrganizationId string) (string, error) { + targetOrgId := flagOrganizationId + if targetOrgId == "" { + targetOrgId = os.Getenv("INFISICAL_ORGANIZATION_ID") + } + if targetOrgId == "" { + targetOrgId = configOrganizationId + } + if targetOrgId == "" { + return loggedInUserDetails.UserCredentials.JTWToken, nil + } + + currentTokenOrgId, err := getTokenOrganizationId(loggedInUserDetails.UserCredentials.JTWToken) + if err != nil { + return "", fmt.Errorf("unable to determine the organization scope for the current login token [err=%v]", err) + } + if currentTokenOrgId == targetOrgId { + return loggedInUserDetails.UserCredentials.JTWToken, nil + } + + httpClient, err := GetRestyClientWithCustomHeaders() + if err != nil { + return "", fmt.Errorf("unable to get resty client with custom headers [err=%v]", err) + } + httpClient.SetAuthToken(loggedInUserDetails.UserCredentials.JTWToken) + + selectOrganizationResponse, err := api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: targetOrgId}) + if err != nil { + return "", fmt.Errorf("unable to scope the current session to organization %s; ensure your account can access that organization or log in again [err=%v]", targetOrgId, err) + } + + return selectOrganizationResponse.Token, nil +} + func GetBackupEncryptionKey() ([]byte, error) { encryptionKey, err := GetValueInKeyring(INFISICAL_BACKUP_SECRET_ENCRYPTION_KEY) if err != nil { diff --git a/packages/util/secrets_org_scope_test.go b/packages/util/secrets_org_scope_test.go new file mode 100644 index 00000000..a2d407c7 --- /dev/null +++ b/packages/util/secrets_org_scope_test.go @@ -0,0 +1,114 @@ +package util + +import ( + "testing" + "time" + + "github.com/Infisical/infisical-merge/packages/models" + jwt "github.com/golang-jwt/jwt/v5" +) + +func TestGetTokenOrganizationId(t *testing.T) { + t.Run("decodes organizationId claim", func(t *testing.T) { + token := makeUnsignedJWT(t, map[string]any{ + "organizationId": "org-123", + "exp": time.Now().Add(time.Hour).Unix(), + }) + + organizationId, err := getTokenOrganizationId(token) + if err != nil { + t.Fatalf("getTokenOrganizationId: unexpected error: %v", err) + } + if organizationId != "org-123" { + t.Errorf("organizationId = %q, want %q", organizationId, "org-123") + } + }) + + t.Run("malformed token returns error", func(t *testing.T) { + if _, err := getTokenOrganizationId("not-a-jwt"); err == nil { + t.Errorf("getTokenOrganizationId(%q) = nil error, want error", "not-a-jwt") + } + }) +} + +func TestResolveOrgScopedToken(t *testing.T) { + storedToken := makeUnsignedJWT(t, map[string]any{ + "organizationId": "org-current", + "exp": time.Now().Add(time.Hour).Unix(), + }) + loggedInUserDetails := LoggedInUserDetails{ + UserCredentials: models.UserCredentials{ + JTWToken: storedToken, + }, + } + + t.Run("returns stored token when no org source is set", func(t *testing.T) { + t.Setenv("INFISICAL_ORGANIZATION_ID", "") + + resolvedToken, err := resolveOrgScopedToken(loggedInUserDetails, "", "") + if err != nil { + t.Fatalf("resolveOrgScopedToken: unexpected error: %v", err) + } + if resolvedToken != storedToken { + t.Errorf("resolvedToken = %q, want stored token %q", resolvedToken, storedToken) + } + }) + + cases := []struct { + name string + flagOrganization string + envOrganization string + configOrganization string + }{ + { + name: "matching flag org returns stored token", + flagOrganization: "org-current", + envOrganization: "org-env", + configOrganization: "org-config", + }, + { + name: "matching env org returns stored token when flag empty", + flagOrganization: "", + envOrganization: "org-current", + configOrganization: "org-config", + }, + { + name: "matching config org returns stored token when flag and env empty", + flagOrganization: "", + envOrganization: "", + configOrganization: "org-current", + }, + { + name: "matching org returns stored token when all sources equal current", + flagOrganization: "org-current", + envOrganization: "org-current", + configOrganization: "org-current", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("INFISICAL_ORGANIZATION_ID", tc.envOrganization) + + resolvedToken, err := resolveOrgScopedToken(loggedInUserDetails, tc.flagOrganization, tc.configOrganization) + if err != nil { + t.Fatalf("resolveOrgScopedToken: unexpected error: %v", err) + } + if resolvedToken != storedToken { + t.Errorf("resolvedToken = %q, want stored token %q", resolvedToken, storedToken) + } + }) + } +} + +func makeUnsignedJWT(t *testing.T, claims map[string]any) string { + t.Helper() + + token := jwt.NewWithClaims(jwt.SigningMethodNone, jwt.MapClaims(claims)) + tokenString, err := token.SignedString(jwt.UnsafeAllowNoneSignatureType) + if err != nil { + t.Fatalf("SignedString: unexpected error: %v", err) + } + + return tokenString +} From f62a4a43b64241dc7da54f4fc1260ce6c30042e1 Mon Sep 17 00:00:00 2001 From: Farrel Darian <62016900+fdarian@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:28:08 +0700 Subject: [PATCH 2/3] feat(run): auto-discover a project's org on a scope mismatch When the resolved token still gets a 403 "project does not belong to your selected organization", enumerate the organizations the account belongs to, re-scope into each and retry the fetch, and on the first success persist the discovered organizationId into .infisical.json so later runs route directly. This lets project configs that predate the organizationId field work without a re-init. Discovery only runs on that 403, so correct-org and single-org fetches make no extra requests. --- packages/cmd/init.go | 8 +-- packages/util/config.go | 14 +++++ packages/util/secrets.go | 83 ++++++++++++++++++++++++- packages/util/secrets_org_scope_test.go | 78 +++++++++++++++++++++++ 4 files changed, 174 insertions(+), 9 deletions(-) diff --git a/packages/cmd/init.go b/packages/cmd/init.go index d9c1f011..0240ac3d 100644 --- a/packages/cmd/init.go +++ b/packages/cmd/init.go @@ -4,7 +4,6 @@ Copyright (c) 2023 Infisical Inc. package cmd import ( - "encoding/json" "fmt" "github.com/Infisical/infisical-merge/packages/api" @@ -217,12 +216,7 @@ func writeWorkspaceFile(selectedWorkspace models.Workspace, organizationId strin OrganizationId: organizationId, } - marshalledWorkspaceFile, err := json.MarshalIndent(workspaceFileToSave, "", " ") - if err != nil { - return err - } - - err = util.WriteToFile(util.INFISICAL_WORKSPACE_CONFIG_FILE_NAME, marshalledWorkspaceFile, 0600) + err := util.WriteWorkspaceConfigToPath(workspaceFileToSave, util.INFISICAL_WORKSPACE_CONFIG_FILE_NAME) if err != nil { return err } diff --git a/packages/util/config.go b/packages/util/config.go index 99bfb47d..4e6af1a4 100644 --- a/packages/util/config.go +++ b/packages/util/config.go @@ -197,6 +197,20 @@ func GetWorkspaceConfigByPath(path string) (workspaceConfig models.WorkspaceConf return workspaceConfigFile, nil } +func WriteWorkspaceConfigToPath(config models.WorkspaceConfigFile, path string) error { + marshalledWorkspaceFile, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + + err = WriteToFile(path, marshalledWorkspaceFile, 0600) + if err != nil { + return err + } + + return nil +} + // Get the infisical config file and if it doesn't exist, return empty config model, otherwise raise error func GetConfigFile() (models.ConfigFile, error) { fullConfigFilePath, _, err := GetFullConfigFilePath() diff --git a/packages/util/secrets.go b/packages/util/secrets.go index 13a29573..f3195e35 100644 --- a/packages/util/secrets.go +++ b/packages/util/secrets.go @@ -8,6 +8,7 @@ import ( "fmt" "net/http" "os" + "path/filepath" "strings" "unicode" @@ -282,6 +283,7 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo // var serviceTokenDetails api.GetServiceTokenDetailsResponse var errorToReturn error var workspaceConfigFile models.WorkspaceConfigFile + var workspaceConfigFilePath string if params.InfisicalToken == "" && params.UniversalAuthAccessToken == "" { if params.WorkspaceId == "" { @@ -316,6 +318,10 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo if params.WorkspaceId == "" { if projectConfigFilePath == "" { + workspaceConfigFilePath, err = FindWorkspaceConfigFile() + if err != nil { + PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") + } projectConfig, err := GetWorkSpaceFromFile() if err != nil { PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") @@ -323,6 +329,7 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo workspaceConfigFile = projectConfig } else { + workspaceConfigFilePath = filepath.Join(projectConfigFilePath, INFISICAL_WORKSPACE_CONFIG_FILE_NAME) projectConfig, err := GetWorkSpaceFromFilePath(projectConfigFilePath) if err != nil { return nil, err @@ -332,11 +339,16 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo } params.WorkspaceId = workspaceConfigFile.WorkspaceId } else if projectConfigFilePath == "" { + workspaceConfigFilePath, err = FindWorkspaceConfigFile() + if err != nil { + workspaceConfigFilePath = "" + } projectConfig, err := GetWorkSpaceFromFile() if err == nil { workspaceConfigFile = projectConfig } } else { + workspaceConfigFilePath = filepath.Join(projectConfigFilePath, INFISICAL_WORKSPACE_CONFIG_FILE_NAME) projectConfig, err := GetWorkSpaceFromFilePath(projectConfigFilePath) if err == nil { workspaceConfigFile = projectConfig @@ -348,8 +360,7 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo return nil, err } - res, err := GetPlainTextSecretsV4(resolvedToken, params.WorkspaceId, - params.Environment, params.SecretsPath, params.IncludeImport, params.Recursive, params.TagSlugs, true, params.IncludePersonalOverrides) + res, err := fetchSecretsWithOrgDiscovery(loggedInUserDetails, resolvedToken, params, workspaceConfigFile, workspaceConfigFilePath) log.Debug().Msgf("GetAllEnvironmentVariables: Trying to fetch secrets JTW token [err=%s]", err) if err == nil { @@ -445,6 +456,74 @@ func resolveOrgScopedToken(loggedInUserDetails LoggedInUserDetails, flagOrganiza return selectOrganizationResponse.Token, nil } +func isOrganizationScopeError(err error) bool { + var apiErr *api.APIError + return errors.As(err, &apiErr) && apiErr.StatusCode == 403 +} + +func fetchSecretsWithOrgDiscovery(loggedInUserDetails LoggedInUserDetails, resolvedToken string, params models.GetAllSecretsParameters, workspaceConfigFile models.WorkspaceConfigFile, workspaceConfigFilePath string) (models.PlaintextSecretResult, error) { + res, err := GetPlainTextSecretsV4(resolvedToken, params.WorkspaceId, + params.Environment, params.SecretsPath, params.IncludeImport, params.Recursive, params.TagSlugs, true, params.IncludePersonalOverrides) + if err == nil { + return res, nil + } + if !isOrganizationScopeError(err) { + return models.PlaintextSecretResult{}, err + } + + originalError := err + httpClient, err := GetRestyClientWithCustomHeaders() + if err != nil { + return models.PlaintextSecretResult{}, originalError + } + httpClient.SetAuthToken(loggedInUserDetails.UserCredentials.JTWToken) + + orgsResponse, err := api.CallGetAllOrganizations(httpClient) + if err != nil { + return models.PlaintextSecretResult{}, originalError + } + + currentTokenOrgId, err := getTokenOrganizationId(loggedInUserDetails.UserCredentials.JTWToken) + if err != nil { + return models.PlaintextSecretResult{}, originalError + } + + for _, organization := range orgsResponse.Organizations { + if organization.ID == currentTokenOrgId { + continue + } + + httpClient.SetAuthToken(loggedInUserDetails.UserCredentials.JTWToken) + selectOrganizationResponse, err := api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: organization.ID}) + if err != nil { + continue + } + + res, err := GetPlainTextSecretsV4(selectOrganizationResponse.Token, params.WorkspaceId, + params.Environment, params.SecretsPath, params.IncludeImport, params.Recursive, params.TagSlugs, true, params.IncludePersonalOverrides) + if err != nil { + continue + } + + persistDiscoveredOrganizationId(workspaceConfigFile, workspaceConfigFilePath, organization.ID) + return res, nil + } + + return models.PlaintextSecretResult{}, originalError +} + +func persistDiscoveredOrganizationId(workspaceConfigFile models.WorkspaceConfigFile, workspaceConfigFilePath string, organizationId string) { + if workspaceConfigFilePath == "" { + return + } + + workspaceConfigFile.OrganizationId = organizationId + err := WriteWorkspaceConfigToPath(workspaceConfigFile, workspaceConfigFilePath) + if err != nil { + log.Debug().Err(err).Str("path", workspaceConfigFilePath).Msg("Failed to persist discovered organization ID to workspace config") + } +} + func GetBackupEncryptionKey() ([]byte, error) { encryptionKey, err := GetValueInKeyring(INFISICAL_BACKUP_SECRET_ENCRYPTION_KEY) if err != nil { diff --git a/packages/util/secrets_org_scope_test.go b/packages/util/secrets_org_scope_test.go index a2d407c7..d17f9efa 100644 --- a/packages/util/secrets_org_scope_test.go +++ b/packages/util/secrets_org_scope_test.go @@ -1,9 +1,12 @@ package util import ( + "errors" + "path/filepath" "testing" "time" + "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/models" jwt "github.com/golang-jwt/jwt/v5" ) @@ -101,6 +104,81 @@ func TestResolveOrgScopedToken(t *testing.T) { } } +func TestIsOrganizationScopeError(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + { + name: "403 api error", + err: &api.APIError{ + StatusCode: 403, + }, + want: true, + }, + { + name: "404 api error", + err: &api.APIError{ + StatusCode: 404, + }, + want: false, + }, + { + name: "plain error", + err: errors.New("boom"), + want: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := isOrganizationScopeError(tc.err) + if got != tc.want { + t.Errorf("isOrganizationScopeError(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} + +func TestWriteWorkspaceConfigToPath(t *testing.T) { + path := filepath.Join(t.TempDir(), ".infisical.json") + workspaceConfig := models.WorkspaceConfigFile{ + WorkspaceId: "workspace-123", + OrganizationId: "org-123", + DefaultEnvironment: "dev", + GitBranchToEnvironmentMapping: map[string]string{ + "main": "prod", + }, + Domain: "https://example.infisical.com", + } + + err := WriteWorkspaceConfigToPath(workspaceConfig, path) + if err != nil { + t.Fatalf("WriteWorkspaceConfigToPath: unexpected error: %v", err) + } + + storedConfig, err := GetWorkspaceConfigByPath(path) + if err != nil { + t.Fatalf("GetWorkspaceConfigByPath: unexpected error: %v", err) + } + if storedConfig.WorkspaceId != workspaceConfig.WorkspaceId { + t.Errorf("WorkspaceId = %q, want %q", storedConfig.WorkspaceId, workspaceConfig.WorkspaceId) + } + if storedConfig.OrganizationId != workspaceConfig.OrganizationId { + t.Errorf("OrganizationId = %q, want %q", storedConfig.OrganizationId, workspaceConfig.OrganizationId) + } + if storedConfig.DefaultEnvironment != workspaceConfig.DefaultEnvironment { + t.Errorf("DefaultEnvironment = %q, want %q", storedConfig.DefaultEnvironment, workspaceConfig.DefaultEnvironment) + } + if storedConfig.Domain != workspaceConfig.Domain { + t.Errorf("Domain = %q, want %q", storedConfig.Domain, workspaceConfig.Domain) + } + if storedConfig.GitBranchToEnvironmentMapping["main"] != workspaceConfig.GitBranchToEnvironmentMapping["main"] { + t.Errorf("GitBranchToEnvironmentMapping[main] = %q, want %q", storedConfig.GitBranchToEnvironmentMapping["main"], workspaceConfig.GitBranchToEnvironmentMapping["main"]) + } +} + func makeUnsignedJWT(t *testing.T, claims map[string]any) string { t.Helper() From 399032565de4f2e8742f829c423e4cc29d68616e Mon Sep 17 00:00:00 2001 From: Farrel Darian <62016900+fdarian@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:54:39 +0700 Subject: [PATCH 3/3] fix(run): only auto-discover on the org-scope 403, not every 403 isOrganizationScopeError matched every 403, so a generic permission rejection would enumerate all of the account's organizations before failing. Match the specific "does not belong to your selected organization" backend message instead. --- packages/util/secrets.go | 8 +++++++- packages/util/secrets_org_scope_test.go | 16 +++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/util/secrets.go b/packages/util/secrets.go index f3195e35..d70939c3 100644 --- a/packages/util/secrets.go +++ b/packages/util/secrets.go @@ -458,7 +458,13 @@ func resolveOrgScopedToken(loggedInUserDetails LoggedInUserDetails, flagOrganiza func isOrganizationScopeError(err error) bool { var apiErr *api.APIError - return errors.As(err, &apiErr) && apiErr.StatusCode == 403 + if !errors.As(err, &apiErr) || apiErr.StatusCode != 403 { + return false + } + + // Only the org-scoping rejection should trigger discovery; a generic + // permission 403 must not enumerate every organization. + return strings.Contains(apiErr.ErrorMessage, "does not belong to your selected organization") } func fetchSecretsWithOrgDiscovery(loggedInUserDetails LoggedInUserDetails, resolvedToken string, params models.GetAllSecretsParameters, workspaceConfigFile models.WorkspaceConfigFile, workspaceConfigFilePath string) (models.PlaintextSecretResult, error) { diff --git a/packages/util/secrets_org_scope_test.go b/packages/util/secrets_org_scope_test.go index d17f9efa..f4e4a556 100644 --- a/packages/util/secrets_org_scope_test.go +++ b/packages/util/secrets_org_scope_test.go @@ -111,16 +111,26 @@ func TestIsOrganizationScopeError(t *testing.T) { want bool }{ { - name: "403 api error", + name: "403 org scoping error", err: &api.APIError{ - StatusCode: 403, + StatusCode: 403, + ErrorMessage: "This project does not belong to your selected organization", }, want: true, }, + { + name: "403 generic permission error", + err: &api.APIError{ + StatusCode: 403, + ErrorMessage: "You don't have access to this project", + }, + want: false, + }, { name: "404 api error", err: &api.APIError{ - StatusCode: 404, + StatusCode: 404, + ErrorMessage: "This project does not belong to your selected organization", }, want: false, },