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
15 changes: 5 additions & 10 deletions packages/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ Copyright (c) 2023 Infisical Inc.
package cmd

import (
"encoding/json"
"fmt"

"github.com/Infisical/infisical-merge/packages/api"
Expand Down Expand Up @@ -135,7 +134,7 @@ var initCmd = &cobra.Command{
util.HandleError(err)
}

err = writeWorkspaceFile(filteredWorkspaces[index])
err = writeWorkspaceFile(filteredWorkspaces[index], selectedOrgID)
if err != nil {
util.HandleError(err)
}
Expand Down Expand Up @@ -211,17 +210,13 @@ 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, "", " ")
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
}
Expand Down
21 changes: 14 additions & 7 deletions packages/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -146,15 +151,15 @@ 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")
}

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()
Expand Down Expand Up @@ -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 ")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions packages/models/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -136,6 +137,7 @@ type GetAllSecretsParameters struct {
EnvironmentPassedViaFlag bool
InfisicalToken string
UniversalAuthAccessToken string
OrganizationId string
TagSlugs string
WorkspaceId string
SecretsPath string
Expand Down
14 changes: 14 additions & 0 deletions packages/util/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
163 changes: 156 additions & 7 deletions packages/util/secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"unicode"

"github.com/Infisical/infisical-merge/packages/api"
"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"
Expand Down Expand Up @@ -280,6 +282,8 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo
var secretsToReturn []models.SingleEnvironmentVariable
// var serviceTokenDetails api.GetServiceTokenDetailsResponse
var errorToReturn error
var workspaceConfigFile models.WorkspaceConfigFile
var workspaceConfigFilePath string

if params.InfisicalToken == "" && params.UniversalAuthAccessToken == "" {
if params.WorkspaceId == "" {
Expand Down Expand Up @@ -313,28 +317,50 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo
}

if params.WorkspaceId == "" {
var infisicalDotJson models.WorkspaceConfigFile

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")
}

infisicalDotJson = projectConfig
workspaceConfigFile = projectConfig
} else {
workspaceConfigFilePath = filepath.Join(projectConfigFilePath, INFISICAL_WORKSPACE_CONFIG_FILE_NAME)
projectConfig, err := GetWorkSpaceFromFilePath(projectConfigFilePath)
if err != nil {
return nil, err
}

infisicalDotJson = projectConfig
workspaceConfigFile = projectConfig
}
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
}
params.WorkspaceId = infisicalDotJson.WorkspaceId
}

res, err := GetPlainTextSecretsV4(loggedInUserDetails.UserCredentials.JTWToken, params.WorkspaceId,
params.Environment, params.SecretsPath, params.IncludeImport, params.Recursive, params.TagSlugs, true, params.IncludePersonalOverrides)
resolvedToken, err := resolveOrgScopedToken(loggedInUserDetails, params.OrganizationId, workspaceConfigFile.OrganizationId)
if err != nil {
return nil, err
}

res, err := fetchSecretsWithOrgDiscovery(loggedInUserDetails, resolvedToken, params, workspaceConfigFile, workspaceConfigFilePath)
log.Debug().Msgf("GetAllEnvironmentVariables: Trying to fetch secrets JTW token [err=%s]", err)

if err == nil {
Expand Down Expand Up @@ -381,6 +407,129 @@ 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 isOrganizationScopeError(err error) bool {
var apiErr *api.APIError
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")
}
Comment on lines +459 to +468

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.

P1 isOrganizationScopeError fires for every 403, not only the org-scope case. The Infisical backend returns distinct messages for authorization errors (e.g., "You don't have access to this project") versus org-scoping errors ("This project does not belong to your selected organization"). When a user genuinely lacks read permission on a workspace or secrets path, the current code will silently enumerate all of their organizations and retry — adding O(N orgs) extra API calls — before returning the original 403 unchanged. Consider checking the error message/name in addition to the status code, or at minimum document the intentional choice here.

Suggested change
func isOrganizationScopeError(err error) bool {
var apiErr *api.APIError
return errors.As(err, &apiErr) && apiErr.StatusCode == 403
}
func isOrganizationScopeError(err error) bool {
var apiErr *api.APIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != 403 {
return false
}
// Only trigger org discovery for the specific org-scoping rejection.
// Generic permission/auth 403s should not silently enumerate all orgs.
return strings.Contains(apiErr.ErrorMessage, "does not belong to your selected organization")
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch. Narrowed isOrganizationScopeError to only match the org-scope rejection (403 plus the "does not belong to your selected organization" message), so a generic permission 403 no longer enumerates every organization. Added a test for the permission-403 case as well.

Fixed in 3990325.


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 {
Expand Down
Loading