Skip to content

Commit

Permalink
feat: Add tag read, write, and delete commands (#507)
Browse files Browse the repository at this point in the history
The new commands are for reading, writing, and deleting tags on the
underlying resources representing secrets.

For now, the commands are only implemented for SSM parameters.
  • Loading branch information
bhavanki authored Jun 7, 2024
1 parent 7369ada commit 37a70e0
Show file tree
Hide file tree
Showing 14 changed files with 768 additions and 13 deletions.
12 changes: 12 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ var (
validKeyFormat = regexp.MustCompile(`^[\w\-\.]+$`)
validServicePathFormat = regexp.MustCompile(`^[\w\-\.]+(\/[\w\-\.]+)*$`)
validServicePathFormatWithLabel = regexp.MustCompile(`^[\w\-\.]+((\/[\w\-\.]+)+(\:[\w\-\.]+)*)?$`)
validTagKeyFormat = regexp.MustCompile(`^[A-Za-z0-9 +\-=\._:/@]{1,128}$`)
validTagValueFormat = regexp.MustCompile(`^[A-Za-z0-9 +\-=\._:/@]{1,256}$`)

verbose bool
numRetries int
Expand Down Expand Up @@ -134,6 +136,16 @@ func validateKey(key string) error {
return nil
}

func validateTag(key string, value string) error {
if !validTagKeyFormat.MatchString(key) {
return fmt.Errorf("Failed to validate tag key '%s'. Only 128 alphanumeric, space, and characters +-=._:/@ are allowed for tag keys", key)
}
if !validTagValueFormat.MatchString(value) {
return fmt.Errorf("Failed to validate tag value '%s'. Only 256 alphanumeric, space, and characters +-=._:/@ are allowed for tag values", value)
}
return nil
}

func getSecretStore(ctx context.Context) (store.Store, error) {
rootPflags := RootCmd.PersistentFlags()
if backendEnvVarValue := os.Getenv(BackendEnvVar); !rootPflags.Changed("backend") && backendEnvVarValue != "" {
Expand Down
16 changes: 11 additions & 5 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@ import (
"github.com/stretchr/testify/assert"
)

func TestValidations(t *testing.T) {

// Test Key formats
func TestValidateKey(t *testing.T) {
validKeyFormat := []string{
"foo",
"foo.bar",
Expand All @@ -23,7 +21,9 @@ func TestValidations(t *testing.T) {
assert.Nil(t, result)
})
}
}

func TestValidateKey_Invalid(t *testing.T) {
invalidKeyFormat := []string{
"/foo",
"foo//bar",
Expand All @@ -36,8 +36,9 @@ func TestValidations(t *testing.T) {
assert.Error(t, result)
})
}
}

// Test Service format with PATH
func TestValidateService_Path(t *testing.T) {
validServicePathFormat := []string{
"foo",
"foo.",
Expand All @@ -58,7 +59,9 @@ func TestValidations(t *testing.T) {
assert.Nil(t, result)
})
}
}

func TestValidateService_Path_Invalid(t *testing.T) {
invalidServicePathFormat := []string{
"foo/",
"/foo",
Expand All @@ -71,8 +74,9 @@ func TestValidations(t *testing.T) {
assert.Error(t, result)
})
}
}

// Test Service format with PATH and Label
func TestValidateService_PathLabel(t *testing.T) {
validServicePathFormatWithLabel := []string{
"foo",
"foo/bar:-current-",
Expand All @@ -90,7 +94,9 @@ func TestValidations(t *testing.T) {
assert.Nil(t, result)
})
}
}

