From 741bb2ade0bc0344dc55dfa0b08a22248ea075a1 Mon Sep 17 00:00:00 2001 From: Matheus Nogueira Date: Thu, 28 May 2026 18:39:23 -0300 Subject: [PATCH 1/4] allow `--tag` in `infisical secrets set` for both creation and update --- packages/api/api.go | 39 +++++++++++++++++++++ packages/api/model.go | 60 ++++++++++++++++++++++---------- packages/cmd/secrets.go | 10 ++++-- packages/models/cli.go | 2 +- packages/util/secrets.go | 75 +++++++++++++++++++++++++++++++++------- 5 files changed, 151 insertions(+), 35 deletions(-) diff --git a/packages/api/api.go b/packages/api/api.go index 30d6e0d7..4ca651e9 100644 --- a/packages/api/api.go +++ b/packages/api/api.go @@ -1364,3 +1364,42 @@ func CallGetCertificateRequest(httpClient *resty.Client, certificateRequestId st return &resBody, nil } + +func GetTagBySlug(httpClient *resty.Client, projectId string, tagSlug string) (SecretTag, error) { + var resBody GetTagBySlugResponse + response, err := httpClient. + R(). + SetResult(&resBody). + SetHeader("User-Agent", USER_AGENT). + Get(fmt.Sprintf("%v/v1/projects/%s/tags/slug/%s", config.INFISICAL_URL, url.PathEscape(projectId), url.PathEscape(tagSlug))) + + if err != nil { + return SecretTag{}, NewGenericRequestError("GetTagBySlug", err) + } + + if response.IsError() { + return SecretTag{}, NewAPIErrorWithResponse("GetTagBySlug", response, nil) + } + + return resBody.Tag, nil +} + +func CreateTag(httpClient *resty.Client, projectId string, request CreateTagRequest) (SecretTag, error) { + var resBody CreateTagResponse + response, err := httpClient. + R(). + SetResult(&resBody). + SetHeader("User-Agent", USER_AGENT). + SetBody(request). + Post(fmt.Sprintf("%v/v1/projects/%s/tags", config.INFISICAL_URL, url.PathEscape(projectId))) + + if err != nil { + return SecretTag{}, NewGenericRequestError("CreateTag", err) + } + + if response.IsError() { + return SecretTag{}, NewAPIErrorWithResponse("CreateTag", response, nil) + } + + return resBody.Tag, nil +} diff --git a/packages/api/model.go b/packages/api/model.go index 75d9bcf0..52735299 100644 --- a/packages/api/model.go +++ b/packages/api/model.go @@ -229,11 +229,12 @@ type Project struct { } type RawSecret struct { - SecretKey string `json:"secretKey,omitempty"` - SecretValue string `json:"secretValue,omitempty"` - Type string `json:"type,omitempty"` - SecretComment string `json:"secretComment,omitempty"` - ID string `json:"id,omitempty"` + SecretKey string `json:"secretKey,omitempty"` + SecretValue string `json:"secretValue,omitempty"` + Type string `json:"type,omitempty"` + SecretComment string `json:"secretComment,omitempty"` + ID string `json:"id,omitempty"` + TagIDs []string `json:"tagIds,omitempty"` } type GetEncryptedWorkspaceKeyRequest struct { @@ -487,14 +488,15 @@ type CreateSecretV3Request struct { } type CreateRawSecretV3Request struct { - SecretName string `json:"-"` - WorkspaceID string `json:"workspaceId"` - Type string `json:"type,omitempty"` - Environment string `json:"environment"` - SecretPath string `json:"secretPath,omitempty"` - SecretValue string `json:"secretValue"` - SecretComment string `json:"secretComment,omitempty"` - SkipMultilineEncoding bool `json:"skipMultilineEncoding,omitempty"` + SecretName string `json:"-"` + WorkspaceID string `json:"workspaceId"` + Type string `json:"type,omitempty"` + Environment string `json:"environment"` + SecretPath string `json:"secretPath,omitempty"` + SecretValue string `json:"secretValue"` + SecretComment string `json:"secretComment,omitempty"` + SkipMultilineEncoding bool `json:"skipMultilineEncoding,omitempty"` + TagIDs []string `json:"tagIds,omitempty"` } type DeleteSecretV3Request struct { @@ -516,12 +518,13 @@ type UpdateSecretByNameV3Request struct { } type UpdateRawSecretByNameV3Request struct { - SecretName string `json:"-"` - WorkspaceID string `json:"workspaceId"` - Environment string `json:"environment"` - SecretPath string `json:"secretPath,omitempty"` - SecretValue string `json:"secretValue"` - Type string `json:"type,omitempty"` + SecretName string `json:"-"` + WorkspaceID string `json:"workspaceId"` + Environment string `json:"environment"` + SecretPath string `json:"secretPath,omitempty"` + SecretValue string `json:"secretValue"` + Type string `json:"type,omitempty"` + TagIDs []string `json:"tagIds,omitempty"` } type GetSingleSecretByNameV3Request struct { @@ -1127,3 +1130,22 @@ type GetCertificateRequestResponse struct { CertificateID *string `json:"certificateId,omitempty"` ErrorMessage *string `json:"errorMessage,omitempty"` } + +type SecretTag struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` +} + +type GetTagBySlugResponse struct { + Tag SecretTag `json:"tag"` +} + +type CreateTagRequest struct { + Slug string `json:"slug"` + Color string `json:"color"` +} + +type CreateTagResponse struct { + Tag SecretTag `json:"tag"` +} diff --git a/packages/cmd/secrets.go b/packages/cmd/secrets.go index a9655a28..55123ba3 100644 --- a/packages/cmd/secrets.go +++ b/packages/cmd/secrets.go @@ -218,6 +218,11 @@ var secretsSetCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } + tags, err := cmd.Flags().GetStringArray("tag") + if err != nil { + util.HandleError(err, `Unable to parse "tag" flag`) + } + processedArgs := []string{} for _, arg := range args { splitKeyValue := strings.SplitN(arg, "=", 2) @@ -253,7 +258,7 @@ var secretsSetCmd = &cobra.Command{ util.PrintErrorMessageAndExit("When using service tokens or machine identities, you must set the --projectId flag") } - secretOperations, err = util.SetRawSecrets(args, secretType, environmentName, secretsPath, projectId, token, file) + secretOperations, err = util.SetRawSecrets(args, secretType, environmentName, secretsPath, projectId, token, file, tags) if err != nil { util.HandleError(err, "Unable to set secrets") @@ -280,7 +285,7 @@ var secretsSetCmd = &cobra.Command{ secretOperations, err = util.SetRawSecrets(processedArgs, secretType, environmentName, secretsPath, projectId, &models.TokenDetails{ Type: "", Token: loggedInUserDetails.UserCredentials.JTWToken, - }, file) + }, file, tags) if err != nil { util.HandleError(err, "Unable to set secrets") @@ -835,6 +840,7 @@ func init() { secretsSetCmd.Flags().String("path", "/", "set secrets within a folder path") secretsSetCmd.Flags().String("type", util.SECRET_TYPE_SHARED, "the type of secret to create: personal or shared") secretsSetCmd.Flags().String("file", "", "Load secrets from the specified file. File format: .env or YAML (comments: # or //). This option is mutually exclusive with command-line secrets arguments.") + secretsSetCmd.Flags().StringArray("tag", []string{}, "Tags to associate with the secret. Can be specified multiple times (e.g. --tag backend --tag production). When updating an existing secret, the provided tags will replace any existing tags") util.AddOutputFlagsToCmd(secretsSetCmd, "The output to format the secrets in.") secretsDeleteCmd.Flags().String("type", "personal", "the type of secret to delete: personal or shared (default: personal)") diff --git a/packages/models/cli.go b/packages/models/cli.go index 0a427f73..ea874088 100644 --- a/packages/models/cli.go +++ b/packages/models/cli.go @@ -32,7 +32,7 @@ type LoggedInUser struct { } type Tag struct { - ID string `json:"_id"` + ID string `json:"id"` Name string `json:"name"` Slug string `json:"slug"` Color string `json:"color"` diff --git a/packages/util/secrets.go b/packages/util/secrets.go index caf02b5f..d0016027 100644 --- a/packages/util/secrets.go +++ b/packages/util/secrets.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "net/http" "os" "strings" "unicode" @@ -13,6 +14,7 @@ import ( "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" "github.com/rs/zerolog/log" "github.com/zalando/go-keyring" "gopkg.in/yaml.v3" @@ -377,16 +379,6 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo return secretsToReturn, errorToReturn } -func getSecretsByKeys(secrets []models.SingleEnvironmentVariable) map[string]models.SingleEnvironmentVariable { - secretMapByName := make(map[string]models.SingleEnvironmentVariable, len(secrets)) - - for _, secret := range secrets { - secretMapByName[secret.Key] = secret - } - - return secretMapByName -} - func OverrideSecrets(secrets []models.SingleEnvironmentVariable, secretType string) []models.SingleEnvironmentVariable { personalSecrets := make(map[string]models.SingleEnvironmentVariable) sharedSecrets := make(map[string]models.SingleEnvironmentVariable) @@ -632,7 +624,7 @@ func validateSecretKey(key string) error { return nil } -func SetRawSecrets(secretArgs []string, secretType string, environmentName string, secretsPath string, projectId string, tokenDetails *models.TokenDetails, file string) ([]models.SecretSetOperation, error) { +func SetRawSecrets(secretArgs []string, secretType string, environmentName string, secretsPath string, projectId string, tokenDetails *models.TokenDetails, file string, tagSlugs []string) ([]models.SecretSetOperation, error) { if file != "" { content, err := os.ReadFile(file) if err != nil { @@ -689,6 +681,15 @@ func SetRawSecrets(secretArgs []string, secretType string, environmentName strin return nil, fmt.Errorf("unable to retrieve secrets [err=%v]", err) } + var cliProvidedTagIds []string + for _, slug := range tagSlugs { + tag, err := GetOrCreateTag(httpClient, projectId, slug) + if err != nil { + return nil, err + } + cliProvidedTagIds = append(cliProvidedTagIds, tag.ID) + } + secretsToCreate := []api.RawSecret{} secretsToModify := []api.RawSecret{} secretOperations := []models.SecretSetOperation{} @@ -734,15 +735,36 @@ func SetRawSecrets(secretArgs []string, secretType string, environmentName strin SecretValue: value, SecretKey: key, Type: existingSecret.Type, + TagIDs: cliProvidedTagIds, + } + + existingTags := make(map[string]struct{}, 0) + for _, tag := range existingSecret.Tags { + existingTags[tag.ID] = struct{}{} + } + + newTagProvided := false + for _, cliProvidedTagId := range cliProvidedTagIds { + if _, found := existingTags[cliProvidedTagId]; !found { + newTagProvided = true + } } + tagsChanged := newTagProvided || len(existingTags) != len(cliProvidedTagIds) + // Only add to modifications if the value is different - if existingSecret.Value != value { + if existingSecret.Value != value || tagsChanged { secretsToModify = append(secretsToModify, encryptedSecretDetails) + message := "SECRET VALUE MODIFIED" + if existingSecret.Value == value { + // We only display SECRET TAGS UPDATED if the value has not changed + // otherwise value changes should take precedence + message = "SECRET TAGS MODIFIED" + } secretOperations = append(secretOperations, models.SecretSetOperation{ SecretKey: key, SecretValue: value, - SecretOperation: "SECRET VALUE MODIFIED", + SecretOperation: message, }) } else { // Current value is same as existing so no change @@ -759,6 +781,7 @@ func SetRawSecrets(secretArgs []string, secretType string, environmentName strin SecretKey: key, SecretValue: value, Type: secretType, + TagIDs: cliProvidedTagIds, } secretsToCreate = append(secretsToCreate, encryptedSecretDetails) secretOperations = append(secretOperations, models.SecretSetOperation{ @@ -777,6 +800,7 @@ func SetRawSecrets(secretArgs []string, secretType string, environmentName strin SecretPath: secretsPath, WorkspaceID: projectId, Environment: environmentName, + TagIDs: secret.TagIDs, } err = api.CallCreateRawSecretsV3(httpClient, createSecretRequest) @@ -793,6 +817,7 @@ func SetRawSecrets(secretArgs []string, secretType string, environmentName strin WorkspaceID: projectId, Environment: environmentName, Type: secret.Type, + TagIDs: secret.TagIDs, } err = api.CallUpdateRawSecretsV3(httpClient, updateSecretRequest) @@ -804,3 +829,27 @@ func SetRawSecrets(secretArgs []string, secretType string, environmentName strin return secretOperations, nil } + +func GetOrCreateTag(client *resty.Client, projectId string, slug string) (api.SecretTag, error) { + tag, err := api.GetTagBySlug(client, projectId, slug) + if err == nil { + return tag, nil + } + + var apiErr *api.APIError + if errors.As(err, &apiErr) { + if apiErr.StatusCode == http.StatusNotFound { + newTag, createErr := api.CreateTag(client, projectId, api.CreateTagRequest{ + Slug: slug, + Color: "", + }) + if createErr != nil { + return api.SecretTag{}, fmt.Errorf("could not create tag %q: [err=%v]", slug, createErr) + } + + return newTag, nil + } + } + + return api.SecretTag{}, fmt.Errorf("unable to resolve tag slug %q [err=%v]", slug, err) +} From c90ccf99e8dc90867ac1100c1b32e027c5f25000 Mon Sep 17 00:00:00 2001 From: Matheus Nogueira Date: Thu, 28 May 2026 18:49:20 -0300 Subject: [PATCH 2/4] fix tag changed condition --- packages/util/secrets.go | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/util/secrets.go b/packages/util/secrets.go index d0016027..eee383e2 100644 --- a/packages/util/secrets.go +++ b/packages/util/secrets.go @@ -738,20 +738,21 @@ func SetRawSecrets(secretArgs []string, secretType string, environmentName strin TagIDs: cliProvidedTagIds, } - existingTags := make(map[string]struct{}, 0) + existingTagIds := make(map[string]struct{}, len(existingSecret.Tags)) for _, tag := range existingSecret.Tags { - existingTags[tag.ID] = struct{}{} + existingTagIds[tag.ID] = struct{}{} } - newTagProvided := false - for _, cliProvidedTagId := range cliProvidedTagIds { - if _, found := existingTags[cliProvidedTagId]; !found { - newTagProvided = true + tagsChanged := len(cliProvidedTagIds) > 0 && len(cliProvidedTagIds) != len(existingTagIds) + if !tagsChanged { + for _, id := range cliProvidedTagIds { + if _, found := existingTagIds[id]; !found { + tagsChanged = true + break + } } } - tagsChanged := newTagProvided || len(existingTags) != len(cliProvidedTagIds) - // Only add to modifications if the value is different if existingSecret.Value != value || tagsChanged { secretsToModify = append(secretsToModify, encryptedSecretDetails) From 16ad7c74982156676fba1e800e27a01c04b29320 Mon Sep 17 00:00:00 2001 From: Matheus Nogueira Date: Thu, 28 May 2026 18:57:53 -0300 Subject: [PATCH 3/4] dedupe tag slugs --- packages/util/secrets.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/util/secrets.go b/packages/util/secrets.go index eee383e2..5b1a884e 100644 --- a/packages/util/secrets.go +++ b/packages/util/secrets.go @@ -681,8 +681,13 @@ func SetRawSecrets(secretArgs []string, secretType string, environmentName strin return nil, fmt.Errorf("unable to retrieve secrets [err=%v]", err) } + uniqueSlugs := make(map[string]struct{}, len(tagSlugs)) var cliProvidedTagIds []string for _, slug := range tagSlugs { + if _, seen := uniqueSlugs[slug]; seen { + continue + } + uniqueSlugs[slug] = struct{}{} tag, err := GetOrCreateTag(httpClient, projectId, slug) if err != nil { return nil, err From 9898c7bf79de1e8c8cc1dc8a696055baa1b4f0a9 Mon Sep 17 00:00:00 2001 From: Matheus Nogueira Date: Wed, 3 Jun 2026 16:51:14 -0300 Subject: [PATCH 4/4] use %s --- packages/api/api.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/api.go b/packages/api/api.go index 4ca651e9..6e5e46b3 100644 --- a/packages/api/api.go +++ b/packages/api/api.go @@ -1391,7 +1391,7 @@ func CreateTag(httpClient *resty.Client, projectId string, request CreateTagRequ SetResult(&resBody). SetHeader("User-Agent", USER_AGENT). SetBody(request). - Post(fmt.Sprintf("%v/v1/projects/%s/tags", config.INFISICAL_URL, url.PathEscape(projectId))) + Post(fmt.Sprintf("%s/v1/projects/%s/tags", config.INFISICAL_URL, url.PathEscape(projectId))) if err != nil { return SecretTag{}, NewGenericRequestError("CreateTag", err)