diff --git a/temporalcloudcli/commands.gen.go b/temporalcloudcli/commands.gen.go index 6e318a4..7bb14ff 100644 --- a/temporalcloudcli/commands.gen.go +++ b/temporalcloudcli/commands.gen.go @@ -1890,6 +1890,7 @@ func NewCloudNamespaceCommand(cctx *CommandContext, parent *CloudCommand) *Cloud s.Command.AddCommand(&NewCloudNamespaceDeleteCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNamespaceDescriptionCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNamespaceEditCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudNamespaceEncryptionValidationCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNamespaceExportCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNamespaceFairnessCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNamespaceGetCommand(cctx, &s).Command) @@ -2398,17 +2399,22 @@ type CloudNamespaceCreateCommand struct { CodecServerOptions CaCertificateOptions CertificateFilterOptions - Name string - Region []string - RetentionDays int - ApiKeyAuthEnabled bool - MtlsAuthEnabled bool - EnableDeleteProtection bool - EnableTaskQueueFairness bool - SearchAttribute []string - ConnectionRuleId []string - ProjectId string - Description string + Name string + Region []string + RetentionDays int + ApiKeyAuthEnabled bool + MtlsAuthEnabled bool + EnableDeleteProtection bool + EnableTaskQueueFairness bool + SearchAttribute []string + ConnectionRuleId []string + ProjectId string + Description string + EncryptionValidationMode string + EncryptionValidationMetadataKey string + EncryptionValidationMetadataValue []string + EncryptionValidationInspectHeader bool + EncryptionValidationInspectFailure bool } func NewCloudNamespaceCreateCommand(cctx *CommandContext, parent *CloudNamespaceCommand) *CloudNamespaceCreateCommand { @@ -2436,6 +2442,11 @@ func NewCloudNamespaceCreateCommand(cctx *CommandContext, parent *CloudNamespace s.Command.Flags().StringArrayVar(&s.ConnectionRuleId, "connection-rule-id", nil, "Private connectivity rule ID. Repeat to specify multiple.") s.Command.Flags().StringVar(&s.ProjectId, "project-id", "", "The ID of the project to create the namespace in. If omitted, the namespace is created in the account's default project.") s.Command.Flags().StringVar(&s.Description, "description", "", "The description is a human-readable description of the namespace. Must be at most 255 printable ASCII characters plus whitespace. Optional, default is empty.") + s.Command.Flags().StringVar(&s.EncryptionValidationMode, "encryption-validation-mode", "", "[Experimental] Payload encryption validation mode. Valid values: disabled, warn, deny. When any encryption-validation flag is set, this field is required.") + s.Command.Flags().StringVar(&s.EncryptionValidationMetadataKey, "encryption-validation-metadata-key", "", "[Experimental] Payload metadata key used to identify encrypted payloads. If omitted, the server default (encoding) applies.") + s.Command.Flags().StringArrayVar(&s.EncryptionValidationMetadataValue, "encryption-validation-metadata-value", nil, "[Experimental] Payload metadata value used to identify encrypted payloads. Repeat to add additional values. If omitted, the server default (binary/encrypted) applies.") + s.Command.Flags().BoolVar(&s.EncryptionValidationInspectHeader, "encryption-validation-inspect-header", false, "[Experimental] Inspect workflow headers for encryption validation. Default is false.") + s.Command.Flags().BoolVar(&s.EncryptionValidationInspectFailure, "encryption-validation-inspect-failure", false, "[Experimental] Inspect Failure payloads for encryption validation. Default is false.") s.ClientOptions.BuildFlags(s.Command.Flags()) s.AsyncOperationOptions.BuildFlags(s.Command.Flags()) s.CodecServerOptions.BuildFlags(s.Command.Flags()) @@ -2609,6 +2620,165 @@ func NewCloudNamespaceEditCommand(cctx *CommandContext, parent *CloudNamespaceCo return &s } +type CloudNamespaceEncryptionValidationCommand struct { + Parent *CloudNamespaceCommand + Command cobra.Command +} + +func NewCloudNamespaceEncryptionValidationCommand(cctx *CommandContext, parent *CloudNamespaceCommand) *CloudNamespaceEncryptionValidationCommand { + var s CloudNamespaceEncryptionValidationCommand + s.Parent = parent + s.Command.Use = "encryption-validation" + s.Command.Short = "[Experimental] Manage namespace payload encryption validation settings" + s.Command.Long = "Commands for managing payload encryption validation configuration of Temporal Cloud namespaces." + s.Command.Args = cobra.NoArgs + s.Command.AddCommand(&NewCloudNamespaceEncryptionValidationDisableCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudNamespaceEncryptionValidationEnableCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudNamespaceEncryptionValidationGetCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudNamespaceEncryptionValidationSetCommand(cctx, &s).Command) + return &s +} + +type CloudNamespaceEncryptionValidationDisableCommand struct { + Parent *CloudNamespaceEncryptionValidationCommand + Command cobra.Command + ClientOptions + NamespaceOptions + AsyncOperationOptions + ResourceVersionOptions +} + +func NewCloudNamespaceEncryptionValidationDisableCommand(cctx *CommandContext, parent *CloudNamespaceEncryptionValidationCommand) *CloudNamespaceEncryptionValidationDisableCommand { + var s CloudNamespaceEncryptionValidationDisableCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "disable [flags]" + s.Command.Short = "[Experimental] Disable payload encryption validation for a namespace" + if hasHighlighting { + s.Command.Long = "Disable payload encryption validation for a Temporal Cloud namespace.\nExisting metadata and inspect settings are kept. If validation is not\nconfigured or is already disabled, nothing is changed.\n\nExample:\n\n\x1b[1mtemporal cloud namespace encryption-validation disable --namespace my-namespace.my-account\x1b[0m" + } else { + s.Command.Long = "Disable payload encryption validation for a Temporal Cloud namespace.\nExisting metadata and inspect settings are kept. If validation is not\nconfigured or is already disabled, nothing is changed.\n\nExample:\n\n```\ntemporal cloud namespace encryption-validation disable --namespace my-namespace.my-account\n```" + } + s.Command.Args = cobra.NoArgs + s.ClientOptions.BuildFlags(s.Command.Flags()) + s.NamespaceOptions.BuildFlags(s.Command.Flags()) + s.AsyncOperationOptions.BuildFlags(s.Command.Flags()) + s.ResourceVersionOptions.BuildFlags(s.Command.Flags()) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + +type CloudNamespaceEncryptionValidationEnableCommand struct { + Parent *CloudNamespaceEncryptionValidationCommand + Command cobra.Command + ClientOptions + NamespaceOptions + AsyncOperationOptions + ResourceVersionOptions + Deny bool +} + +func NewCloudNamespaceEncryptionValidationEnableCommand(cctx *CommandContext, parent *CloudNamespaceEncryptionValidationCommand) *CloudNamespaceEncryptionValidationEnableCommand { + var s CloudNamespaceEncryptionValidationEnableCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "enable [flags]" + s.Command.Short = "[Experimental] Enable payload encryption validation for a namespace" + if hasHighlighting { + s.Command.Long = "Enable payload encryption validation for a Temporal Cloud namespace.\nIf validation has not been configured, it is enabled in warn mode with\nno metadata key or values, so the defaults apply\n(metadata-key=encoding, metadata-values=[binary/encrypted]).\nIf validation is disabled, the existing configuration is re-enabled.\nUse --deny to enable in deny mode; this also switches a namespace that\nis already in warn mode to deny. Without --deny, a namespace that is\nalready in warn or deny mode is left unchanged.\n\nExample:\n\n\x1b[1mtemporal cloud namespace encryption-validation enable --namespace my-namespace.my-account\x1b[0m" + } else { + s.Command.Long = "Enable payload encryption validation for a Temporal Cloud namespace.\nIf validation has not been configured, it is enabled in warn mode with\nno metadata key or values, so the defaults apply\n(metadata-key=encoding, metadata-values=[binary/encrypted]).\nIf validation is disabled, the existing configuration is re-enabled.\nUse --deny to enable in deny mode; this also switches a namespace that\nis already in warn mode to deny. Without --deny, a namespace that is\nalready in warn or deny mode is left unchanged.\n\nExample:\n\n```\ntemporal cloud namespace encryption-validation enable --namespace my-namespace.my-account\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().BoolVar(&s.Deny, "deny", false, "Enable validation in deny mode instead of warn. Switches warn mode to deny.") + s.ClientOptions.BuildFlags(s.Command.Flags()) + s.NamespaceOptions.BuildFlags(s.Command.Flags()) + s.AsyncOperationOptions.BuildFlags(s.Command.Flags()) + s.ResourceVersionOptions.BuildFlags(s.Command.Flags()) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + +type CloudNamespaceEncryptionValidationGetCommand struct { + Parent *CloudNamespaceEncryptionValidationCommand + Command cobra.Command + ClientOptions + NamespaceOptions +} + +func NewCloudNamespaceEncryptionValidationGetCommand(cctx *CommandContext, parent *CloudNamespaceEncryptionValidationCommand) *CloudNamespaceEncryptionValidationGetCommand { + var s CloudNamespaceEncryptionValidationGetCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "get [flags]" + s.Command.Short = "[Experimental] Get namespace payload encryption validation configuration" + if hasHighlighting { + s.Command.Long = "Retrieve the current payload encryption validation configuration for a Temporal Cloud namespace.\n\nExample:\n\n\x1b[1mtemporal cloud namespace encryption-validation get --namespace my-namespace.my-account\x1b[0m" + } else { + s.Command.Long = "Retrieve the current payload encryption validation configuration for a Temporal Cloud namespace.\n\nExample:\n\n```\ntemporal cloud namespace encryption-validation get --namespace my-namespace.my-account\n```" + } + s.Command.Args = cobra.NoArgs + s.ClientOptions.BuildFlags(s.Command.Flags()) + s.NamespaceOptions.BuildFlags(s.Command.Flags()) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + +type CloudNamespaceEncryptionValidationSetCommand struct { + Parent *CloudNamespaceEncryptionValidationCommand + Command cobra.Command + ClientOptions + NamespaceOptions + AsyncOperationOptions + ResourceVersionOptions + Mode string + MetadataKey string + MetadataValue []string + InspectHeader bool + InspectFailure bool +} + +func NewCloudNamespaceEncryptionValidationSetCommand(cctx *CommandContext, parent *CloudNamespaceEncryptionValidationCommand) *CloudNamespaceEncryptionValidationSetCommand { + var s CloudNamespaceEncryptionValidationSetCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "set [flags]" + s.Command.Short = "[Experimental] Set namespace payload encryption validation configuration" + if hasHighlighting { + s.Command.Long = "Update the payload encryption validation configuration for a Temporal Cloud\nnamespace. At least one flag must be set. Omitted flags keep the\nexisting values. An empty metadata key or values means the server\ndefaults apply (metadata-key=encoding, metadata-values=[binary/encrypted]).\n\nExample:\n\n\x1b[1mtemporal cloud namespace encryption-validation set --namespace my-namespace.my-account --mode warn --metadata-key encoding --metadata-value binary/encrypted\x1b[0m" + } else { + s.Command.Long = "Update the payload encryption validation configuration for a Temporal Cloud\nnamespace. At least one flag must be set. Omitted flags keep the\nexisting values. An empty metadata key or values means the server\ndefaults apply (metadata-key=encoding, metadata-values=[binary/encrypted]).\n\nExample:\n\n```\ntemporal cloud namespace encryption-validation set --namespace my-namespace.my-account --mode warn --metadata-key encoding --metadata-value binary/encrypted\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringVar(&s.Mode, "mode", "", "Payload encryption validation mode. Valid values: disabled, warn, deny. If omitted, the existing mode is kept. Required when encryption validation is not configured yet.") + s.Command.Flags().StringVar(&s.MetadataKey, "metadata-key", "", "Payload metadata key used to identify encrypted payloads. If omitted, the existing key is kept.") + s.Command.Flags().StringArrayVar(&s.MetadataValue, "metadata-value", nil, "Payload metadata value used to identify encrypted payloads. Repeat to add additional values. Replaces the existing values. If omitted, the existing values are kept.") + s.Command.Flags().BoolVar(&s.InspectHeader, "inspect-header", false, "Inspect workflow headers for encryption validation. If omitted, the existing value is kept.") + s.Command.Flags().BoolVar(&s.InspectFailure, "inspect-failure", false, "Inspect Failure payloads for encryption validation. If omitted, the existing value is kept.") + s.ClientOptions.BuildFlags(s.Command.Flags()) + s.NamespaceOptions.BuildFlags(s.Command.Flags()) + s.AsyncOperationOptions.BuildFlags(s.Command.Flags()) + s.ResourceVersionOptions.BuildFlags(s.Command.Flags()) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + type CloudNamespaceExportCommand struct { Parent *CloudNamespaceCommand Command cobra.Command diff --git a/temporalcloudcli/commands.namespace.create_test.go b/temporalcloudcli/commands.namespace.create_test.go index bfd0e72..f8c3933 100644 --- a/temporalcloudcli/commands.namespace.create_test.go +++ b/temporalcloudcli/commands.namespace.create_test.go @@ -435,3 +435,61 @@ func TestCreateNamespace_InvalidInput(t *testing.T) { }) } } + +func TestCreateNamespace_EncryptionValidation(t *testing.T) { + tests := []struct { + name string + spec *namespacev1.EncryptionValidationSpec + expected *namespacev1.EncryptionValidationSpec + }{ + {name: "Unset", spec: nil, expected: nil}, + { + name: "Explicit", + spec: &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DENY, + MetadataKey: "encoding", + MetadataValues: []string{"binary/encrypted"}, + InspectHeader: true, + }, + expected: &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DENY, + MetadataKey: "encoding", + MetadataValues: []string{"binary/encrypted"}, + InspectHeader: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expectedSpec := baseNamespaceSpec() + expectedSpec.EncryptionValidation = tt.expected + + mockCloud := cloudmock.NewMockCloudServiceClient(t) + mockPrompter := cmdmock.NewMockPrompter(t) + mockHandler := cmdmock.NewMockAsyncOperationHandler(t) + + mockPrompter.EXPECT(). + PromptApply(&namespacev1.NamespaceSpec{}, specMatcher(expectedSpec), false). + Return(nil) + mockCloud.EXPECT(). + CreateNamespace(context.Background(), createReqMatcher(expectedSpec)). + Return(defaultCreateResponse, nil) + mockHandler.EXPECT(). + HandleOperation(defaultCreateResponse.AsyncOperation, "my-namespace.my-account"). + Return(nil) + + var buf bytes.Buffer + err := temporalcloudcli.CreateNamespace(context.Background(), temporalcloudcli.CreateNamespaceParams{ + Name: "my-namespace", + Regions: []string{"aws-us-east-1"}, + Cloud: mockCloud, + Printer: &printer.Printer{Output: &buf, JSON: true}, + Prompter: mockPrompter, + UnmarshalProtoJSON: noopUnmarshalProtoJSON, + OperationHandler: mockHandler, + EncryptionValidation: tt.spec, + }) + require.NoError(t, err) + }) + } +} diff --git a/temporalcloudcli/commands.namespace.encryption_validation.go b/temporalcloudcli/commands.namespace.encryption_validation.go new file mode 100644 index 0000000..41b4d87 --- /dev/null +++ b/temporalcloudcli/commands.namespace.encryption_validation.go @@ -0,0 +1,259 @@ +package temporalcloudcli + +import ( + "errors" + "fmt" + "slices" + "strings" + + cloudservice "go.temporal.io/cloud-sdk/api/cloudservice/v1" + namespacev1 "go.temporal.io/cloud-sdk/api/namespace/v1" + "google.golang.org/protobuf/proto" + + "github.com/temporalio/cloud-cli/temporalcloudcli/internal/printer" +) + +const ( + encryptionValidationModeDisabled = "disabled" + encryptionValidationModeWarn = "warn" + encryptionValidationModeDeny = "deny" + + alreadyInDesiredStateMessage = "Resource already in desired state" +) + +var encryptionValidationSetFlags = []string{"mode", "metadata-key", "metadata-value", "inspect-header", "inspect-failure"} + +func (c *CloudNamespaceEncryptionValidationGetCommand) run(cctx *CommandContext, _ []string) error { + client, err := cctx.GetCloudClient(c.ClientOptions) + if err != nil { + return err + } + res, err := client.GetNamespace(cctx, &cloudservice.GetNamespaceRequest{Namespace: c.Namespace}) + if err != nil { + return err + } + + var spec *namespacev1.EncryptionValidationSpec + if res.Namespace.Spec != nil { + spec = res.Namespace.Spec.EncryptionValidation + } + return cctx.Printer.PrintResource(struct { + Namespace string `json:"namespace"` + Spec *namespacev1.EncryptionValidationSpec `json:"spec"` + }{ + Namespace: res.Namespace.Namespace, + Spec: spec, + }, printer.PrintResourceOptions{}) +} + +func (c *CloudNamespaceEncryptionValidationSetCommand) run(cctx *CommandContext, _ []string) error { + flags := c.Command.Flags() + if !slices.ContainsFunc(encryptionValidationSetFlags, flags.Changed) { + return errors.New("at least one of --mode, --metadata-key, --metadata-value, --inspect-header, " + + "or --inspect-failure must be set") + } + var mode namespacev1.EncryptionValidationSpec_EncryptionValidationMode + if flags.Changed("mode") { + var err error + mode, err = parseEncryptionValidationMode(c.Mode) + if err != nil { + return err + } + } + + client, err := cctx.GetCloudClient(c.ClientOptions) + if err != nil { + return err + } + res, err := client.GetNamespace(cctx, &cloudservice.GetNamespaceRequest{Namespace: c.Namespace}) + if err != nil { + return err + } + + ns := res.Namespace + newSpec := proto.Clone(ns.Spec).(*namespacev1.NamespaceSpec) + if newSpec.EncryptionValidation == nil { + if !flags.Changed("mode") { + return errors.New("--mode is required when encryption validation is not configured") + } + newSpec.EncryptionValidation = &namespacev1.EncryptionValidationSpec{} + } + if flags.Changed("mode") { + newSpec.EncryptionValidation.Mode = mode + } + if flags.Changed("metadata-key") { + newSpec.EncryptionValidation.MetadataKey = c.MetadataKey + } + if flags.Changed("metadata-value") { + newSpec.EncryptionValidation.MetadataValues = c.MetadataValue + } + if flags.Changed("inspect-header") { + newSpec.EncryptionValidation.InspectHeader = c.InspectHeader + } + if flags.Changed("inspect-failure") { + newSpec.EncryptionValidation.InspectFailure = c.InspectFailure + } + if proto.Equal(ns.Spec, newSpec) { + cctx.Printer.Println(alreadyInDesiredStateMessage) + return nil + } + + yes, err := cctx.GetPrompter().PromptApply(ns.Spec, newSpec, false) + if err != nil { + return err + } + if !yes { + return errors.New("Aborting set.") + } + + rv := ns.ResourceVersion + if c.ResourceVersion != "" { + rv = c.ResourceVersion + } + resp, err := client.UpdateNamespace(cctx, &cloudservice.UpdateNamespaceRequest{ + Namespace: c.Namespace, + Spec: newSpec, + ResourceVersion: rv, + AsyncOperationId: c.AsyncOperationId, + }) + return cctx.GetPoller(client, c.AsyncOperationOptions).HandleUpdateOperation(cctx, resp, err) +} + +func (c *CloudNamespaceEncryptionValidationEnableCommand) run(cctx *CommandContext, _ []string) error { + client, err := cctx.GetCloudClient(c.ClientOptions) + if err != nil { + return err + } + res, err := client.GetNamespace(cctx, &cloudservice.GetNamespaceRequest{Namespace: c.Namespace}) + if err != nil { + return err + } + + ns := res.Namespace + newSpec := proto.Clone(ns.Spec).(*namespacev1.NamespaceSpec) + if newSpec.EncryptionValidation == nil { + newSpec.EncryptionValidation = &namespacev1.EncryptionValidationSpec{} + } + ev := newSpec.EncryptionValidation + // --deny always results in deny mode. Without it, an existing warn/deny mode is kept. + if c.Deny { + ev.Mode = namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DENY + } else if ev.Mode == namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_UNSPECIFIED || + ev.Mode == namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DISABLED { + ev.Mode = namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN + } + if proto.Equal(ns.Spec, newSpec) { + cctx.Printer.Println(alreadyInDesiredStateMessage) + return nil + } + + yes, err := cctx.GetPrompter().PromptApply(ns.Spec, newSpec, false) + if err != nil { + return err + } + if !yes { + return errors.New("Aborting enable.") + } + + rv := ns.ResourceVersion + if c.ResourceVersion != "" { + rv = c.ResourceVersion + } + resp, err := client.UpdateNamespace(cctx, &cloudservice.UpdateNamespaceRequest{ + Namespace: c.Namespace, + Spec: newSpec, + ResourceVersion: rv, + AsyncOperationId: c.AsyncOperationId, + }) + return cctx.GetPoller(client, c.AsyncOperationOptions).HandleUpdateOperation(cctx, resp, err) +} + +func (c *CloudNamespaceEncryptionValidationDisableCommand) run(cctx *CommandContext, _ []string) error { + client, err := cctx.GetCloudClient(c.ClientOptions) + if err != nil { + return err + } + res, err := client.GetNamespace(cctx, &cloudservice.GetNamespaceRequest{Namespace: c.Namespace}) + if err != nil { + return err + } + + ns := res.Namespace + if ns.Spec.EncryptionValidation == nil { + cctx.Printer.Println(alreadyInDesiredStateMessage) + return nil + } + newSpec := proto.Clone(ns.Spec).(*namespacev1.NamespaceSpec) + newSpec.EncryptionValidation.Mode = namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DISABLED + if proto.Equal(ns.Spec, newSpec) { + cctx.Printer.Println(alreadyInDesiredStateMessage) + return nil + } + + yes, err := cctx.GetPrompter().PromptApply(ns.Spec, newSpec, false) + if err != nil { + return err + } + if !yes { + return errors.New("Aborting disable.") + } + + rv := ns.ResourceVersion + if c.ResourceVersion != "" { + rv = c.ResourceVersion + } + resp, err := client.UpdateNamespace(cctx, &cloudservice.UpdateNamespaceRequest{ + Namespace: c.Namespace, + Spec: newSpec, + ResourceVersion: rv, + AsyncOperationId: c.AsyncOperationId, + }) + return cctx.GetPoller(client, c.AsyncOperationOptions).HandleUpdateOperation(cctx, resp, err) +} + +func parseEncryptionValidationMode(value string) (namespacev1.EncryptionValidationSpec_EncryptionValidationMode, error) { + switch strings.ToLower(value) { + case encryptionValidationModeDisabled: + return namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DISABLED, nil + case encryptionValidationModeWarn: + return namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN, nil + case encryptionValidationModeDeny: + return namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DENY, nil + default: + return namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_UNSPECIFIED, fmt.Errorf( + "invalid encryption validation mode %q: must be disabled, warn, or deny", + value, + ) + } +} + +func encryptionValidationFromCreateFlags(c *CloudNamespaceCreateCommand) (*namespacev1.EncryptionValidationSpec, error) { + flags := c.Command.Flags() + spec := &namespacev1.EncryptionValidationSpec{} + if flags.Changed("encryption-validation-mode") { + mode, err := parseEncryptionValidationMode(c.EncryptionValidationMode) + if err != nil { + return nil, err + } + spec.Mode = mode + } + if flags.Changed("encryption-validation-metadata-key") { + spec.MetadataKey = c.EncryptionValidationMetadataKey + } + if flags.Changed("encryption-validation-metadata-value") { + spec.MetadataValues = c.EncryptionValidationMetadataValue + } + if flags.Changed("encryption-validation-inspect-header") { + spec.InspectHeader = c.EncryptionValidationInspectHeader + } + if flags.Changed("encryption-validation-inspect-failure") { + spec.InspectFailure = c.EncryptionValidationInspectFailure + } + if proto.Equal(spec, &namespacev1.EncryptionValidationSpec{}) { + return nil, nil + } + if spec.Mode == namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_UNSPECIFIED { + return nil, errors.New("--encryption-validation-mode is required when any encryption-validation flag is set") + } + return spec, nil +} diff --git a/temporalcloudcli/commands.namespace.encryption_validation_test.go b/temporalcloudcli/commands.namespace.encryption_validation_test.go new file mode 100644 index 0000000..cf6931e --- /dev/null +++ b/temporalcloudcli/commands.namespace.encryption_validation_test.go @@ -0,0 +1,642 @@ +package temporalcloudcli_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + cloudservice "go.temporal.io/cloud-sdk/api/cloudservice/v1" + namespacev1 "go.temporal.io/cloud-sdk/api/namespace/v1" + operation "go.temporal.io/cloud-sdk/api/operation/v1" + "google.golang.org/protobuf/proto" + + cloudmock "github.com/temporalio/cloud-cli/internal/cloudservice/mock" + "github.com/temporalio/cloud-cli/temporalcloudcli" +) + +func namespaceWithEncryptionValidation(spec *namespacev1.EncryptionValidationSpec) *namespacev1.Namespace { + return &namespacev1.Namespace{ + Namespace: "my-ns.my-acct", + ResourceVersion: "rv-fetched", + Spec: &namespacev1.NamespaceSpec{ + Name: "my-ns", + Regions: []string{"aws-us-east-1"}, + RetentionDays: 30, + EncryptionValidation: spec, + }, + } +} + +func TestNamespaceEncryptionValidationGet(t *testing.T) { + configured := &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN, + MetadataKey: "encoding", + MetadataValues: []string{"binary/encrypted"}, + } + tests := []struct { + name string + cmd temporalcloudcli.CloudNamespaceEncryptionValidationGetCommand + cloudClientExpectations func(*cloudmock.MockCloudServiceClient) + expectedErr string + expectedJsonOutput any + }{ + { + name: "Configured", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationGetCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, &cloudservice.GetNamespaceRequest{Namespace: "my-ns.my-acct"}, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(configured)}, nil) + }, + expectedJsonOutput: map[string]any{ + "namespace": "my-ns.my-acct", + "spec": map[string]any{ + "mode": "ENCRYPTION_VALIDATION_MODE_WARN", + "metadataKey": "encoding", + "metadataValues": []any{"binary/encrypted"}, + }, + }, + }, + { + name: "NilSpec", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationGetCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, &cloudservice.GetNamespaceRequest{Namespace: "my-ns.my-acct"}, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(nil)}, nil) + }, + expectedJsonOutput: map[string]any{ + "namespace": "my-ns.my-acct", + "spec": nil, + }, + }, + { + name: "GetNamespaceError", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationGetCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(nil, errors.New("namespace not found")) + }, + expectedErr: "namespace not found", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + temporalcloudcli.TestCommand(t, &tt.cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: tt.cloudClientExpectations, + JSONOutput: true, + ExpectedError: tt.expectedErr, + ExpectedOutputJson: tt.expectedJsonOutput, + }) + }) + } +} + +func TestNamespaceEncryptionValidationSet(t *testing.T) { + tests := []struct { + name string + cmd temporalcloudcli.CloudNamespaceEncryptionValidationSetCommand + args []string + cloudClientExpectations func(*cloudmock.MockCloudServiceClient) + promptOptions temporalcloudcli.TestPromptOptions + asyncPollerOptions temporalcloudcli.TestAsyncPollerOptions + expectedErr string + }{ + { + name: "AllFlags", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationSetCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + args: []string{ + "--mode=warn", + "--metadata-key=encoding", + "--metadata-value=binary/encrypted", + "--metadata-value=legacy-value", + "--inspect-header", + "--inspect-failure", + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, &cloudservice.GetNamespaceRequest{Namespace: "my-ns.my-acct"}, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(nil)}, nil) + c.EXPECT(). + UpdateNamespace(mock.Anything, mock.MatchedBy(func(req *cloudservice.UpdateNamespaceRequest) bool { + return req.Namespace == "my-ns.my-acct" && + req.ResourceVersion == "rv-fetched" && + proto.Equal(req.Spec.EncryptionValidation, &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN, + MetadataKey: "encoding", + MetadataValues: []string{"binary/encrypted", "legacy-value"}, + InspectHeader: true, + InspectFailure: true, + }) + }), mock.Anything). + Return(&cloudservice.UpdateNamespaceResponse{ + AsyncOperation: &operation.AsyncOperation{Id: "op-set"}, + }, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: true}, + asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-set"}, + }, + { + name: "OmittedFlagsPreserved", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationSetCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + args: []string{"--mode=deny"}, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(&namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN, + MetadataKey: "custom-key", + MetadataValues: []string{"custom-value"}, + InspectHeader: true, + InspectFailure: true, + })}, nil) + c.EXPECT(). + UpdateNamespace(mock.Anything, mock.MatchedBy(func(req *cloudservice.UpdateNamespaceRequest) bool { + return proto.Equal(req.Spec.EncryptionValidation, &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DENY, + MetadataKey: "custom-key", + MetadataValues: []string{"custom-value"}, + InspectHeader: true, + InspectFailure: true, + }) + }), mock.Anything). + Return(&cloudservice.UpdateNamespaceResponse{ + AsyncOperation: &operation.AsyncOperation{Id: "op-set"}, + }, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: true}, + asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-set"}, + }, + { + name: "ExplicitFalseOverrides", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationSetCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + args: []string{"--inspect-failure=false"}, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(&namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN, + InspectFailure: true, + })}, nil) + c.EXPECT(). + UpdateNamespace(mock.Anything, mock.MatchedBy(func(req *cloudservice.UpdateNamespaceRequest) bool { + return proto.Equal(req.Spec.EncryptionValidation, &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN, + }) + }), mock.Anything). + Return(&cloudservice.UpdateNamespaceResponse{ + AsyncOperation: &operation.AsyncOperation{Id: "op-set"}, + }, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: true}, + asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-set"}, + }, + { + name: "InvalidMode", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationSetCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + args: []string{"--mode=oops"}, + expectedErr: `invalid encryption validation mode "oops": must be disabled, warn, or deny`, + }, + { + name: "NoFlags", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationSetCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + expectedErr: "at least one of --mode, --metadata-key, --metadata-value, --inspect-header, " + + "or --inspect-failure must be set", + }, + { + // No prompt and no UpdateNamespace call are expected. + name: "NoChange", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationSetCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + args: []string{"--mode=warn", "--inspect-header=false"}, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(&namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN, + })}, nil) + }, + }, + { + name: "ModeRequiredWhenUnconfigured", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationSetCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + args: []string{"--inspect-failure"}, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(nil)}, nil) + }, + expectedErr: "--mode is required when encryption validation is not configured", + }, + { + name: "ResourceVersionOverride", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationSetCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + ResourceVersionOptions: temporalcloudcli.ResourceVersionOptions{ResourceVersion: "rv-user"}, + }, + args: []string{"--mode=deny"}, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(nil)}, nil) + c.EXPECT(). + UpdateNamespace(mock.Anything, mock.MatchedBy(func(req *cloudservice.UpdateNamespaceRequest) bool { + return req.ResourceVersion == "rv-user" && + proto.Equal(req.Spec.EncryptionValidation, &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DENY, + }) + }), mock.Anything). + Return(&cloudservice.UpdateNamespaceResponse{ + AsyncOperation: &operation.AsyncOperation{Id: "op-rv"}, + }, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: true}, + asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-rv"}, + }, + { + name: "AsyncOperationIdOverride", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationSetCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + AsyncOperationOptions: temporalcloudcli.AsyncOperationOptions{AsyncOperationId: "op-custom"}, + }, + args: []string{"--mode=warn"}, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(nil)}, nil) + c.EXPECT(). + UpdateNamespace(mock.Anything, mock.MatchedBy(func(req *cloudservice.UpdateNamespaceRequest) bool { + return req.AsyncOperationId == "op-custom" + }), mock.Anything). + Return(&cloudservice.UpdateNamespaceResponse{ + AsyncOperation: &operation.AsyncOperation{Id: "op-custom"}, + }, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: true}, + asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-custom"}, + }, + { + name: "GetNamespaceError", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationSetCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + args: []string{"--mode=warn"}, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(nil, errors.New("namespace not found")) + }, + expectedErr: "namespace not found", + }, + { + name: "UpdateNamespaceError", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationSetCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + args: []string{"--mode=warn"}, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(nil)}, nil) + c.EXPECT(). + UpdateNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(nil, errors.New("update failed")) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: true}, + expectedErr: "update failed", + }, + { + name: "PromptDeclined", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationSetCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + args: []string{"--mode=warn"}, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(nil)}, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: false}, + expectedErr: "Aborting set.", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := tt.cmd.Command.Flags() + fs.StringVar(&tt.cmd.Mode, "mode", "", "") + fs.StringVar(&tt.cmd.MetadataKey, "metadata-key", "", "") + fs.StringArrayVar(&tt.cmd.MetadataValue, "metadata-value", nil, "") + fs.BoolVar(&tt.cmd.InspectHeader, "inspect-header", false, "") + fs.BoolVar(&tt.cmd.InspectFailure, "inspect-failure", false, "") + require.NoError(t, fs.Parse(tt.args)) + + temporalcloudcli.TestCommand(t, &tt.cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: tt.cloudClientExpectations, + PromptOptions: tt.promptOptions, + AsyncPollerOptions: tt.asyncPollerOptions, + JSONOutput: true, + ExpectedError: tt.expectedErr, + }) + }) + } +} + +func TestNamespaceEncryptionValidationEnable(t *testing.T) { + disabledWithCustomMetadata := &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DISABLED, + MetadataKey: "custom-key", + MetadataValues: []string{"custom-value"}, + InspectHeader: true, + } + warn := &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN, + MetadataKey: "encoding", + MetadataValues: []string{"binary/encrypted"}, + } + deny := &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DENY, + MetadataKey: "encoding", + MetadataValues: []string{"binary/encrypted"}, + } + + tests := []struct { + name string + cmd temporalcloudcli.CloudNamespaceEncryptionValidationEnableCommand + cloudClientExpectations func(*cloudmock.MockCloudServiceClient) + promptOptions temporalcloudcli.TestPromptOptions + asyncPollerOptions temporalcloudcli.TestAsyncPollerOptions + expectedErr string + }{ + { + name: "NilToWarn", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationEnableCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, &cloudservice.GetNamespaceRequest{Namespace: "my-ns.my-acct"}, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(nil)}, nil) + c.EXPECT(). + UpdateNamespace(mock.Anything, mock.MatchedBy(func(req *cloudservice.UpdateNamespaceRequest) bool { + return req.Namespace == "my-ns.my-acct" && + req.ResourceVersion == "rv-fetched" && + proto.Equal(req.Spec.EncryptionValidation, &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN, + }) + }), mock.Anything). + Return(&cloudservice.UpdateNamespaceResponse{ + AsyncOperation: &operation.AsyncOperation{Id: "op-enable"}, + }, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: true}, + asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-enable"}, + }, + { + name: "DisabledKeepsMetadata", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationEnableCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(disabledWithCustomMetadata)}, nil) + c.EXPECT(). + UpdateNamespace(mock.Anything, mock.MatchedBy(func(req *cloudservice.UpdateNamespaceRequest) bool { + return proto.Equal(req.Spec.EncryptionValidation, &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN, + MetadataKey: "custom-key", + MetadataValues: []string{"custom-value"}, + InspectHeader: true, + }) + }), mock.Anything). + Return(&cloudservice.UpdateNamespaceResponse{ + AsyncOperation: &operation.AsyncOperation{Id: "op-enable"}, + }, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: true}, + asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-enable"}, + }, + { + name: "DenyFlag", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationEnableCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + Deny: true, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(disabledWithCustomMetadata)}, nil) + c.EXPECT(). + UpdateNamespace(mock.Anything, mock.MatchedBy(func(req *cloudservice.UpdateNamespaceRequest) bool { + return proto.Equal(req.Spec.EncryptionValidation, &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DENY, + MetadataKey: "custom-key", + MetadataValues: []string{"custom-value"}, + InspectHeader: true, + }) + }), mock.Anything). + Return(&cloudservice.UpdateNamespaceResponse{ + AsyncOperation: &operation.AsyncOperation{Id: "op-enable"}, + }, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: true}, + asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-enable"}, + }, + { + name: "WarnToDenyWithDenyFlag", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationEnableCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + Deny: true, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(warn)}, nil) + c.EXPECT(). + UpdateNamespace(mock.Anything, mock.MatchedBy(func(req *cloudservice.UpdateNamespaceRequest) bool { + return proto.Equal(req.Spec.EncryptionValidation, deny) + }), mock.Anything). + Return(&cloudservice.UpdateNamespaceResponse{ + AsyncOperation: &operation.AsyncOperation{Id: "op-enable"}, + }, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: true}, + asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-enable"}, + }, + { + // No-op cases: no prompt and no UpdateNamespace call are expected. + name: "AlreadyWarn", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationEnableCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(warn)}, nil) + }, + }, + { + name: "AlreadyDeny", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationEnableCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(deny)}, nil) + }, + }, + { + name: "GetNamespaceError", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationEnableCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(nil, errors.New("namespace not found")) + }, + expectedErr: "namespace not found", + }, + { + name: "PromptDeclined", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationEnableCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(nil)}, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: false}, + expectedErr: "Aborting enable.", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + temporalcloudcli.TestCommand(t, &tt.cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: tt.cloudClientExpectations, + PromptOptions: tt.promptOptions, + AsyncPollerOptions: tt.asyncPollerOptions, + JSONOutput: true, + ExpectedError: tt.expectedErr, + }) + }) + } +} + +func TestNamespaceEncryptionValidationDisable(t *testing.T) { + existing := &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN, + MetadataKey: "encoding", + MetadataValues: []string{"binary/encrypted"}, + InspectFailure: true, + } + tests := []struct { + name string + cmd temporalcloudcli.CloudNamespaceEncryptionValidationDisableCommand + cloudClientExpectations func(*cloudmock.MockCloudServiceClient) + promptOptions temporalcloudcli.TestPromptOptions + asyncPollerOptions temporalcloudcli.TestAsyncPollerOptions + expectedErr string + }{ + { + name: "DisableFromEnabled", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationDisableCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(existing)}, nil) + c.EXPECT(). + UpdateNamespace(mock.Anything, mock.MatchedBy(func(req *cloudservice.UpdateNamespaceRequest) bool { + return proto.Equal(req.Spec.EncryptionValidation, &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DISABLED, + MetadataKey: "encoding", + MetadataValues: []string{"binary/encrypted"}, + InspectFailure: true, + }) + }), mock.Anything). + Return(&cloudservice.UpdateNamespaceResponse{ + AsyncOperation: &operation.AsyncOperation{Id: "op-disable"}, + }, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: true}, + asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-disable"}, + }, + { + // No-op cases: no prompt and no UpdateNamespace call are expected. + name: "AlreadyDisabled", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationDisableCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(&namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DISABLED, + })}, nil) + }, + }, + { + name: "Unconfigured", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationDisableCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(nil)}, nil) + }, + }, + { + name: "UpdateNamespaceError", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationDisableCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(existing)}, nil) + c.EXPECT(). + UpdateNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(nil, errors.New("update failed")) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: true}, + expectedErr: "update failed", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + temporalcloudcli.TestCommand(t, &tt.cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: tt.cloudClientExpectations, + PromptOptions: tt.promptOptions, + AsyncPollerOptions: tt.asyncPollerOptions, + JSONOutput: true, + ExpectedError: tt.expectedErr, + }) + }) + } +} diff --git a/temporalcloudcli/commands.namespace.go b/temporalcloudcli/commands.namespace.go index 805360b..e573832 100644 --- a/temporalcloudcli/commands.namespace.go +++ b/temporalcloudcli/commands.namespace.go @@ -330,6 +330,7 @@ type ( ConnectionRuleIDs []string ProjectID string Description string + EncryptionValidation *namespacev1.EncryptionValidationSpec Cloud cloudservice.CloudServiceClient Printer *printer.Printer @@ -419,6 +420,7 @@ func CreateNamespace(ctx context.Context, params CreateNamespaceParams) error { } spec.SearchAttributes = searchAttrs + spec.EncryptionValidation = params.EncryptionValidation if err := params.Prompter.PromptApply(&namespacev1.NamespaceSpec{}, spec, false); err != nil { return err @@ -450,6 +452,11 @@ func (c *CloudNamespaceCreateCommand) run(cctx *CommandContext, _ []string) erro } projectID := c.ProjectId + encryptionValidation, err := encryptionValidationFromCreateFlags(c) + if err != nil { + return err + } + return CreateNamespace(cctx.Context, CreateNamespaceParams{ Name: c.Name, Regions: c.Region, @@ -473,6 +480,7 @@ func (c *CloudNamespaceCreateCommand) run(cctx *CommandContext, _ []string) erro Prompter: newPrompter(cctx), UnmarshalProtoJSON: cctx.UnmarshalProtoJSON, OperationHandler: NewOperationHandler(cctx, c.AsyncOperationOptions, c.ClientOptions), + EncryptionValidation: encryptionValidation, }) } diff --git a/temporalcloudcli/commands.yml b/temporalcloudcli/commands.yml index f7973d7..c4a947f 100644 --- a/temporalcloudcli/commands.yml +++ b/temporalcloudcli/commands.yml @@ -446,6 +446,29 @@ commands: The description is a human-readable description of the namespace. Must be at most 255 printable ASCII characters plus whitespace. Optional, default is empty. + - name: encryption-validation-mode + type: string + description: | + [Experimental] Payload encryption validation mode. Valid values: disabled, warn, deny. + When any encryption-validation flag is set, this field is required. + - name: encryption-validation-metadata-key + type: string + description: | + [Experimental] Payload metadata key used to identify encrypted payloads. If omitted, + the server default (encoding) applies. + - name: encryption-validation-metadata-value + type: string[] + description: | + [Experimental] Payload metadata value used to identify encrypted payloads. Repeat to + add additional values. If omitted, the server default (binary/encrypted) applies. + - name: encryption-validation-inspect-header + type: bool + description: | + [Experimental] Inspect workflow headers for encryption validation. Default is false. + - name: encryption-validation-inspect-failure + type: bool + description: | + [Experimental] Inspect Failure payloads for encryption validation. Default is false. - name: cloud namespace move-to-project summary: Move a namespace to a different project description: | @@ -741,6 +764,120 @@ commands: description: | Enable or disable task queue fairness for the namespace. + # Namespace encryption validation commands + - name: cloud namespace encryption-validation + summary: '[Experimental] Manage namespace payload encryption validation settings' + description: | + Commands for managing payload encryption validation configuration of Temporal Cloud namespaces. + has-init: false + - name: cloud namespace encryption-validation get + summary: '[Experimental] Get namespace payload encryption validation configuration' + description: | + Retrieve the current payload encryption validation configuration for a Temporal Cloud namespace. + + Example: + + ``` + temporal cloud namespace encryption-validation get --namespace my-namespace.my-account + ``` + has-init: false + option-sets: + - client + - namespace + - name: cloud namespace encryption-validation set + summary: '[Experimental] Set namespace payload encryption validation configuration' + description: | + Update the payload encryption validation configuration for a Temporal Cloud + namespace. At least one flag must be set. Omitted flags keep the + existing values. An empty metadata key or values means the server + defaults apply (metadata-key=encoding, metadata-values=[binary/encrypted]). + + Example: + + ``` + temporal cloud namespace encryption-validation set --namespace my-namespace.my-account --mode warn --metadata-key encoding --metadata-value binary/encrypted + ``` + has-init: false + option-sets: + - client + - namespace + - async-operation + - resource-version + options: + - name: mode + type: string + description: | + Payload encryption validation mode. Valid values: disabled, warn, deny. + If omitted, the existing mode is kept. Required when encryption + validation is not configured yet. + - name: metadata-key + type: string + description: | + Payload metadata key used to identify encrypted payloads. If omitted, + the existing key is kept. + - name: metadata-value + type: string[] + description: | + Payload metadata value used to identify encrypted payloads. Repeat to + add additional values. Replaces the existing values. If omitted, the + existing values are kept. + - name: inspect-header + type: bool + description: | + Inspect workflow headers for encryption validation. If omitted, the + existing value is kept. + - name: inspect-failure + type: bool + description: | + Inspect Failure payloads for encryption validation. If omitted, the + existing value is kept. + - name: cloud namespace encryption-validation enable + summary: '[Experimental] Enable payload encryption validation for a namespace' + description: | + Enable payload encryption validation for a Temporal Cloud namespace. + If validation has not been configured, it is enabled in warn mode with + no metadata key or values, so the defaults apply + (metadata-key=encoding, metadata-values=[binary/encrypted]). + If validation is disabled, the existing configuration is re-enabled. + Use --deny to enable in deny mode; this also switches a namespace that + is already in warn mode to deny. Without --deny, a namespace that is + already in warn or deny mode is left unchanged. + + Example: + + ``` + temporal cloud namespace encryption-validation enable --namespace my-namespace.my-account + ``` + has-init: false + option-sets: + - client + - namespace + - async-operation + - resource-version + options: + - name: deny + type: bool + description: | + Enable validation in deny mode instead of warn. Switches warn mode to deny. + - name: cloud namespace encryption-validation disable + summary: '[Experimental] Disable payload encryption validation for a namespace' + description: | + Disable payload encryption validation for a Temporal Cloud namespace. + Existing metadata and inspect settings are kept. If validation is not + configured or is already disabled, nothing is changed. + + Example: + + ``` + temporal cloud namespace encryption-validation disable --namespace my-namespace.my-account + ``` + has-init: false + option-sets: + - client + - namespace + - async-operation + - resource-version + # Namespace mtls commands - name: cloud namespace mtls summary: Manage namespace mTLS authentication settings