diff --git a/internal/api/client.go b/internal/api/client.go index bb43be6..7427d0d 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -119,8 +119,12 @@ func NewClient(cfg *config.Config, log *debug.Logger) (*Client, error) { host: host, } + var rt http.RoundTripper = transport + if cfg.ReadOnly { + rt = &readOnlyTransport{base: transport} + } c := &Client{ - httpClient: &http.Client{Transport: transport}, + httpClient: &http.Client{Transport: rt}, baseURL: baseURL, debugLog: log, } diff --git a/internal/api/read_only.go b/internal/api/read_only.go new file mode 100644 index 0000000..0255934 --- /dev/null +++ b/internal/api/read_only.go @@ -0,0 +1,34 @@ +package api + +import ( + "fmt" + "net/http" +) + +// ErrReadOnly is returned when a mutating request is attempted while the +// client is in read-only mode. The request is never sent. +type ErrReadOnly struct { + Method string + Path string +} + +func (e *ErrReadOnly) Error() string { + return fmt.Sprintf( + "read-only mode enabled: refusing %s %s (unset --read-only / REDMINE_READ_ONLY to allow writes)", + e.Method, e.Path, + ) +} + +// readOnlyTransport rejects any non-read HTTP method before delegating to base. +type readOnlyTransport struct { + base http.RoundTripper +} + +func (t *readOnlyTransport) RoundTrip(req *http.Request) (*http.Response, error) { + switch req.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return t.base.RoundTrip(req) + default: + return nil, &ErrReadOnly{Method: req.Method, Path: req.URL.Path} + } +} diff --git a/internal/api/read_only_test.go b/internal/api/read_only_test.go new file mode 100644 index 0000000..87f4d31 --- /dev/null +++ b/internal/api/read_only_test.go @@ -0,0 +1,46 @@ +package api + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/aarondpn/redmine-cli/v2/internal/config" + "github.com/aarondpn/redmine-cli/v2/internal/debug" +) + +func TestReadOnlyBlocksWritesAllowsReads(t *testing.T) { + var hits int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + cfg := &config.Config{Server: srv.URL, APIKey: "k", AuthMethod: "apikey", ReadOnly: true} + c, err := NewClient(cfg, debug.New(nil)) + if err != nil { + t.Fatal(err) + } + + // Write is refused before reaching the server. + err = c.Post(context.Background(), "/issues.json", map[string]any{}, nil) + var roErr *ErrReadOnly + if !errors.As(err, &roErr) { + t.Fatalf("Post error = %v, want *ErrReadOnly", err) + } + if hits != 0 { + t.Fatalf("server received %d requests, want 0", hits) + } + + // Read still works. + if err := c.Get(context.Background(), "/issues.json", nil, &struct{}{}); err != nil { + t.Fatalf("Get failed: %v", err) + } + if hits != 1 { + t.Fatalf("server received %d requests, want 1", hits) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index f2d2362..fdc870a 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -55,6 +55,7 @@ func NewRootCmdWithFactory(version string) (*cobra.Command, *cmdutil.Factory) { verbose bool cfgFile string outputFormat string + readOnly bool ) cmd := &cobra.Command{ @@ -72,6 +73,9 @@ func NewRootCmdWithFactory(version string) (*cobra.Command, *cmdutil.Factory) { } f.Verbose = verbose f.OutputFormat = outputFormat + if cmd.Flags().Changed("read-only") { + f.ReadOnly = &readOnly + } return nil }, SilenceUsage: true, @@ -86,6 +90,7 @@ func NewRootCmdWithFactory(version string) (*cobra.Command, *cmdutil.Factory) { cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable debug logging") cmd.PersistentFlags().StringVar(&cfgFile, "config", "", "Config file path (default ~/.redmine-cli.yaml)") cmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "", "Output format: table, json, csv") + cmd.PersistentFlags().BoolVar(&readOnly, "read-only", false, "Refuse all write requests (GET/HEAD only)") _ = cmd.RegisterFlagCompletionFunc("output", cmdutil.CompleteOutputFormat) // Version diff --git a/internal/cmdutil/factory.go b/internal/cmdutil/factory.go index 7384999..7bec0e2 100644 --- a/internal/cmdutil/factory.go +++ b/internal/cmdutil/factory.go @@ -30,6 +30,10 @@ type Factory struct { APIKeyOverride string NoColorOverride bool + // ReadOnly, when non-nil, is the --read-only flag value and overrides + // both REDMINE_READ_ONLY and the per-profile config (highest precedence). + ReadOnly *bool + // OutputFormat is set by the root persistent --output/-o flag and used // as the default format when commands call Printer(""). OutputFormat string @@ -94,6 +98,9 @@ func (f *Factory) Config() (*config.Config, error) { if f.NoColorOverride { cfg.NoColor = true } + if f.ReadOnly != nil { + cfg.ReadOnly = *f.ReadOnly + } f.config = cfg return cfg, nil diff --git a/internal/cmdutil/factory_test.go b/internal/cmdutil/factory_test.go index d1ba222..3112e3a 100644 --- a/internal/cmdutil/factory_test.go +++ b/internal/cmdutil/factory_test.go @@ -337,3 +337,41 @@ func testFactoryWithConfig(t *testing.T, body string) *Factory { } var _ = config.Config{} + +func TestFactoryReadOnlyFlagBeatsConfig(t *testing.T) { + // File says read_only: true... + cfgPath := writeConfigFile(t, "active_profile: default\nprofiles:\n default:\n server: https://x\n api_key: k\n read_only: true\n") + + // ...but the flag explicitly sets it false (highest precedence). + ff := false + f := NewFactory() + f.ConfigPath = cfgPath + f.ProfileOverride = "default" + f.ReadOnly = &ff + + cfg, err := f.Config() + if err != nil { + t.Fatal(err) + } + if cfg.ReadOnly { + t.Fatal("flag --read-only=false must override config read_only: true") + } +} + +func TestFactoryReadOnlyEnvWhenNoFlag(t *testing.T) { + cfgPath := writeConfigFile(t, "active_profile: default\nprofiles:\n default:\n server: https://x\n api_key: k\n") + t.Setenv("REDMINE_READ_ONLY", "true") + + f := NewFactory() + f.ConfigPath = cfgPath + f.ProfileOverride = "default" + // f.ReadOnly stays nil (flag not set) + + cfg, err := f.Config() + if err != nil { + t.Fatal(err) + } + if !cfg.ReadOnly { + t.Fatal("REDMINE_READ_ONLY=true must apply when --read-only is not set") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 9e296ce..5ba3e7c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -340,6 +340,10 @@ func applyEnvOverrides(cfg *Config, log *debug.Logger) { cfg.NoColor = true } + if val := os.Getenv("REDMINE_READ_ONLY"); val != "" { + cfg.ReadOnly = parseBoolEnv(val) + } + mcpListEnv := map[string]*[]string{ "REDMINE_MCP_ENABLE_GROUPS": &cfg.MCP.EnableGroups, "REDMINE_MCP_DISABLE_GROUPS": &cfg.MCP.DisableGroups, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0276936..f2a68c5 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -544,3 +544,18 @@ func TestApplyEnvOverrides_MCPAuthToken(t *testing.T) { t.Errorf("AuthToken = %q, want from-env (env should override file)", cfg.MCP.AuthToken) } } + +func TestReadOnlyEnvOverride(t *testing.T) { + cfgPath := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(cfgPath, []byte("profiles:\n default:\n server: https://x\n api_key: k\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("REDMINE_READ_ONLY", "1") + cfg, err := Load(cfgPath, "default", debug.New(nil)) + if err != nil { + t.Fatal(err) + } + if !cfg.ReadOnly { + t.Fatal("expected ReadOnly=true from REDMINE_READ_ONLY=1") + } +} diff --git a/internal/config/types.go b/internal/config/types.go index 45518bb..8532b13 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -10,6 +10,7 @@ type Config struct { DefaultProject string `mapstructure:"default_project" yaml:"default_project,omitempty"` OutputFormat string `mapstructure:"output_format" yaml:"output_format,omitempty"` // "table", "json", "csv" NoColor bool `mapstructure:"no_color" yaml:"no_color,omitempty"` + ReadOnly bool `mapstructure:"read_only" yaml:"read_only,omitempty"` MCP MCPConfig `mapstructure:"mcp" yaml:"mcp,omitempty"` }