diff --git a/cli/packages/api/api.go b/cli/packages/api/api.go index 56d96f3fb..c57cec59f 100644 --- a/cli/packages/api/api.go +++ b/cli/packages/api/api.go @@ -261,6 +261,70 @@ func CallGetSecretsV3(httpClient *resty.Client, request GetEncryptedSecretsV3Req return secretsResponse, nil } +func CallGetFoldersV1(httpClient *resty.Client, request GetFoldersV1Request) (GetFoldersV1Response, error) { + var foldersResponse GetFoldersV1Response + httpRequest := httpClient. + R(). + SetResult(&foldersResponse). + SetHeader("User-Agent", USER_AGENT). + SetQueryParam("environment", request.Environment). + SetQueryParam("workspaceId", request.WorkspaceId). + SetQueryParam("directory", request.FoldersPath) + + response, err := httpRequest.Get(fmt.Sprintf("%v/v1/folders", config.INFISICAL_URL)) + + if err != nil { + return GetFoldersV1Response{}, fmt.Errorf("CallGetFoldersV1: Unable to complete api request [err=%v]", err) + } + + if response.IsError() { + return GetFoldersV1Response{}, fmt.Errorf("CallGetFoldersV1: Unsuccessful [response=%s]", response) + } + + return foldersResponse, nil +} + +func CallCreateFolderV1(httpClient *resty.Client, request CreateFolderV1Request) (CreateFolderV1Response, error) { + var folderResponse CreateFolderV1Response + httpRequest := httpClient. + R(). + SetResult(&folderResponse). + SetHeader("User-Agent", USER_AGENT). + SetBody(request) + + response, err := httpRequest.Post(fmt.Sprintf("%v/v1/folders", config.INFISICAL_URL)) + if err != nil { + return CreateFolderV1Response{}, fmt.Errorf("CallCreateFolderV1: Unable to complete api request [err=%s]", err) + } + + if response.IsError() { + return CreateFolderV1Response{}, fmt.Errorf("CallCreateFolderV1: Unsuccessful [response=%s]", response.String()) + } + + return folderResponse, nil +} + +func CallDeleteFolderV1(httpClient *resty.Client, request DeleteFolderV1Request) (DeleteFolderV1Response, error) { + var folderResponse DeleteFolderV1Response + + httpRequest := httpClient. + R(). + SetResult(&folderResponse). + SetHeader("User-Agent", USER_AGENT). + SetBody(request) + + response, err := httpRequest.Delete(fmt.Sprintf("%v/v1/folders/%v", config.INFISICAL_URL, request.FolderName)) + if err != nil { + return DeleteFolderV1Response{}, fmt.Errorf("CallDeleteFolderV1: Unable to complete api request [err=%s]", err) + } + + if response.IsError() { + return DeleteFolderV1Response{}, fmt.Errorf("CallDeleteFolderV1: Unsuccessful [response=%s]", response.String()) + } + + return folderResponse, nil +} + func CallCreateSecretsV3(httpClient *resty.Client, request CreateSecretV3Request) error { var secretsResponse GetEncryptedSecretsV3Response response, err := httpClient. diff --git a/cli/packages/api/model.go b/cli/packages/api/model.go index 14aa48b30..ceff88e71 100644 --- a/cli/packages/api/model.go +++ b/cli/packages/api/model.go @@ -278,6 +278,47 @@ type GetEncryptedSecretsV3Request struct { IncludeImport bool `json:"include_imports"` } +type GetFoldersV1Request struct { + Environment string `json:"environment"` + WorkspaceId string `json:"workspaceId"` + FoldersPath string `json:"foldersPath"` +} + +type GetFoldersV1Response struct { + Folders []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"folders"` +} + +type CreateFolderV1Request struct { + FolderName string `json:"folderName"` + WorkspaceId string `json:"workspaceId"` + Environment string `json:"environment"` + Directory string `json:"directory"` +} + +type CreateFolderV1Response struct { + Folder struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"folder"` +} + +type DeleteFolderV1Request struct { + FolderName string `json:"folderName"` + WorkspaceId string `json:"workspaceId"` + Environment string `json:"environment"` + Directory string `json:"directory"` +} + +type DeleteFolderV1Response struct { + Folders []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"folders"` +} + type EncryptedSecretV3 struct { ID string `json:"_id"` Version int `json:"version"` diff --git a/cli/packages/cmd/folder.go b/cli/packages/cmd/folder.go new file mode 100644 index 000000000..12e2206fa --- /dev/null +++ b/cli/packages/cmd/folder.go @@ -0,0 +1,164 @@ +package cmd + +import ( + "fmt" + + "github.com/Infisical/infisical-merge/packages/models" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/Infisical/infisical-merge/packages/visualize" + "github.com/posthog/posthog-go" + "github.com/spf13/cobra" +) + +var folderCmd = &cobra.Command{ + Use: "folders", + Short: "Create, delete, and list folders", + DisableFlagsInUseLine: true, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +var getCmd = &cobra.Command{ + Use: "get", + Short: "Get folders in a directory", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + util.RequireLocalWorkspaceFile() + util.RequireLogin() + }, + Run: func(cmd *cobra.Command, args []string) { + + environmentName, _ := cmd.Flags().GetString("env") + if !cmd.Flags().Changed("env") { + environmentFromWorkspace := util.GetEnvFromWorkspaceFile() + if environmentFromWorkspace != "" { + environmentName = environmentFromWorkspace + } + } + + infisicalToken, err := cmd.Flags().GetString("token") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + foldersPath, err := cmd.Flags().GetString("path") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + folders, err := util.GetAllFolders(models.GetAllFoldersParameters{Environment: environmentName, InfisicalToken: infisicalToken, FoldersPath: foldersPath}) + if err != nil { + util.HandleError(err, "Unable to get folders") + } + + visualize.PrintAllFoldersDetails(folders, foldersPath) + Telemetry.CaptureEvent("cli-command:folders get", posthog.NewProperties().Set("folderCount", len(folders)).Set("version", util.CLI_VERSION)) + }, +} + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create a folder", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + util.RequireLogin() + util.RequireLocalWorkspaceFile() + }, + Run: func(cmd *cobra.Command, args []string) { + environmentName, _ := cmd.Flags().GetString("env") + if !cmd.Flags().Changed("env") { + environmentFromWorkspace := util.GetEnvFromWorkspaceFile() + if environmentFromWorkspace != "" { + environmentName = environmentFromWorkspace + } + } + + folderPath, err := cmd.Flags().GetString("path") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + folderName, err := cmd.Flags().GetString("name") + if err != nil { + util.HandleError(err, "Unable to parse name flag") + } + + if folderName == "" { + util.HandleError(fmt.Errorf("Invalid folder name"), "Folder name cannot be empty") + } + + workspaceFile, err := util.GetWorkSpaceFromFile() + if err != nil { + util.HandleError(err, "Unable to get workspace file") + } + + params := models.CreateFolderParameters{ + FolderName: folderName, + WorkspaceId: workspaceFile.WorkspaceId, + Environment: environmentName, + FolderPath: folderPath, + } + + _, err = util.CreateFolder(params) + if err != nil { + util.HandleError(err, "Unable to create folder") + } + + util.PrintSuccessMessage(fmt.Sprintf("folder named `%s` created in path %s", folderName, folderPath)) + + Telemetry.CaptureEvent("cli-command:folders create", posthog.NewProperties().Set("version", util.CLI_VERSION)) + }, +} + +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a folder", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + util.RequireLogin() + util.RequireLocalWorkspaceFile() + }, + Run: func(cmd *cobra.Command, args []string) { + + environmentName, _ := cmd.Flags().GetString("env") + if !cmd.Flags().Changed("env") { + environmentFromWorkspace := util.GetEnvFromWorkspaceFile() + if environmentFromWorkspace != "" { + environmentName = environmentFromWorkspace + } + } + + folderPath, err := cmd.Flags().GetString("path") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + folderName, err := cmd.Flags().GetString("name") + if err != nil { + util.HandleError(err, "Unable to parse name flag") + } + + if folderName == "" { + util.HandleError(fmt.Errorf("Invalid folder name"), "Folder name cannot be empty") + } + + workspaceFile, err := util.GetWorkSpaceFromFile() + if err != nil { + util.HandleError(err, "Unable to get workspace file") + } + + params := models.DeleteFolderParameters{ + FolderName: folderName, + WorkspaceId: workspaceFile.WorkspaceId, + Environment: environmentName, + FolderPath: folderPath, + } + + _, err = util.DeleteFolder(params) + if err != nil { + util.HandleError(err, "Unable to delete folder") + } + + util.PrintSuccessMessage(fmt.Sprintf("folder named `%s` deleted in path %s", folderName, folderPath)) + + Telemetry.CaptureEvent("cli-command:folders delete", posthog.NewProperties().Set("version", util.CLI_VERSION)) + }, +} diff --git a/cli/packages/cmd/secrets.go b/cli/packages/cmd/secrets.go index 5c4d314f5..186776837 100644 --- a/cli/packages/cmd/secrets.go +++ b/cli/packages/cmd/secrets.go @@ -679,6 +679,28 @@ func init() { util.RequireLocalWorkspaceFile() } + // *** Folders sub command *** + folderCmd.PersistentFlags().String("env", "dev", "Used to select the environment name on which actions should be taken on") + + // Add getCmd, createCmd and deleteCmd flags here + getCmd.Flags().StringP("path", "p", "/", "The path from where folders should be fetched from") + getCmd.Flags().String("token", "", "Fetch folders using the infisical token") + folderCmd.AddCommand(getCmd) + + // Add createCmd flags here + createCmd.Flags().StringP("path", "p", "/", "Path to where the folder should be created") + createCmd.Flags().StringP("name", "n", "", "Name of the folder to be created") + folderCmd.AddCommand(createCmd) + + // Add deleteCmd flags here + deleteCmd.Flags().StringP("path", "p", "/", "Path to the folder to be deleted") + deleteCmd.Flags().StringP("name", "n", "", "Name of the folder to be deleted") + folderCmd.AddCommand(deleteCmd) + + secretsCmd.AddCommand(folderCmd) + + // ** End of folders sub command + secretsCmd.Flags().String("token", "", "Fetch secrets using the Infisical Token") secretsCmd.PersistentFlags().String("env", "dev", "Used to select the environment name on which actions should be taken on") secretsCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets") diff --git a/cli/packages/models/cli.go b/cli/packages/models/cli.go index 8e012a86f..165982a77 100644 --- a/cli/packages/models/cli.go +++ b/cli/packages/models/cli.go @@ -34,6 +34,11 @@ type SingleEnvironmentVariable struct { Comment string `json:"comment"` } +type SingleFolder struct { + ID string `json:"_id"` + Name string `json:"name"` +} + type Workspace struct { ID string `json:"_id"` Name string `json:"name"` @@ -63,3 +68,26 @@ type GetAllSecretsParameters struct { SecretsPath string IncludeImport bool } + +type GetAllFoldersParameters struct { + WorkspaceId string + Environment string + FoldersPath string + InfisicalToken string +} + +type CreateFolderParameters struct { + FolderName string + WorkspaceId string + Environment string + FolderPath string + InfisicalToken string +} + +type DeleteFolderParameters struct { + FolderName string + WorkspaceId string + Environment string + FolderPath string + InfisicalToken string +} diff --git a/cli/packages/util/folders.go b/cli/packages/util/folders.go new file mode 100644 index 000000000..7275653d9 --- /dev/null +++ b/cli/packages/util/folders.go @@ -0,0 +1,212 @@ +package util + +import ( + "fmt" + "os" + "strings" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/models" + "github.com/go-resty/resty/v2" + "github.com/rs/zerolog/log" +) + +func GetAllFolders(params models.GetAllFoldersParameters) ([]models.SingleFolder, error) { + + if params.InfisicalToken == "" { + params.InfisicalToken = os.Getenv(INFISICAL_TOKEN_NAME) + } + + var foldersToReturn []models.SingleFolder + var folderErr error + if params.InfisicalToken == "" { + + log.Debug().Msg("GetAllFolders: Trying to fetch folders using logged in details") + + loggedInUserDetails, err := GetCurrentLoggedInUserDetails() + if err != nil { + return nil, err + } + + if loggedInUserDetails.LoginExpired { + PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + } + + workspaceFile, err := GetWorkSpaceFromFile() + if err != nil { + return nil, err + } + + if params.WorkspaceId != "" { + workspaceFile.WorkspaceId = params.WorkspaceId + } + + folders, err := GetFoldersViaJTW(loggedInUserDetails.UserCredentials.JTWToken, workspaceFile.WorkspaceId, params.Environment, params.FoldersPath) + folderErr = err + foldersToReturn = folders + } else { + // get folders via service token + folders, err := GetFoldersViaServiceToken(params.InfisicalToken, params.WorkspaceId, params.Environment, params.FoldersPath) + folderErr = err + foldersToReturn = folders + } + return foldersToReturn, folderErr +} + +func GetFoldersViaJTW(JTWToken string, workspaceId string, environmentName string, foldersPath string) ([]models.SingleFolder, error) { + // set up resty client + httpClient := resty.New() + httpClient.SetAuthToken(JTWToken). + SetHeader("Accept", "application/json") + + getFoldersRequest := api.GetFoldersV1Request{ + WorkspaceId: workspaceId, + Environment: environmentName, + FoldersPath: foldersPath, + } + + apiResponse, err := api.CallGetFoldersV1(httpClient, getFoldersRequest) + if err != nil { + return nil, err + } + + var folders []models.SingleFolder + + for _, folder := range apiResponse.Folders { + folders = append(folders, models.SingleFolder{ + Name: folder.Name, + ID: folder.ID, + }) + } + + return folders, nil +} + +func GetFoldersViaServiceToken(fullServiceToken string, workspaceId string, environmentName string, foldersPath string) ([]models.SingleFolder, error) { + serviceTokenParts := strings.SplitN(fullServiceToken, ".", 4) + if len(serviceTokenParts) < 4 { + return nil, fmt.Errorf("invalid service token entered. Please double check your service token and try again") + } + + serviceToken := fmt.Sprintf("%v.%v.%v", serviceTokenParts[0], serviceTokenParts[1], serviceTokenParts[2]) + + httpClient := resty.New() + + httpClient.SetAuthToken(serviceToken). + SetHeader("Accept", "application/json") + + serviceTokenDetails, err := api.CallGetServiceTokenDetailsV2(httpClient) + if err != nil { + return nil, fmt.Errorf("unable to get service token details. [err=%v]", err) + } + + // if multiple scopes are there then user needs to specify which environment and folder path + if environmentName == "" { + if len(serviceTokenDetails.Scopes) != 1 { + return nil, fmt.Errorf("you need to provide the --env for multiple environment scoped token") + } else { + environmentName = serviceTokenDetails.Scopes[0].Environment + } + } + + getFoldersRequest := api.GetFoldersV1Request{ + WorkspaceId: serviceTokenDetails.Workspace, + Environment: environmentName, + FoldersPath: foldersPath, + } + + apiResponse, err := api.CallGetFoldersV1(httpClient, getFoldersRequest) + if err != nil { + return nil, fmt.Errorf("unable to get folders. [err=%v]", err) + } + + var folders []models.SingleFolder + + for _, folder := range apiResponse.Folders { + folders = append(folders, models.SingleFolder{ + Name: folder.Name, + ID: folder.ID, + }) + } + + return folders, nil +} + +// CreateFolder creates a folder in Infisical +func CreateFolder(params models.CreateFolderParameters) (models.SingleFolder, error) { + loggedInUserDetails, err := GetCurrentLoggedInUserDetails() + if err != nil { + return models.SingleFolder{}, err + } + + if loggedInUserDetails.LoginExpired { + PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + } + + // set up resty client + httpClient := resty.New() + httpClient. + SetAuthToken(loggedInUserDetails.UserCredentials.JTWToken). + SetHeader("Accept", "application/json"). + SetHeader("Content-Type", "application/json") + + createFolderRequest := api.CreateFolderV1Request{ + WorkspaceId: params.WorkspaceId, + Environment: params.Environment, + FolderName: params.FolderName, + Directory: params.FolderPath, + } + + apiResponse, err := api.CallCreateFolderV1(httpClient, createFolderRequest) + if err != nil { + return models.SingleFolder{}, err + } + + folder := apiResponse.Folder + + return models.SingleFolder{ + Name: folder.Name, + ID: folder.ID, + }, nil +} + +func DeleteFolder(params models.DeleteFolderParameters) ([]models.SingleFolder, error) { + loggedInUserDetails, err := GetCurrentLoggedInUserDetails() + if err != nil { + return nil, err + } + + if loggedInUserDetails.LoginExpired { + PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + } + + // set up resty client + httpClient := resty.New() + httpClient. + SetAuthToken(loggedInUserDetails.UserCredentials.JTWToken). + SetHeader("Accept", "application/json"). + SetHeader("Content-Type", "application/json") + + deleteFolderRequest := api.DeleteFolderV1Request{ + WorkspaceId: params.WorkspaceId, + Environment: params.Environment, + FolderName: params.FolderName, + Directory: params.FolderPath, + } + + apiResponse, err := api.CallDeleteFolderV1(httpClient, deleteFolderRequest) + if err != nil { + return nil, err + } + + var folders []models.SingleFolder + + for _, folder := range apiResponse.Folders { + folders = append(folders, models.SingleFolder{ + Name: folder.Name, + ID: folder.ID, + }) + } + + return folders, nil +} diff --git a/cli/packages/visualize/folders.go b/cli/packages/visualize/folders.go new file mode 100644 index 000000000..74f053c4f --- /dev/null +++ b/cli/packages/visualize/folders.go @@ -0,0 +1,14 @@ +package visualize + +import "github.com/Infisical/infisical-merge/packages/models" + +func PrintAllFoldersDetails(folders []models.SingleFolder, path string) { + rows := [][3]string{} + for _, folder := range folders { + rows = append(rows, [...]string{folder.Name, path, folder.ID}) + } + + headers := [...]string{"FOLDER NAME", "PATH", "FOLDER ID"} + + Table(headers, rows) +}