diff --git a/README.md b/README.md index eecf157..fd88e7e 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,7 @@ The following environment variables can be set up to control which environment a | EPCC_CLI_DISABLE_RESOURCES | A comma seperated list of resources that will not be available with commands or in the resource list | | EPCC_CLI_RATE_LIMIT | The default rate limit to use | | EPCC_CLI_DISABLE_HTTP_LOGGING | Disables writing of HTTP logs | +| EPCC_CLI_READ_ONLY | Enables read-only mode, blocking create/update/delete operations. Commands are hidden and return exit code 4 if attempted. | It is recommended to set EPCC_API_BASE_URL, EPCC_CLIENT_ID, and EPCC_CLIENT_SECRET to be able to interact with most things in the CLI. diff --git a/cmd/create.go b/cmd/create.go index 91ea341..8c7e714 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -21,8 +21,15 @@ import ( func NewCreateCommand(parentCmd *cobra.Command) func() { var createCmd = &cobra.Command{ - Use: "create", - Short: "Creates a resource", + Use: "create", + Short: "Creates a resource", + Hidden: IsReadOnly(), + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if IsReadOnly() { + return ErrReadOnlyMode + } + return RootCmd.PersistentPreRunE(RootCmd, args) + }, RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { return fmt.Errorf("please specify a resource, epcc create [RESOURCE], see epcc create --help") diff --git a/cmd/delete-all.go b/cmd/delete-all.go index 9548de0..e8cfe16 100644 --- a/cmd/delete-all.go +++ b/cmd/delete-all.go @@ -33,7 +33,14 @@ func NewDeleteAllCommand(parentCmd *cobra.Command) func() { var deleteAll = &cobra.Command{ Use: "delete-all", Short: "Deletes all of a resource", + Hidden: IsReadOnly(), SilenceUsage: false, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if IsReadOnly() { + return ErrReadOnlyMode + } + return RootCmd.PersistentPreRunE(RootCmd, args) + }, RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { return fmt.Errorf("please specify a resource, epcc delete-all [RESOURCE], see epcc delete-all --help") diff --git a/cmd/delete.go b/cmd/delete.go index 0e17966..53e0a6c 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -20,7 +20,14 @@ func NewDeleteCommand(parentCmd *cobra.Command) func() { var deleteCmd = &cobra.Command{ Use: "delete", Short: "Deletes a resource", + Hidden: IsReadOnly(), SilenceUsage: false, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if IsReadOnly() { + return ErrReadOnlyMode + } + return RootCmd.PersistentPreRunE(RootCmd, args) + }, RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { return fmt.Errorf("please specify a resource, epcc delete [RESOURCE], see epcc delete --help") diff --git a/cmd/reset-store.go b/cmd/reset-store.go index 7bd349a..8ba57a9 100644 --- a/cmd/reset-store.go +++ b/cmd/reset-store.go @@ -30,6 +30,12 @@ var ResetStore = &cobra.Command{ Short: "Resets a store to it's initial state on a \"best effort\" basis.", Long: "This command resets a store to it's initial state. There are some limitations to this as for instance orders cannot be deleted, nor can audit entries.", Args: cobra.MinimumNArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + if IsReadOnly() { + return ErrReadOnlyMode + } + return RootCmd.PersistentPreRunE(RootCmd, args) + }, RunE: func(cmd *cobra.Command, args []string) error { ctx := clictx.Ctx diff --git a/cmd/root.go b/cmd/root.go index ec5b511..16ba21e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "os" "os/signal" @@ -63,6 +64,14 @@ var jqCompletionFunc = func(cmd *cobra.Command, args []string, toComplete string var profileNameFromCommandLine = "" +// ErrReadOnlyMode is returned when a write operation is attempted in read-only mode +var ErrReadOnlyMode = errors.New("operation not permitted: EPCC_CLI_READ_ONLY is enabled") + +// IsReadOnly returns true if the CLI is in read-only mode +func IsReadOnly() bool { + return config.GetEnv().EPCC_CLI_READ_ONLY +} + func InitializeCmd() { DumpTraces() @@ -84,6 +93,13 @@ func InitializeCmd() { applyLogLevelEarlyDetectionHack() log.Tracef("Root Command Building In Progress") + // Check for read-only mode and hide write commands + readOnlyMode := IsReadOnly() + if readOnlyMode { + log.Debugf("Read-only mode is enabled (EPCC_CLI_READ_ONLY=true)") + ResetStore.Hidden = true + } + resources.PublicInit() initRunbookCommands() log.Tracef("Runbooks initialized") @@ -231,6 +247,7 @@ Environment Variables - EPCC_CLI_DISABLE_RESOURCES - A comma seperated list of resources that will be hidden in command lists - EPCC_CLI_RATE_LIMIT - The default rate limit to use. - EPCC_CLI_DISABLE_HTTP_LOGGING - Disables writing of HTTP logs +- EPCC_CLI_READ_ONLY - Enables read-only mode, blocking create/update/delete operations `, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { log.SetLevel(logger.Loglevel) @@ -311,8 +328,11 @@ func Execute() { <-shutdownHandlerDone if err != nil { + if errors.Is(err, ErrReadOnlyMode) { + log.Errorf("Error: %s", err) + os.Exit(4) + } log.Errorf("Error occurred while processing command: %s", err) - os.Exit(1) } else { os.Exit(0) diff --git a/cmd/update.go b/cmd/update.go index d99d019..368a7ef 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -73,7 +73,14 @@ func NewUpdateCommand(parentCmd *cobra.Command) func() { var updateCmd = &cobra.Command{ Use: "update", Short: "Updates a resource", + Hidden: IsReadOnly(), SilenceUsage: false, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if IsReadOnly() { + return ErrReadOnlyMode + } + return RootCmd.PersistentPreRunE(RootCmd, args) + }, RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { return fmt.Errorf("please specify a resource, epcc update [RESOURCE], see epcc update --help") diff --git a/config/config.go b/config/config.go index bbf3e1a..1cbccdb 100644 --- a/config/config.go +++ b/config/config.go @@ -14,6 +14,7 @@ type Env struct { EPCC_CLI_DISABLE_RESOURCES []string `env:"EPCC_CLI_DISABLE_RESOURCES" envSeparator:","` EPCC_CLI_DISABLE_TEMPLATE_EXECUTION bool `env:"EPCC_CLI_DISABLE_TEMPLATE_EXECUTION"` EPCC_CLI_DISABLE_HTTP_LOGGING bool `env:"EPCC_CLI_DISABLE_HTTP_LOGGING"` + EPCC_CLI_READ_ONLY bool `env:"EPCC_CLI_READ_ONLY"` } var env = atomic.Pointer[Env]{} diff --git a/external/httpclient/httpclient.go b/external/httpclient/httpclient.go index 1448f28..dcdb541 100644 --- a/external/httpclient/httpclient.go +++ b/external/httpclient/httpclient.go @@ -205,6 +205,15 @@ func doRequestInternal(ctx context.Context, method string, contentType string, p env := config.GetEnv() + // Read-only mode: block POST, PUT, DELETE, PATCH requests (except auth endpoints) + if env.EPCC_CLI_READ_ONLY { + if method == "POST" || method == "PUT" || method == "DELETE" || method == "PATCH" { + if !isExemptAuthPath(path) { + return nil, fmt.Errorf("HTTP %s request blocked: EPCC_CLI_READ_ONLY is enabled", method) + } + } + } + reqURL, err := url.Parse(env.EPCC_API_BASE_URL) if err != nil { return nil, err @@ -498,3 +507,17 @@ func AddAdditionalHeadersSpecifiedByFlag(r *http.Request) error { return nil } + +// isExemptAuthPath returns true if the path is an authentication endpoint +// that should be allowed even in read-only mode. +func isExemptAuthPath(path string) bool { + // Allow customer token creation + if strings.Contains(path, "customer-token") { + return true + } + // Allow account management token creation + if strings.Contains(path, "account-management-authentication-token") { + return true + } + return false +} diff --git a/external/runbooks/run-all-runbooks.sh b/external/runbooks/run-all-runbooks.sh index ed0ee66..619a464 100755 --- a/external/runbooks/run-all-runbooks.sh +++ b/external/runbooks/run-all-runbooks.sh @@ -13,6 +13,17 @@ set -x #Let's test that epcc command works after an embarrassing bug that caused it to panic :( epcc +# Smoke test for EPCC_CLI_READ_ONLY +echo "Starting Read-Only Mode Smoke Test" +epcc reset-store .+ + +EPCC_CLI_READ_ONLY=true epcc create account --auto-fill && exit 1 || test $? -eq 4 +EPCC_CLI_READ_ONLY=true epcc update account 00000000-0000-0000-0000-000000000000 name foo && exit 1 || test $? -eq 4 +EPCC_CLI_READ_ONLY=true epcc delete account 00000000-0000-0000-0000-000000000000 && exit 1 || test $? -eq 4 +EPCC_CLI_READ_ONLY=true epcc reset-store .+ && exit 1 || test $? -eq 4 +EPCC_CLI_READ_ONLY=true epcc delete-all accounts && exit 1 || test $? -eq 4 + +echo "Read-Only Mode Smoke Test Passed" echo "Starting Currencies Runbook" epcc reset-store .+