func TestValidateService_PathLabel_Invalid(t *testing.T) {
invalidServicePathFormatWithLabel := []string{
"foo:current$",
"foo.:",
Expand Down
74 changes: 74 additions & 0 deletions cmd/tag-delete.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package cmd

import (
"fmt"

analytics "github.com/segmentio/analytics-go/v3"
"github.com/segmentio/chamber/v2/store"
"github.com/segmentio/chamber/v2/utils"
"github.com/spf13/cobra"
)

var (
// tagWriteCmd represents the tag read command
tagDeleteCmd = &cobra.Command{
Use: "delete <service> <key> <tag key>...",
Short: "Delete tags for a specific secret",
Args: cobra.MinimumNArgs(3),
RunE: tagDelete,
}
)

func init() {
tagCmd.AddCommand(tagDeleteCmd)
}

func tagDelete(cmd *cobra.Command, args []string) error {
service := utils.NormalizeService(args[0])
if err := validateService(service); err != nil {
return fmt.Errorf("Failed to validate service: %w", err)
}

key := utils.NormalizeKey(args[1])
if err := validateKey(key); err != nil {
return fmt.Errorf("Failed to validate key: %w", err)
}

tagKeys := make([]string, len(args)-2)
for i, tagArg := range args[2:] {
if err := validateTag(tagArg, "dummy"); err != nil {
return fmt.Errorf("Failed to validate tag key %s: %w", tagArg, err)
}
tagKeys[i] = tagArg
}

if analyticsEnabled && analyticsClient != nil {
_ = analyticsClient.Enqueue(analytics.Track{
UserId: username,
Event: "Ran Command",
Properties: analytics.NewProperties().
Set("command", "tag delete").
Set("chamber-version", chamberVersion).
Set("service", service).
Set("key", key).
Set("backend", backend),
})
}

secretStore, err := getSecretStore(cmd.Context())
if err != nil {
return fmt.Errorf("Failed to get secret store: %w", err)
}

secretId := store.SecretId{
Service: service,
Key: key,
}

err = secretStore.DeleteTags(cmd.Context(), secretId, tagKeys)
if err != nil {
return fmt.Errorf("Failed to delete tags: %w", err)
}

return nil
}
79 changes: 79 additions & 0 deletions cmd/tag-read.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package cmd

import (
"fmt"
"os"
"text/tabwriter"

analytics "github.com/segmentio/analytics-go/v3"
"github.com/segmentio/chamber/v2/store"
"github.com/segmentio/chamber/v2/utils"
"github.com/spf13/cobra"
)

var (
// tagReadCmd represents the tag read command
tagReadCmd = &cobra.Command{
Use: "read <service> <key>",
Short: "Read tags for a specific secret",
Args: cobra.ExactArgs(2),
RunE: tagRead,
}
)

func init() {
tagCmd.AddCommand(tagReadCmd)
}

func tagRead(cmd *cobra.Command, args []string) error {
service := utils.NormalizeService(args[0])
if err := validateService(service); err != nil {
return fmt.Errorf("Failed to validate service: %w", err)
}

key := utils.NormalizeKey(args[1])
if err := validateKey(key); err != nil {
return fmt.Errorf("Failed to validate key: %w", err)
}

if analyticsEnabled && analyticsClient != nil {
_ = analyticsClient.Enqueue(analytics.Track{
UserId: username,
Event: "Ran Command",
Properties: analytics.NewProperties().
Set("command", "tag read").
Set("chamber-version", chamberVersion).
Set("service", service).
Set("key", key).
Set("backend", backend),
})
}

secretStore, err := getSecretStore(cmd.Context())
if err != nil {
return fmt.Errorf("Failed to get secret store: %w", err)
}

secretId := store.SecretId{
Service: service,
Key: key,
}

tags, err := secretStore.ReadTags(cmd.Context(), secretId)
if err != nil {
return fmt.Errorf("Failed to read tags: %w", err)
}

if quiet {
fmt.Fprintf(os.Stdout, "%s\n", tags)
return nil
}

w := tabwriter.NewWriter(os.Stdout, 0, 8, 2, '\t', 0)
fmt.Fprintln(w, "Key\tValue")
for k, v := range tags {
fmt.Fprintf(w, "%s\t%s\n", k, v)
}
w.Flush()
return nil
}
95 changes: 95 additions & 0 deletions cmd/tag-write.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package cmd

import (
"fmt"
"os"
"strings"
"text/tabwriter"

analytics "github.com/segmentio/analytics-go/v3"
"github.com/segmentio/chamber/v2/store"
"github.com/segmentio/chamber/v2/utils"
"github.com/spf13/cobra"
)

var (
deleteOtherTags bool

// tagWriteCmd represents the tag read command
tagWriteCmd = &cobra.Command{
Use: "write <service> <key> <tag>...",
Short: "Write tags for a specific secret",
Args: cobra.MinimumNArgs(3),
RunE: tagWrite,
}
)

func init() {
tagWriteCmd.Flags().BoolVar(&deleteOtherTags, "delete-other-tags", false, "Delete tags not specified in the command")
tagCmd.AddCommand(tagWriteCmd)
}

func tagWrite(cmd *cobra.Command, args []string) error {
service := utils.NormalizeService(args[0])
if err := validateService(service); err != nil {
return fmt.Errorf("Failed to validate service: %w", err)
}

key := utils.NormalizeKey(args[1])
if err := validateKey(key); err != nil {
return fmt.Errorf("Failed to validate key: %w", err)
}

tags := make(map[string]string, len(args)-2)
for _, tagArg := range args[2:] {
tagKey, tagValue, found := strings.Cut(tagArg, "=")
if !found {
return fmt.Errorf("Failed to parse tag %s: tag must be in the form key=value", tagArg)
}
if err := validateTag(tagKey, tagValue); err != nil {
return fmt.Errorf("Failed to validate tag with key %s: %w", tagKey, err)
}
tags[tagKey] = tagValue
}

if analyticsEnabled && analyticsClient != nil {
_ = analyticsClient.Enqueue(analytics.Track{
UserId: username,
Event: "Ran Command",
Properties: analytics.NewProperties().
Set("command", "tag write").
Set("chamber-version", chamberVersion).
Set("service", service).
Set("key", key).
Set("backend", backend),
})
}

secretStore, err := getSecretStore(cmd.Context())
if err != nil {
return fmt.Errorf("Failed to get secret store: %w", err)
}

secretId := store.SecretId{
Service: service,
Key: key,
}

err = secretStore.WriteTags(cmd.Context(), secretId, tags, deleteOtherTags)
if err != nil {
return fmt.Errorf("Failed to write tags: %w", err)
}

if quiet {
fmt.Fprintf(os.Stdout, "%s\n", tags)
return nil
}

w := tabwriter.NewWriter(os.Stdout, 0, 8, 2, '\t', 0)
fmt.Fprintln(w, "Key\tValue")
for k, v := range tags {
fmt.Fprintf(w, "%s\t%s\n", k, v)
}
w.Flush()
return nil
}
17 changes: 17 additions & 0 deletions cmd/tag.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package cmd

import (
"github.com/spf13/cobra"
)

var (
// tagCmd represents the tag command
tagCmd = &cobra.Command{
Use: "tag <subcommand> ...",
Short: "work with tags on secrets",
}
)

func init() {
RootCmd.AddCommand(tagCmd)
}
3 changes: 3 additions & 0 deletions store/awsapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,15 @@ type apiS3 interface {
}

type apiSSM interface {
AddTagsToResource(ctx context.Context, params *ssm.AddTagsToResourceInput, optFns ...func(*ssm.Options)) (*ssm.AddTagsToResourceOutput, error)
DeleteParameter(ctx context.Context, params *ssm.DeleteParameterInput, optFns ...func(*ssm.Options)) (*ssm.DeleteParameterOutput, error)
DescribeParameters(ctx context.Context, params *ssm.DescribeParametersInput, optFns ...func(*ssm.Options)) (*ssm.DescribeParametersOutput, error)
GetParameterHistory(ctx context.Context, params *ssm.GetParameterHistoryInput, optFns ...func(*ssm.Options)) (*ssm.GetParameterHistoryOutput, error)
GetParameters(ctx context.Context, params *ssm.GetParametersInput, optFns ...func(*ssm.Options)) (*ssm.GetParametersOutput, error)
GetParametersByPath(ctx context.Context, params *ssm.GetParametersByPathInput, optFns ...func(*ssm.Options)) (*ssm.GetParametersByPathOutput, error)
ListTagsForResource(ctx context.Context, params *ssm.ListTagsForResourceInput, optFns ...func(*ssm.Options)) (*ssm.ListTagsForResourceOutput, error)
PutParameter(ctx context.Context, params *ssm.PutParameterInput, optFns ...func(*ssm.Options)) (*ssm.PutParameterOutput, error)
RemoveTagsFromResource(ctx context.Context, params *ssm.RemoveTagsFromResourceInput, optFns ...func(*ssm.Options)) (*ssm.RemoveTagsFromResourceOutput, error)
}

type apiSTS interface {
Expand Down
Loading

0 comments on commit 37a70e0

Please sign in to comment.