diff --git a/cli/packages/api/api.go b/cli/packages/api/api.go index 1be58985c..d9278d08c 100644 --- a/cli/packages/api/api.go +++ b/cli/packages/api/api.go @@ -260,6 +260,82 @@ 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=%s]", err) + } + + if response.IsError() { + return GetFoldersV1Response{}, fmt.Errorf("CallGetFoldersV1: Unsuccessful response. Please make sure your secret path, workspace and environment name are all correct [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. Please make sure your secret path, workspace and environment name are all correct [response=%s]", response) + } + + return folderResponse, nil +} + +func CallDeleteFolderV1(httpClient *resty.Client, request DeleteFolderV1Request) (DeleteFolderV1Response, error) { + var folderResponse DeleteFolderV1Response + + type deleteFolderRequest struct { + WorkspaceId string `json:"workspaceId"` + Environment string `json:"environment"` + Directory string `json:"folderPath"` + } + + body := deleteFolderRequest{ + WorkspaceId: request.WorkspaceId, + Environment: request.Environment, + Directory: request.Directory, + } + + httpRequest := httpClient. + R(). + SetResult(&folderResponse). + SetHeader("User-Agent", USER_AGENT). + SetBody(body) + + 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. Please make sure your secret path, workspace and environment name are all correct [response=%s]", response) + } + + 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 1531dce36..e8cc03b3a 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..681fd731c --- /dev/null +++ b/cli/packages/cmd/folder.go @@ -0,0 +1,191 @@ +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", + DisableFlagsInUseLine: true, + 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) + 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", + DisableFlagsInUseLine: true, + Run: func(cmd *cobra.Command, args []string) { + util.RequireLocalWorkspaceFile() + 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") + } + + 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, + InfisicalToken: infisicalToken} + + folder, err := util.CreateFolder(params) + if err != nil { + util.HandleError(err, "Unable to create folder") + } + + folders := []models.SingleFolder{folder} + visualize.PrintAllFoldersDetails(folders) + Telemetry.CaptureEvent("cli-command:folders create", posthog.NewProperties().Set("version", util.CLI_VERSION)) + }, +} + +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a folder", + Run: func(cmd *cobra.Command, args []string) { + util.RequireLocalWorkspaceFile() + + 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") + } + + 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, + InfisicalToken: infisicalToken, + } + + folders, err := util.DeleteFolder(params) + if err != nil { + util.HandleError(err, "Unable to delete folder") + } + + visualize.PrintAllFoldersDetails(folders) + Telemetry.CaptureEvent("cli-command:folders delete", posthog.NewProperties().Set("version", util.CLI_VERSION)) + }, +} + +func init() { + + 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", "/", "Path to the directory whose folders will be fetched") + getCmd.Flags().StringP("token", "t", "", "Fetch folders using the infisical token") + folderCmd.AddCommand(getCmd) + + // Add createCmd flags here + createCmd.Flags().StringP("path", "p", "/", "Path to the directory where the folder will be created") + createCmd.Flags().StringP("token", "t", "", "Create folder using the infisical token") + 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 directory where the folder will be deleted") + deleteCmd.Flags().StringP("token", "t", "", "Delete folder using the infisical token") + deleteCmd.Flags().StringP("name", "n", "", "Name of the folder to be deleted") + folderCmd.AddCommand(deleteCmd) + + rootCmd.AddCommand(folderCmd) + +} 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..67ed66fa2 --- /dev/null +++ b/cli/packages/util/folders.go @@ -0,0 +1,217 @@ +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) + } + + isConnected := CheckIsConnectedToInternet() + var foldersToReturn []models.SingleFolder + var folderErr error + if params.InfisicalToken == "" { + if isConnected { + log.Debug().Msg("GetAllFolders: Connected to internet, checking logged in creds") + RequireLocalWorkspaceFile() + RequireLogin() + } + + log.Debug().Msg("GetAllEnvironmentVariables: Trying to fetch secrets 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, + } + + 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..9c23763c1 --- /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) { + rows := [][3]string{} + for _, folder := range folders { + rows = append(rows, [...]string{folder.ID, folder.Name, ""}) + } + + headers := [...]string{"FOLDER ID", "FOLDER NAME", "VERSION"} + + Table(headers, rows) +}