From c3bcdf2ba4e270efb80650d3ea95f00b7309e17c Mon Sep 17 00:00:00 2001 From: Anthony Gomez Date: Fri, 19 Dec 2025 12:44:51 -0500 Subject: [PATCH 1/8] feat(ngwaf/rules): adds account level and workspace level operations for ngwaf rules using json file inputs --- CHANGELOG.md | 1 + pkg/commands/commands.go | 26 ++ pkg/commands/ngwaf/rule/create.go | 99 +++++ pkg/commands/ngwaf/rule/delete.go | 84 ++++ pkg/commands/ngwaf/rule/doc.go | 2 + pkg/commands/ngwaf/rule/get.go | 76 ++++ pkg/commands/ngwaf/rule/list.go | 86 ++++ pkg/commands/ngwaf/rule/root.go | 31 ++ pkg/commands/ngwaf/rule/rule_test.go | 386 +++++++++++++++++ .../ngwaf/rule/testdata/test_rule.json | 20 + pkg/commands/ngwaf/rule/update.go | 103 +++++ .../ngwaf/workspace/customsignal/create.go | 2 +- .../ngwaf/workspace/customsignal/delete.go | 4 +- .../ngwaf/workspace/customsignal/get.go | 2 +- pkg/commands/ngwaf/workspace/rule/create.go | 108 +++++ pkg/commands/ngwaf/workspace/rule/delete.go | 94 ++++ pkg/commands/ngwaf/workspace/rule/doc.go | 2 + pkg/commands/ngwaf/workspace/rule/get.go | 86 ++++ pkg/commands/ngwaf/workspace/rule/list.go | 100 +++++ pkg/commands/ngwaf/workspace/rule/root.go | 31 ++ .../ngwaf/workspace/rule/rule_test.go | 408 ++++++++++++++++++ .../workspace/rule/testdata/test_rule.json | 20 + pkg/commands/ngwaf/workspace/rule/update.go | 112 +++++ .../ngwaf/workspace/stringlist/delete.go | 2 +- pkg/text/customsignal.go | 14 +- pkg/text/rule.go | 46 ++ 26 files changed, 1933 insertions(+), 12 deletions(-) create mode 100644 pkg/commands/ngwaf/rule/create.go create mode 100644 pkg/commands/ngwaf/rule/delete.go create mode 100644 pkg/commands/ngwaf/rule/doc.go create mode 100644 pkg/commands/ngwaf/rule/get.go create mode 100644 pkg/commands/ngwaf/rule/list.go create mode 100644 pkg/commands/ngwaf/rule/root.go create mode 100644 pkg/commands/ngwaf/rule/rule_test.go create mode 100644 pkg/commands/ngwaf/rule/testdata/test_rule.json create mode 100644 pkg/commands/ngwaf/rule/update.go create mode 100644 pkg/commands/ngwaf/workspace/rule/create.go create mode 100644 pkg/commands/ngwaf/workspace/rule/delete.go create mode 100644 pkg/commands/ngwaf/workspace/rule/doc.go create mode 100644 pkg/commands/ngwaf/workspace/rule/get.go create mode 100644 pkg/commands/ngwaf/workspace/rule/list.go create mode 100644 pkg/commands/ngwaf/workspace/rule/root.go create mode 100644 pkg/commands/ngwaf/workspace/rule/rule_test.go create mode 100644 pkg/commands/ngwaf/workspace/rule/testdata/test_rule.json create mode 100644 pkg/commands/ngwaf/workspace/rule/update.go create mode 100644 pkg/text/rule.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 94749cd6a..b5682bbd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Enhancements: - feat(rust): Allow testing with prerelease Rust versions ([#1604](https://github.com/fastly/cli/pull/1604)) - feat(compute/hashfiles): remove hashsum subcommand ([#1608](https://github.com/fastly/cli/pull/1608)) +- feat(commands/ngwaf/rules): add support for CRUD operations for NGWAF rules ([#1578](https://github.com/fastly/cli/pull/1605)) ### Bug fixes: diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go index cd2dced11..0e744b566 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -59,6 +59,7 @@ import ( "github.com/fastly/cli/pkg/commands/ngwaf/countrylist" "github.com/fastly/cli/pkg/commands/ngwaf/customsignal" "github.com/fastly/cli/pkg/commands/ngwaf/iplist" + "github.com/fastly/cli/pkg/commands/ngwaf/rule" "github.com/fastly/cli/pkg/commands/ngwaf/signallist" "github.com/fastly/cli/pkg/commands/ngwaf/stringlist" "github.com/fastly/cli/pkg/commands/ngwaf/wildcardlist" @@ -76,6 +77,7 @@ import ( wscustomsignal "github.com/fastly/cli/pkg/commands/ngwaf/workspace/customsignal" wsiplist "github.com/fastly/cli/pkg/commands/ngwaf/workspace/iplist" "github.com/fastly/cli/pkg/commands/ngwaf/workspace/redaction" + workspaceRule "github.com/fastly/cli/pkg/commands/ngwaf/workspace/rule" wssignallistlist "github.com/fastly/cli/pkg/commands/ngwaf/workspace/signallist" wsstringlistlist "github.com/fastly/cli/pkg/commands/ngwaf/workspace/stringlist" "github.com/fastly/cli/pkg/commands/ngwaf/workspace/threshold" @@ -449,6 +451,12 @@ func Define( // nolint:revive // function-length ngwafIPListGet := iplist.NewGetCommand(ngwafIPListRoot.CmdClause, data) ngwafIPListList := iplist.NewListCommand(ngwafIPListRoot.CmdClause, data) ngwafIPListUpdate := iplist.NewUpdateCommand(ngwafIPListRoot.CmdClause, data) + ngwafRuleRoot := rule.NewRootCommand(ngwafRoot.CmdClause, data) + ngwafRuleCreate := rule.NewCreateCommand(ngwafRuleRoot.CmdClause, data) + ngwafRuleDelete := rule.NewDeleteCommand(ngwafRuleRoot.CmdClause, data) + ngwafRuleGet := rule.NewGetCommand(ngwafRuleRoot.CmdClause, data) + ngwafRuleList := rule.NewListCommand(ngwafRuleRoot.CmdClause, data) + ngwafRuleUpdate := rule.NewUpdateCommand(ngwafRuleRoot.CmdClause, data) ngwafSignalListRoot := signallist.NewRootCommand(ngwafRoot.CmdClause, data) ngwafSignalListCreate := signallist.NewCreateCommand(ngwafSignalListRoot.CmdClause, data) ngwafSignalListDelete := signallist.NewDeleteCommand(ngwafSignalListRoot.CmdClause, data) @@ -485,6 +493,12 @@ func Define( // nolint:revive // function-length ngwafWorkspaceIPListGet := wsiplist.NewGetCommand(ngwafWorkspaceIPListRoot.CmdClause, data) ngwafWorkspaceIPListList := wsiplist.NewListCommand(ngwafWorkspaceIPListRoot.CmdClause, data) ngwafWorkspaceIPListUpdate := wsiplist.NewUpdateCommand(ngwafWorkspaceIPListRoot.CmdClause, data) + ngwafWorkspaceRuleRoot := workspaceRule.NewRootCommand(ngwafWorkspaceRoot.CmdClause, data) + ngwafWorkspaceRuleCreate := workspaceRule.NewCreateCommand(ngwafWorkspaceRuleRoot.CmdClause, data) + ngwafWorkspaceRuleDelete := workspaceRule.NewDeleteCommand(ngwafWorkspaceRuleRoot.CmdClause, data) + ngwafWorkspaceRuleGet := workspaceRule.NewGetCommand(ngwafWorkspaceRuleRoot.CmdClause, data) + ngwafWorkspaceRuleList := workspaceRule.NewListCommand(ngwafWorkspaceRuleRoot.CmdClause, data) + ngwafWorkspaceRuleUpdate := workspaceRule.NewUpdateCommand(ngwafWorkspaceRuleRoot.CmdClause, data) ngwafWorkspaceSignalListRoot := wssignallistlist.NewRootCommand(ngwafWorkspaceRoot.CmdClause, data) ngwafWorkspaceSignalListCreate := wssignallistlist.NewCreateCommand(ngwafWorkspaceSignalListRoot.CmdClause, data) ngwafWorkspaceSignalListDelete := wssignallistlist.NewDeleteCommand(ngwafWorkspaceSignalListRoot.CmdClause, data) @@ -1007,6 +1021,12 @@ func Define( // nolint:revive // function-length ngwafIPListGet, ngwafIPListList, ngwafIPListUpdate, + ngwafRuleRoot, + ngwafRuleCreate, + ngwafRuleDelete, + ngwafRuleGet, + ngwafRuleList, + ngwafRuleUpdate, ngwafSignalListRoot, ngwafSignalListCreate, ngwafSignalListDelete, @@ -1042,6 +1062,12 @@ func Define( // nolint:revive // function-length ngwafWorkspaceIPListGet, ngwafWorkspaceIPListList, ngwafWorkspaceIPListUpdate, + ngwafWorkspaceRuleRoot, + ngwafWorkspaceRuleCreate, + ngwafWorkspaceRuleDelete, + ngwafWorkspaceRuleGet, + ngwafWorkspaceRuleList, + ngwafWorkspaceRuleUpdate, ngwafWorkspaceSignalListRoot, ngwafWorkspaceSignalListCreate, ngwafWorkspaceSignalListDelete, diff --git a/pkg/commands/ngwaf/rule/create.go b/pkg/commands/ngwaf/rule/create.go new file mode 100644 index 000000000..644f0098d --- /dev/null +++ b/pkg/commands/ngwaf/rule/create.go @@ -0,0 +1,99 @@ +package rule + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "os" + "path/filepath" + + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/rules" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/scope" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create account-level rules. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + path string +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create an account-level rule").Alias("add") + + // Required. + c.CmdClause.Flag("path", "Path to a json file that contains the rule schema.").Required().StringVar(&c.path) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + var err error + input := &rules.CreateInput{} + if c.path != "" { + path, err := filepath.Abs(c.path) + if err != nil { + return fmt.Errorf("error parsing path '%s': %q", c.path, err) + } + + jsonFile, err := os.Open(path) + if err != nil { + return fmt.Errorf("error reading cert-path '%s': %q", c.path, err) + } + defer jsonFile.Close() + + byteValue, err := io.ReadAll(jsonFile) + if err != nil { + log.Fatalf("failed to read json file: %v", err) + } + + if err := json.Unmarshal(byteValue, input); err != nil { + log.Fatalf("failed to unmarshal json data: %v", err) + } + } + input.Scope = &scope.Scope{ + Type: scope.ScopeTypeAccount, + AppliesTo: []string{"*"}, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := rules.Create(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.Success(out, "Created account-level rule with ID %s", data.RuleID) + return nil +} diff --git a/pkg/commands/ngwaf/rule/delete.go b/pkg/commands/ngwaf/rule/delete.go new file mode 100644 index 000000000..863beab86 --- /dev/null +++ b/pkg/commands/ngwaf/rule/delete.go @@ -0,0 +1,84 @@ +package rule + +import ( + "context" + "errors" + "io" + + "github.com/fastly/go-fastly/v12/fastly" + + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/rules" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/scope" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete an account-level rule. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + ruleID string +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("delete", "Delete an account-level rule") + + // Required. + c.CmdClause.Flag("rule-id", "Rule ID").Required().StringVar(&c.ruleID) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + err := rules.Delete(context.TODO(), fc, &rules.DeleteInput{ + RuleID: &c.ruleID, + Scope: &scope.Scope{ + Type: scope.ScopeTypeAccount, + AppliesTo: []string{"*"}, + }, + }) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if c.JSONOutput.Enabled { + o := struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` + }{ + c.ruleID, + true, + } + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, "Deleted account-level rule with id: %s", c.ruleID) + return nil +} diff --git a/pkg/commands/ngwaf/rule/doc.go b/pkg/commands/ngwaf/rule/doc.go new file mode 100644 index 000000000..cf2cbc40b --- /dev/null +++ b/pkg/commands/ngwaf/rule/doc.go @@ -0,0 +1,2 @@ +// Package rule contains commands to inspect and manipulate NGWAF account-level rules. +package rule diff --git a/pkg/commands/ngwaf/rule/get.go b/pkg/commands/ngwaf/rule/get.go new file mode 100644 index 000000000..211b61248 --- /dev/null +++ b/pkg/commands/ngwaf/rule/get.go @@ -0,0 +1,76 @@ +package rule + +import ( + "context" + "errors" + "io" + + "github.com/fastly/go-fastly/v12/fastly" + + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/rules" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/scope" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// GetCommand calls the Fastly API to get an account-level rule. +type GetCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + ruleID string +} + +// NewGetCommand returns a usable command registered under the parent. +func NewGetCommand(parent argparser.Registerer, g *global.Data) *GetCommand { + c := GetCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("get", "Get an account-level rule") + + // Required. + c.CmdClause.Flag("rule-id", "Rule ID").Required().StringVar(&c.ruleID) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *GetCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := rules.Get(context.TODO(), fc, &rules.GetInput{ + RuleID: &c.ruleID, + Scope: &scope.Scope{ + Type: scope.ScopeTypeAccount, + AppliesTo: []string{"*"}, + }, + }) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.PrintRule(out, data) + return nil +} diff --git a/pkg/commands/ngwaf/rule/list.go b/pkg/commands/ngwaf/rule/list.go new file mode 100644 index 000000000..9ca272fed --- /dev/null +++ b/pkg/commands/ngwaf/rule/list.go @@ -0,0 +1,86 @@ +package rule + +import ( + "context" + "errors" + "io" + "strconv" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/rules" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/scope" +) + +// ListCommand calls the Fastly API to list all account-level rules for your API token. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + // Optional. + action argparser.OptionalString + enabled argparser.OptionalString +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("list", "List all account-level rules") + + // Optional. + c.CmdClause.Flag("action", "Filter rules based on action.").Action(c.action.Set).StringVar(&c.action.Value) + c.CmdClause.Flag("enabled", "Filter rules based on whether the rule is enabled.").Action(c.enabled.Set).StringVar(&c.enabled.Value) + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + input := &rules.ListInput{ + Scope: &scope.Scope{ + Type: scope.ScopeTypeAccount, + AppliesTo: []string{"*"}, + }, + } + + if c.action.WasSet { + input.Action = &c.action.Value + } + + if c.enabled.WasSet { + enabled, _ := strconv.ParseBool(c.enabled.Value) + input.Enabled = &enabled + } + + rules, err := rules.List(context.TODO(), fc, input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, rules); ok { + return err + } + + text.PrintRuleTbl(out, rules.Data) + return nil +} diff --git a/pkg/commands/ngwaf/rule/root.go b/pkg/commands/ngwaf/rule/root.go new file mode 100644 index 000000000..754778287 --- /dev/null +++ b/pkg/commands/ngwaf/rule/root.go @@ -0,0 +1,31 @@ +package rule + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "rule" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manage NGWAF Account-Level Rules") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/ngwaf/rule/rule_test.go b/pkg/commands/ngwaf/rule/rule_test.go new file mode 100644 index 000000000..c939167f1 --- /dev/null +++ b/pkg/commands/ngwaf/rule/rule_test.go @@ -0,0 +1,386 @@ +package rule_test + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "testing" + + root "github.com/fastly/cli/pkg/commands/ngwaf" + sub "github.com/fastly/cli/pkg/commands/ngwaf/rule" + fstfmt "github.com/fastly/cli/pkg/fmt" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/rules" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/scope" +) + +const ( + ruleDescription = "Utility requests" + ruleEnabled = true + ruleAction = "allow" + ruleID = "someID" + rulePath = "testdata/test_rule.json" + ruleType = "request" +) + +var rule = rules.Rule{ + CreatedAt: testutil.Date, + Description: ruleDescription, + Enabled: ruleEnabled, + RuleID: ruleID, + Actions: []rules.Action{ + { + Type: ruleAction, + }, + }, + Type: ruleType, + Scope: rules.Scope{ + Type: string(scope.ScopeTypeAccount), + AppliesTo: []string{"*"}, + }, +} + +func TestRuleCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --path flag", + Args: "", + WantError: "error parsing arguments: required flag --path not provided", + }, + { + Name: "validate internal server error", + Args: fmt.Sprintf("--path %s", rulePath), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + Status: http.StatusText(http.StatusInternalServerError), + }, + }, + }, + WantError: "500 - Internal Server Error", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--path %s", rulePath), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(rule)))), + }, + }, + }, + WantOutput: fstfmt.Success("Created account-level rule with ID %s", ruleID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--path %s --json", rulePath), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(rule))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(rule), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) +} + +func TestRuleDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --rule-id flag", + Args: "", + WantError: "error parsing arguments: required flag --rule-id not provided", + }, + { + Name: "validate bad request", + Args: "--rule-id bar", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "invalid rule ID", + "status": 400 + } + `))), + }, + }, + }, + WantError: "400 - Bad Request", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--rule-id %s", ruleID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + }, + }, + }, + WantOutput: fstfmt.Success("Deleted account-level rule with id: %s", ruleID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--rule-id %s --json", ruleID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + }, + }, + }, + WantOutput: fstfmt.JSON(`{"id": %q, "deleted": true}`, ruleID), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) +} + +func TestRuleGet(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --rule-id flag", + Args: "", + WantError: "error parsing arguments: required flag --rule-id not provided", + }, + { + Name: "validate bad request", + Args: "--rule-id baz", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "invalid Rule ID", + "status": 400 + } + `))), + }, + }, + }, + WantError: "400 - Bad Request", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--rule-id %s", ruleID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(rule)))), + }, + }, + }, + WantOutput: ruleString, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--rule-id %s --json", ruleID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(rule)))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(rule), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "get"}, scenarios) +} + +func TestRuleList(t *testing.T) { + rulesObject := rules.Rules{ + Data: []rules.Rule{ + { + CreatedAt: testutil.Date, + Description: ruleDescription, + Enabled: ruleEnabled, + RuleID: ruleID, + Actions: []rules.Action{ + { + Type: ruleAction, + }, + }, + Type: ruleType, + Scope: rules.Scope{ + Type: string(scope.ScopeTypeAccount), + AppliesTo: []string{"*"}, + }, + }, + { + CreatedAt: testutil.Date, + Description: ruleDescription + "2", + Enabled: ruleEnabled, + RuleID: ruleID + "2", + Actions: []rules.Action{ + { + Type: ruleAction, + }, + }, + Type: ruleType, + Scope: rules.Scope{ + Type: string(scope.ScopeTypeAccount), + AppliesTo: []string{"*"}, + }, + }, + }, + Meta: rules.MetaRules{}, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate internal server error", + Args: "", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + Status: http.StatusText(http.StatusInternalServerError), + }, + }, + }, + WantError: "500 - Internal Server Error", + }, + { + Name: "validate API success (zero account-level Rules)", + Args: "", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(rules.Rules{ + Data: []rules.Rule{}, + Meta: rules.MetaRules{}, + }))), + }, + }, + }, + WantOutput: zeroListRulesString, + }, + { + Name: "validate API success", + Args: "", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(rulesObject))), + }, + }, + }, + WantOutput: listRulesString, + }, + { + Name: "validate optional --json flag", + Args: "--json", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(rulesObject))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(rulesObject), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list"}, scenarios) +} + +func TestRuleUpdate(t *testing.T) { + ruleObject := rules.Rule{ + CreatedAt: testutil.Date, + Description: ruleDescription, + RuleID: ruleID, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --rule-id flag", + Args: fmt.Sprintf("--path %s", rulePath), + WantError: "error parsing arguments: required flag --rule-id not provided", + }, + { + Name: "validate missing --path flag", + Args: fmt.Sprintf("--rule-id %s", ruleID), + WantError: "error parsing arguments: required flag --path not provided", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--rule-id %s --path %s", ruleID, rulePath), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(ruleObject))), + }, + }, + }, + WantOutput: fstfmt.Success("Updated account-level rule with id: %s", ruleID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--rule-id %s --path %s --json", ruleID, rulePath), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(rule))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(rule), + }, + } + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) +} + +var listRulesString = strings.TrimSpace(` +ID Action Description Enabled Type Scope Updated At Created At +someID allow Utility requests true request account 0001-01-01 00:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC +someID2 allow Utility requests2 true request account 0001-01-01 00:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC +`) + "\n" + +var zeroListRulesString = strings.TrimSpace(` +ID Action Description Enabled Type Scope Updated At Created At +`) + "\n" + +var ruleString = strings.TrimSpace(` +ID: someID +Action: allow +Description: Utility requests +Enabled: true +Type: request +Scope: account +Updated (UTC): 0001-01-01 00:00 +Created (UTC): 2021-06-15 23:00 +`) diff --git a/pkg/commands/ngwaf/rule/testdata/test_rule.json b/pkg/commands/ngwaf/rule/testdata/test_rule.json new file mode 100644 index 000000000..912a61501 --- /dev/null +++ b/pkg/commands/ngwaf/rule/testdata/test_rule.json @@ -0,0 +1,20 @@ +{ + "type": "request", + "enabled": true, + "description": "Utility requests", + "group_operator": "all", + "request_logging": "sampled", + "conditions": [ + { + "type": "single", + "field": "path", + "operator": "equals", + "value": "/echo.json" + } + ], + "actions": [ + { + "type": "allow" + } + ] +} \ No newline at end of file diff --git a/pkg/commands/ngwaf/rule/update.go b/pkg/commands/ngwaf/rule/update.go new file mode 100644 index 000000000..ce756ca52 --- /dev/null +++ b/pkg/commands/ngwaf/rule/update.go @@ -0,0 +1,103 @@ +package rule + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "os" + "path/filepath" + + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/rules" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/scope" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update an account-level rule. +type UpdateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + path string + ruleID string +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a workspace") + + // Required. + c.CmdClause.Flag("rule-id", "Rule ID").Required().StringVar(&c.ruleID) + c.CmdClause.Flag("path", "Path to a json file that contains the rule schema.").Required().StringVar(&c.path) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + var err error + input := &rules.UpdateInput{ + RuleID: &c.ruleID, + } + if c.path != "" { + path, err := filepath.Abs(c.path) + if err != nil { + return fmt.Errorf("error parsing path '%s': %q", c.path, err) + } + + jsonFile, err := os.Open(path) + if err != nil { + return fmt.Errorf("error reading cert-path '%s': %q", c.path, err) + } + defer jsonFile.Close() + + byteValue, err := io.ReadAll(jsonFile) + if err != nil { + log.Fatalf("failed to read json file: %v", err) + } + + if err := json.Unmarshal(byteValue, input); err != nil { + log.Fatalf("failed to unmarshal json data: %v", err) + } + } + input.Scope = &scope.Scope{ + Type: scope.ScopeTypeAccount, + AppliesTo: []string{"*"}, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := rules.Update(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.Success(out, "Updated account-level rule with id: %s", data.RuleID) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/customsignal/create.go b/pkg/commands/ngwaf/workspace/customsignal/create.go index ceef1512d..7f568ed5a 100644 --- a/pkg/commands/ngwaf/workspace/customsignal/create.go +++ b/pkg/commands/ngwaf/workspace/customsignal/create.go @@ -35,7 +35,7 @@ func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateComman Globals: g, }, } - c.CmdClause = parent.Command("create", "Create an workspace-level custom signal").Alias("add") + c.CmdClause = parent.Command("create", "Create a workspace-level custom signal").Alias("add") // Required. c.CmdClause.Flag("name", "User submitted display name of a custom signal. Is immutable and must be between 3 and 25 characters").Required().StringVar(&c.name) diff --git a/pkg/commands/ngwaf/workspace/customsignal/delete.go b/pkg/commands/ngwaf/workspace/customsignal/delete.go index c2d5b6426..5b7c22e4a 100644 --- a/pkg/commands/ngwaf/workspace/customsignal/delete.go +++ b/pkg/commands/ngwaf/workspace/customsignal/delete.go @@ -16,7 +16,7 @@ import ( "github.com/fastly/cli/pkg/text" ) -// DeleteCommand calls the Fastly API to delete an workspace-level custom signal. +// DeleteCommand calls the Fastly API to delete a workspace-level custom signal. type DeleteCommand struct { argparser.Base argparser.JSONOutput @@ -34,7 +34,7 @@ func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteComman }, } - c.CmdClause = parent.Command("delete", "Delete an workspace-level custom signal") + c.CmdClause = parent.Command("delete", "Delete a workspace-level custom signal") // Required. c.CmdClause.Flag("signal-id", "Custom Signal ID").Required().StringVar(&c.signalID) diff --git a/pkg/commands/ngwaf/workspace/customsignal/get.go b/pkg/commands/ngwaf/workspace/customsignal/get.go index 482cf3cd9..0c2b913a9 100644 --- a/pkg/commands/ngwaf/workspace/customsignal/get.go +++ b/pkg/commands/ngwaf/workspace/customsignal/get.go @@ -16,7 +16,7 @@ import ( "github.com/fastly/cli/pkg/text" ) -// GetCommand calls the Fastly API to get an workspace-level custom signal. +// GetCommand calls the Fastly API to get a workspace-level custom signal. type GetCommand struct { argparser.Base argparser.JSONOutput diff --git a/pkg/commands/ngwaf/workspace/rule/create.go b/pkg/commands/ngwaf/workspace/rule/create.go new file mode 100644 index 000000000..152666cea --- /dev/null +++ b/pkg/commands/ngwaf/workspace/rule/create.go @@ -0,0 +1,108 @@ +package rule + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "os" + "path/filepath" + + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/rules" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/scope" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create workspace-level rules. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + path string + workspaceID argparser.OptionalWorkspaceID +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a workspace-level rule").Alias("add") + + // Required. + c.CmdClause.Flag("path", "Path to a json file that contains the rule schema.").Required().StringVar(&c.path) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.workspaceID.Value, + Action: c.workspaceID.Set, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + if err := c.workspaceID.Parse(); err != nil { + return err + } + input := &rules.CreateInput{} + if c.path != "" { + path, err := filepath.Abs(c.path) + if err != nil { + return fmt.Errorf("error parsing path '%s': %q", c.path, err) + } + + jsonFile, err := os.Open(path) + if err != nil { + return fmt.Errorf("error reading cert-path '%s': %q", c.path, err) + } + defer jsonFile.Close() + + byteValue, err := io.ReadAll(jsonFile) + if err != nil { + log.Fatalf("failed to read json file: %v", err) + } + + if err := json.Unmarshal(byteValue, input); err != nil { + log.Fatalf("failed to unmarshal json data: %v", err) + } + } + input.Scope = &scope.Scope{ + Type: scope.ScopeTypeWorkspace, + AppliesTo: []string{c.workspaceID.Value}, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := rules.Create(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.Success(out, "Created workspace-level rule with ID %s", data.RuleID) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/rule/delete.go b/pkg/commands/ngwaf/workspace/rule/delete.go new file mode 100644 index 000000000..94d0340e8 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/rule/delete.go @@ -0,0 +1,94 @@ +package rule + +import ( + "context" + "errors" + "io" + + "github.com/fastly/go-fastly/v12/fastly" + + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/rules" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/scope" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete a workspace-level rule. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + ruleID string + workspaceID argparser.OptionalWorkspaceID +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("delete", "Delete a workspace-level rule") + + // Required. + c.CmdClause.Flag("rule-id", "Rule ID").Required().StringVar(&c.ruleID) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.workspaceID.Value, + Action: c.workspaceID.Set, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + if err := c.workspaceID.Parse(); err != nil { + return err + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + err := rules.Delete(context.TODO(), fc, &rules.DeleteInput{ + RuleID: &c.ruleID, + Scope: &scope.Scope{ + Type: scope.ScopeTypeWorkspace, + AppliesTo: []string{c.workspaceID.Value}, + }, + }) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if c.JSONOutput.Enabled { + o := struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` + }{ + c.ruleID, + true, + } + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, "Deleted workspace-level rule with id: %s", c.ruleID) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/rule/doc.go b/pkg/commands/ngwaf/workspace/rule/doc.go new file mode 100644 index 000000000..eeced65f7 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/rule/doc.go @@ -0,0 +1,2 @@ +// Package rule contains commands to inspect and manipulate NGWAF workspace-level rules. +package rule diff --git a/pkg/commands/ngwaf/workspace/rule/get.go b/pkg/commands/ngwaf/workspace/rule/get.go new file mode 100644 index 000000000..67e36b4cb --- /dev/null +++ b/pkg/commands/ngwaf/workspace/rule/get.go @@ -0,0 +1,86 @@ +package rule + +import ( + "context" + "errors" + "io" + + "github.com/fastly/go-fastly/v12/fastly" + + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/rules" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/scope" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// GetCommand calls the Fastly API to get a workspace-level rule. +type GetCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + ruleID string + workspaceID argparser.OptionalWorkspaceID +} + +// NewGetCommand returns a usable command registered under the parent. +func NewGetCommand(parent argparser.Registerer, g *global.Data) *GetCommand { + c := GetCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("get", "Get a workspace-level rule") + + // Required. + c.CmdClause.Flag("rule-id", "Rule ID").Required().StringVar(&c.ruleID) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.workspaceID.Value, + Action: c.workspaceID.Set, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *GetCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + if err := c.workspaceID.Parse(); err != nil { + return err + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := rules.Get(context.TODO(), fc, &rules.GetInput{ + RuleID: &c.ruleID, + Scope: &scope.Scope{ + Type: scope.ScopeTypeWorkspace, + AppliesTo: []string{c.workspaceID.Value}, + }, + }) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.PrintRule(out, data) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/rule/list.go b/pkg/commands/ngwaf/workspace/rule/list.go new file mode 100644 index 000000000..2b34ffc56 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/rule/list.go @@ -0,0 +1,100 @@ +package rule + +import ( + "context" + "errors" + "io" + "strconv" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/rules" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/scope" +) + +// ListCommand calls the Fastly API to list all workspace-level rules for your API token. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + workspaceID argparser.OptionalWorkspaceID + + // Optional. + action argparser.OptionalString + enabled argparser.OptionalString +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("list", "List all workspace-level rules") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.workspaceID.Value, + Action: c.workspaceID.Set, + }) + + // Optional. + c.CmdClause.Flag("action", "Filter rules based on action.").Action(c.action.Set).StringVar(&c.action.Value) + c.CmdClause.Flag("enabled", "Filter rules based on whether the rule is enabled.").Action(c.enabled.Set).StringVar(&c.enabled.Value) + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + if err := c.workspaceID.Parse(); err != nil { + return err + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + input := &rules.ListInput{ + Scope: &scope.Scope{ + Type: scope.ScopeTypeWorkspace, + AppliesTo: []string{c.workspaceID.Value}, + }, + } + + if c.action.WasSet { + input.Action = &c.action.Value + } + + if c.enabled.WasSet { + enabled, _ := strconv.ParseBool(c.enabled.Value) + input.Enabled = &enabled + } + + rules, err := rules.List(context.TODO(), fc, input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, rules); ok { + return err + } + + text.PrintRuleTbl(out, rules.Data) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/rule/root.go b/pkg/commands/ngwaf/workspace/rule/root.go new file mode 100644 index 000000000..754778287 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/rule/root.go @@ -0,0 +1,31 @@ +package rule + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "rule" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manage NGWAF Account-Level Rules") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/ngwaf/workspace/rule/rule_test.go b/pkg/commands/ngwaf/workspace/rule/rule_test.go new file mode 100644 index 000000000..b50421843 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/rule/rule_test.go @@ -0,0 +1,408 @@ +package rule_test + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "testing" + + root "github.com/fastly/cli/pkg/commands/ngwaf" + sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace" + sub2 "github.com/fastly/cli/pkg/commands/ngwaf/workspace/rule" + fstfmt "github.com/fastly/cli/pkg/fmt" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/rules" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/scope" +) + +const ( + ruleDescription = "Utility requests" + ruleEnabled = true + ruleAction = "allow" + ruleID = "someID" + rulePath = "testdata/test_rule.json" + ruleType = "request" + ruleWorkspaceID = "someWorkspaceID" +) + +var rule = rules.Rule{ + CreatedAt: testutil.Date, + Description: ruleDescription, + Enabled: ruleEnabled, + RuleID: ruleID, + Actions: []rules.Action{ + { + Type: ruleAction, + }, + }, + Type: ruleType, + Scope: rules.Scope{ + Type: string(scope.ScopeTypeWorkspace), + AppliesTo: []string{ruleWorkspaceID}, + }, +} + +func TestRuleCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --path flag", + Args: fmt.Sprintf("--workspace-id %s", ruleWorkspaceID), + WantError: "error parsing arguments: required flag --path not provided", + }, + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--path %s", rulePath), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate internal server error", + Args: fmt.Sprintf("--path %s --workspace-id %s", rulePath, ruleWorkspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + Status: http.StatusText(http.StatusInternalServerError), + }, + }, + }, + WantError: "500 - Internal Server Error", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--path %s --workspace-id %s", rulePath, ruleWorkspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(rule)))), + }, + }, + }, + WantOutput: fstfmt.Success("Created workspace-level rule with ID %s", ruleID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--path %s --workspace-id %s --json", rulePath, ruleWorkspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(rule))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(rule), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "create"}, scenarios) +} + +func TestRuleDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--rule-id %s", ruleID), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --rule-id flag", + Args: fmt.Sprintf("--workspace-id %s", ruleWorkspaceID), + WantError: "error parsing arguments: required flag --rule-id not provided", + }, + { + Name: "validate bad request", + Args: "--rule-id bar --workspace-id baz", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "invalid rule ID", + "status": 400 + } + `))), + }, + }, + }, + WantError: "400 - Bad Request", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--rule-id %s --workspace-id %s", ruleID, ruleWorkspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + }, + }, + }, + WantOutput: fstfmt.Success("Deleted workspace-level rule with id: %s", ruleID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--rule-id %s --workspace-id %s --json", ruleID, ruleWorkspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + }, + }, + }, + WantOutput: fstfmt.JSON(`{"id": %q, "deleted": true}`, ruleID), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "delete"}, scenarios) +} + +func TestRuleGet(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--rule-id %s", ruleID), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --rule-id flag", + Args: fmt.Sprintf("--workspace-id %s", ruleWorkspaceID), + WantError: "error parsing arguments: required flag --rule-id not provided", + }, + { + Name: "validate bad request", + Args: "--rule-id baz --workspace-id bar", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "invalid Rule ID", + "status": 400 + } + `))), + }, + }, + }, + WantError: "400 - Bad Request", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--rule-id %s --workspace-id %s", ruleID, ruleWorkspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(rule)))), + }, + }, + }, + WantOutput: ruleString, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--rule-id %s --workspace-id %s --json", ruleID, ruleWorkspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(rule)))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(rule), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "get"}, scenarios) +} + +func TestRuleList(t *testing.T) { + rulesObject := rules.Rules{ + Data: []rules.Rule{ + { + CreatedAt: testutil.Date, + Description: ruleDescription, + Enabled: ruleEnabled, + RuleID: ruleID, + Actions: []rules.Action{ + { + Type: ruleAction, + }, + }, + Type: ruleType, + Scope: rules.Scope{ + Type: string(scope.ScopeTypeWorkspace), + AppliesTo: []string{ruleWorkspaceID}, + }, + }, + { + CreatedAt: testutil.Date, + Description: ruleDescription + "2", + Enabled: ruleEnabled, + RuleID: ruleID + "2", + Actions: []rules.Action{ + { + Type: ruleAction, + }, + }, + Type: ruleType, + Scope: rules.Scope{ + Type: string(scope.ScopeTypeWorkspace), + AppliesTo: []string{ruleWorkspaceID}, + }, + }, + }, + Meta: rules.MetaRules{}, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate internal server error", + Args: "--workspace-id baz", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + Status: http.StatusText(http.StatusInternalServerError), + }, + }, + }, + WantError: "500 - Internal Server Error", + }, + { + Name: "validate API success (zero workspace-level Rules)", + Args: fmt.Sprintf("--workspace-id %s", ruleWorkspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(rules.Rules{ + Data: []rules.Rule{}, + Meta: rules.MetaRules{}, + }))), + }, + }, + }, + WantOutput: zeroListRulesString, + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s", ruleWorkspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(rulesObject))), + }, + }, + }, + WantOutput: listRulesString, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --json", ruleWorkspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(rulesObject))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(rulesObject), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "list"}, scenarios) +} + +func TestRuleUpdate(t *testing.T) { + ruleObject := rules.Rule{ + CreatedAt: testutil.Date, + Description: ruleDescription, + RuleID: ruleID, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --rule-id flag", + Args: fmt.Sprintf("--path %s --workspace-id %s", rulePath, ruleWorkspaceID), + WantError: "error parsing arguments: required flag --rule-id not provided", + }, + { + Name: "validate missing --path flag", + Args: fmt.Sprintf("--rule-id %s --workspace-id %s", ruleID, ruleWorkspaceID), + WantError: "error parsing arguments: required flag --path not provided", + }, + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--path %s --rule-id %s", rulePath, ruleID), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--rule-id %s --path %s --workspace-id %s", ruleID, rulePath, ruleWorkspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(ruleObject))), + }, + }, + }, + WantOutput: fstfmt.Success("Updated workspace-level rule with id: %s", ruleID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--rule-id %s --path %s --workspace-id %s --json", ruleID, rulePath, ruleWorkspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(rule))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(rule), + }, + } + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "update"}, scenarios) +} + +var listRulesString = strings.TrimSpace(` +ID Action Description Enabled Type Scope Updated At Created At +someID allow Utility requests true request workspace 0001-01-01 00:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC +someID2 allow Utility requests2 true request workspace 0001-01-01 00:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC +`) + "\n" + +var zeroListRulesString = strings.TrimSpace(` +ID Action Description Enabled Type Scope Updated At Created At +`) + "\n" + +var ruleString = strings.TrimSpace(` +ID: someID +Action: allow +Description: Utility requests +Enabled: true +Type: request +Scope: workspace +Updated (UTC): 0001-01-01 00:00 +Created (UTC): 2021-06-15 23:00 +`) diff --git a/pkg/commands/ngwaf/workspace/rule/testdata/test_rule.json b/pkg/commands/ngwaf/workspace/rule/testdata/test_rule.json new file mode 100644 index 000000000..912a61501 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/rule/testdata/test_rule.json @@ -0,0 +1,20 @@ +{ + "type": "request", + "enabled": true, + "description": "Utility requests", + "group_operator": "all", + "request_logging": "sampled", + "conditions": [ + { + "type": "single", + "field": "path", + "operator": "equals", + "value": "/echo.json" + } + ], + "actions": [ + { + "type": "allow" + } + ] +} \ No newline at end of file diff --git a/pkg/commands/ngwaf/workspace/rule/update.go b/pkg/commands/ngwaf/workspace/rule/update.go new file mode 100644 index 000000000..62704d941 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/rule/update.go @@ -0,0 +1,112 @@ +package rule + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "os" + "path/filepath" + + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/rules" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/scope" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update a workspace-level rule. +type UpdateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + path string + ruleID string + workspaceID argparser.OptionalWorkspaceID +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a workspace") + + // Required. + c.CmdClause.Flag("rule-id", "Rule ID").Required().StringVar(&c.ruleID) + c.CmdClause.Flag("path", "Path to a json file that contains the rule schema.").Required().StringVar(&c.path) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.workspaceID.Value, + Action: c.workspaceID.Set, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + if err := c.workspaceID.Parse(); err != nil { + return err + } + input := &rules.UpdateInput{ + RuleID: &c.ruleID, + } + if c.path != "" { + path, err := filepath.Abs(c.path) + if err != nil { + return fmt.Errorf("error parsing path '%s': %q", c.path, err) + } + + jsonFile, err := os.Open(path) + if err != nil { + return fmt.Errorf("error reading cert-path '%s': %q", c.path, err) + } + defer jsonFile.Close() + + byteValue, err := io.ReadAll(jsonFile) + if err != nil { + log.Fatalf("failed to read json file: %v", err) + } + + if err := json.Unmarshal(byteValue, input); err != nil { + log.Fatalf("failed to unmarshal json data: %v", err) + } + } + input.Scope = &scope.Scope{ + Type: scope.ScopeTypeWorkspace, + AppliesTo: []string{c.workspaceID.Value}, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := rules.Update(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.Success(out, "Updated workspace-level rule with id: %s", data.RuleID) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/stringlist/delete.go b/pkg/commands/ngwaf/workspace/stringlist/delete.go index f0a7850a3..4b77c2c23 100644 --- a/pkg/commands/ngwaf/workspace/stringlist/delete.go +++ b/pkg/commands/ngwaf/workspace/stringlist/delete.go @@ -32,7 +32,7 @@ func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteComman }, } - c.CmdClause = parent.Command("delete", "Delete an workspace string list") + c.CmdClause = parent.Command("delete", "Delete a workspace string list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) diff --git a/pkg/text/customsignal.go b/pkg/text/customsignal.go index 074c8f517..c12468e29 100644 --- a/pkg/text/customsignal.go +++ b/pkg/text/customsignal.go @@ -28,14 +28,14 @@ func PrintCustomSignalTbl(out io.Writer, customSignalsToPrint []signals.Signal) return } - for _, listToPrint := range customSignalsToPrint { + for _, customSignalToPrint := range customSignalsToPrint { tbl.AddLine( - listToPrint.SignalID, - listToPrint.Name, - listToPrint.Description, - listToPrint.Scope.Type, - listToPrint.UpdatedAt, - listToPrint.CreatedAt, + customSignalToPrint.SignalID, + customSignalToPrint.Name, + customSignalToPrint.Description, + customSignalToPrint.Scope.Type, + customSignalToPrint.UpdatedAt, + customSignalToPrint.CreatedAt, ) } tbl.Print() diff --git a/pkg/text/rule.go b/pkg/text/rule.go new file mode 100644 index 000000000..3c3bf4918 --- /dev/null +++ b/pkg/text/rule.go @@ -0,0 +1,46 @@ +package text + +import ( + "fmt" + "io" + + "github.com/fastly/cli/pkg/time" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/rules" +) + +// PrintRule displays an NGWAF rule. +func PrintRule(out io.Writer, ruleToPrint *rules.Rule) { + fmt.Fprintf(out, "ID: %s\n", ruleToPrint.RuleID) + fmt.Fprintf(out, "Action: %s\n", ruleToPrint.Actions[0].Type) + fmt.Fprintf(out, "Description: %s\n", ruleToPrint.Description) + fmt.Fprintf(out, "Enabled: %v\n", ruleToPrint.Enabled) + fmt.Fprintf(out, "Type: %s\n", ruleToPrint.Type) + fmt.Fprintf(out, "Scope: %s\n", ruleToPrint.Scope.Type) + fmt.Fprintf(out, "Updated (UTC): %s\n", ruleToPrint.UpdatedAt.UTC().Format(time.Format)) + fmt.Fprintf(out, "Created (UTC): %s\n", ruleToPrint.CreatedAt.UTC().Format(time.Format)) +} + +// PrintRuleTbl displays rules in a table format. +func PrintRuleTbl(out io.Writer, rulesToPrint []rules.Rule) { + tbl := NewTable(out) + tbl.AddHeader("ID", "Action", "Description", "Enabled", "Type", "Scope", "Updated At", "Created At") + + if rulesToPrint == nil { + tbl.Print() + return + } + + for _, ruleToPrint := range rulesToPrint { + tbl.AddLine( + ruleToPrint.RuleID, + ruleToPrint.Actions[0].Type, + ruleToPrint.Description, + ruleToPrint.Enabled, + ruleToPrint.Type, + ruleToPrint.Scope.Type, + ruleToPrint.UpdatedAt, + ruleToPrint.CreatedAt, + ) + } + tbl.Print() +} From b2813c35a0898866b72666cf1f0bb548ef112562 Mon Sep 17 00:00:00 2001 From: Anthony Gomez Date: Fri, 19 Dec 2025 13:15:17 -0500 Subject: [PATCH 2/8] fix lint --- pkg/commands/ngwaf/rule/create.go | 5 ++--- pkg/commands/ngwaf/rule/update.go | 5 ++--- pkg/commands/ngwaf/workspace/rule/create.go | 5 ++--- pkg/commands/ngwaf/workspace/rule/update.go | 5 ++--- 4 files changed, 8 insertions(+), 12 deletions(-) diff --git a/pkg/commands/ngwaf/rule/create.go b/pkg/commands/ngwaf/rule/create.go index 644f0098d..e182f30ae 100644 --- a/pkg/commands/ngwaf/rule/create.go +++ b/pkg/commands/ngwaf/rule/create.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "log" "os" "path/filepath" @@ -68,11 +67,11 @@ func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { byteValue, err := io.ReadAll(jsonFile) if err != nil { - log.Fatalf("failed to read json file: %v", err) + return fmt.Errorf("failed to read json file: %v", err) } if err := json.Unmarshal(byteValue, input); err != nil { - log.Fatalf("failed to unmarshal json data: %v", err) + return fmt.Errorf("failed to unmarshal json data: %v", err) } } input.Scope = &scope.Scope{ diff --git a/pkg/commands/ngwaf/rule/update.go b/pkg/commands/ngwaf/rule/update.go index ce756ca52..3ddc77e39 100644 --- a/pkg/commands/ngwaf/rule/update.go +++ b/pkg/commands/ngwaf/rule/update.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "log" "os" "path/filepath" @@ -72,11 +71,11 @@ func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { byteValue, err := io.ReadAll(jsonFile) if err != nil { - log.Fatalf("failed to read json file: %v", err) + return fmt.Errorf("failed to read json file: %v", err) } if err := json.Unmarshal(byteValue, input); err != nil { - log.Fatalf("failed to unmarshal json data: %v", err) + return fmt.Errorf("failed to unmarshal json data: %v", err) } } input.Scope = &scope.Scope{ diff --git a/pkg/commands/ngwaf/workspace/rule/create.go b/pkg/commands/ngwaf/workspace/rule/create.go index 152666cea..d361caa42 100644 --- a/pkg/commands/ngwaf/workspace/rule/create.go +++ b/pkg/commands/ngwaf/workspace/rule/create.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "log" "os" "path/filepath" @@ -77,11 +76,11 @@ func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { byteValue, err := io.ReadAll(jsonFile) if err != nil { - log.Fatalf("failed to read json file: %v", err) + return fmt.Errorf("failed to read json file: %v", err) } if err := json.Unmarshal(byteValue, input); err != nil { - log.Fatalf("failed to unmarshal json data: %v", err) + return fmt.Errorf("failed to unmarshal json data: %v", err) } } input.Scope = &scope.Scope{ diff --git a/pkg/commands/ngwaf/workspace/rule/update.go b/pkg/commands/ngwaf/workspace/rule/update.go index 62704d941..5591dc831 100644 --- a/pkg/commands/ngwaf/workspace/rule/update.go +++ b/pkg/commands/ngwaf/workspace/rule/update.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "log" "os" "path/filepath" @@ -81,11 +80,11 @@ func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { byteValue, err := io.ReadAll(jsonFile) if err != nil { - log.Fatalf("failed to read json file: %v", err) + return fmt.Errorf("failed to read json file: %v", err) } if err := json.Unmarshal(byteValue, input); err != nil { - log.Fatalf("failed to unmarshal json data: %v", err) + return fmt.Errorf("failed to unmarshal json data: %v", err) } } input.Scope = &scope.Scope{ From 87705d04fad7bd2b3aed4018b23fb59d292ffe7b Mon Sep 17 00:00:00 2001 From: Anthony Gomez Date: Wed, 7 Jan 2026 12:35:34 -0500 Subject: [PATCH 3/8] add parsing support for different rule types --- pkg/commands/ngwaf/rule/create.go | 98 ++++++++++++++++++- pkg/commands/ngwaf/rule/rule_test.go | 33 +++++++ .../rule/testdata/test_complex_rule.json | 63 ++++++++++++ 3 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 pkg/commands/ngwaf/rule/testdata/test_complex_rule.json diff --git a/pkg/commands/ngwaf/rule/create.go b/pkg/commands/ngwaf/rule/create.go index e182f30ae..12c420f6e 100644 --- a/pkg/commands/ngwaf/rule/create.go +++ b/pkg/commands/ngwaf/rule/create.go @@ -52,7 +52,7 @@ func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { return fsterr.ErrInvalidVerboseJSONCombo } var err error - input := &rules.CreateInput{} + rule := &rules.Rule{} if c.path != "" { path, err := filepath.Abs(c.path) if err != nil { @@ -70,10 +70,104 @@ func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { return fmt.Errorf("failed to read json file: %v", err) } - if err := json.Unmarshal(byteValue, input); err != nil { + if err := json.Unmarshal(byteValue, rule); err != nil { return fmt.Errorf("failed to unmarshal json data: %v", err) } } + input := &rules.CreateInput{ + Actions: []*rules.CreateAction{}, + Conditions: []*rules.CreateCondition{}, + Description: &rule.Description, + GroupConditions: []*rules.CreateGroupCondition{}, + MultivalConditions: []*rules.CreateMultivalCondition{}, + Enabled: &rule.Enabled, + Type: &rule.Type, + GroupOperator: &rule.GroupOperator, + RequestLogging: &rule.RequestLogging, + } + + for _, action := range rule.Actions { + input.Actions = append(input.Actions, &rules.CreateAction{ + AllowInteractive: action.AllowInteractive, + DeceptionType: &action.DeceptionType, + RedirectURL: &action.RedirectURL, + ResponseCode: &action.ResponseCode, + Signal: &action.Signal, + Type: &action.Type, + }) + } + + if rule.RateLimit != nil { + input.RateLimit = &rules.CreateRateLimit{ + ClientIdentifiers: []*rules.CreateClientIdentifier{}, + Duration: &rule.RateLimit.Duration, + Interval: &rule.RateLimit.Interval, + Signal: &rule.RateLimit.Signal, + Threshold: &rule.RateLimit.Threshold, + } + + for _, rateLimit := range rule.RateLimit.ClientIdentifiers { + input.RateLimit.ClientIdentifiers = append(input.RateLimit.ClientIdentifiers, &rules.CreateClientIdentifier{ + Key: &rateLimit.Key, + Name: &rateLimit.Name, + Type: &rateLimit.Type, + }) + } + } + + for _, jsonCondition := range rule.Conditions { + switch jsonCondition.Type { + case "single": + if sc, ok := jsonCondition.Fields.(rules.SingleCondition); ok { + input.Conditions = append(input.Conditions, &rules.CreateCondition{ + Field: &sc.Field, + Operator: &sc.Operator, + Value: &sc.Value, + }) + } else { + return fmt.Errorf("expected SingleCondition, got %T", jsonCondition.Fields) + } + case "group": + if gc, ok := jsonCondition.Fields.(rules.GroupCondition); ok { + parsedGroupCondition := &rules.CreateGroupCondition{ + GroupOperator: &gc.GroupOperator, + Conditions: []*rules.CreateCondition{}, + } + for _, groupSingleCondition := range gc.Conditions { + parsedGroupCondition.Conditions = append(parsedGroupCondition.Conditions, &rules.CreateCondition{ + Field: &groupSingleCondition.Field, + Operator: &groupSingleCondition.Operator, + Value: &groupSingleCondition.Value, + }) + } + input.GroupConditions = append(input.GroupConditions, parsedGroupCondition) + } else { + return fmt.Errorf("expected GroupCondition, got %T", jsonCondition.Fields) + } + case "multival": + if mvc, ok := jsonCondition.Fields.(rules.CreateMultivalCondition); ok { + parsedMultiValCondition := &rules.CreateMultivalCondition{ + Field: mvc.Field, + GroupOperator: mvc.GroupOperator, + Operator: mvc.Operator, + Conditions: []*rules.CreateConditionMult{}, + } + for _, multiSingleCondition := range mvc.Conditions { + parsedMultiValCondition.Conditions = append(parsedMultiValCondition.Conditions, &rules.CreateConditionMult{ + Field: multiSingleCondition.Field, + Operator: multiSingleCondition.Operator, + Value: multiSingleCondition.Value, + }) + } + input.MultivalConditions = append(input.MultivalConditions, parsedMultiValCondition) + } else { + return fmt.Errorf("expected MultivalCondition, got %T", jsonCondition.Fields) + } + default: + return fmt.Errorf("unknown condition type: %s", jsonCondition.Type) + } + } + input.Scope = &scope.Scope{ Type: scope.ScopeTypeAccount, AppliesTo: []string{"*"}, diff --git a/pkg/commands/ngwaf/rule/rule_test.go b/pkg/commands/ngwaf/rule/rule_test.go index c939167f1..305fc358d 100644 --- a/pkg/commands/ngwaf/rule/rule_test.go +++ b/pkg/commands/ngwaf/rule/rule_test.go @@ -22,6 +22,8 @@ const ( ruleAction = "allow" ruleID = "someID" rulePath = "testdata/test_rule.json" + complexRulePath = "testdata/test_complex_rule.json" + complexRuleID = "someComplexID" ruleType = "request" ) @@ -42,6 +44,23 @@ var rule = rules.Rule{ }, } +var complexRule = rules.Rule{ + CreatedAt: testutil.Date, + Description: ruleDescription, + Enabled: ruleEnabled, + RuleID: complexRuleID, + Actions: []rules.Action{ + { + Type: ruleAction, + }, + }, + Type: ruleType, + Scope: rules.Scope{ + Type: string(scope.ScopeTypeAccount), + AppliesTo: []string{"*"}, + }, +} + func TestRuleCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { @@ -76,6 +95,20 @@ func TestRuleCreate(t *testing.T) { }, WantOutput: fstfmt.Success("Created account-level rule with ID %s", ruleID), }, + { + Name: "validate API success with complex rule", + Args: fmt.Sprintf("--path %s", complexRulePath), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(complexRule)))), + }, + }, + }, + WantOutput: fstfmt.Success("Created account-level rule with ID %s", complexRuleID), + }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--path %s --json", rulePath), diff --git a/pkg/commands/ngwaf/rule/testdata/test_complex_rule.json b/pkg/commands/ngwaf/rule/testdata/test_complex_rule.json new file mode 100644 index 000000000..f2b04cde9 --- /dev/null +++ b/pkg/commands/ngwaf/rule/testdata/test_complex_rule.json @@ -0,0 +1,63 @@ +{ + "type": "request", + "description": "complex_test", + "enabled": true, + "expires_at": "", + "group_operator": "all", + "conditions": [ + { + "type": "single", + "field": "ip", + "operator": "equals", + "value": "1.2.3.4" + }, + { + "type": "single", + "field": "country", + "operator": "equals", + "value": "AE" + }, + { + "type": "group", + "group_operator": "all", + "conditions": [ + { + "type": "single", + "field": "ip", + "operator": "equals", + "value": "2.4.5.6" + }, + { + "type": "single", + "field": "country", + "operator": "equals", + "value": "AD" + } + ] + }, + { + "type": "group", + "group_operator": "all", + "conditions": [ + { + "type": "single", + "field": "domain", + "operator": "equals", + "value": "test.com" + }, + { + "type": "single", + "field": "agent_name", + "operator": "equals", + "value": "test" + } + ] + } + ], + "actions": [ + { + "type": "allow" + } + ], + "request_logging": "none" +} \ No newline at end of file From 20b6c3be3cd3f4e151f7d4898b5aba64f35f3668 Mon Sep 17 00:00:00 2001 From: Anthony Gomez Date: Wed, 7 Jan 2026 14:08:15 -0500 Subject: [PATCH 4/8] add workspace level --- pkg/commands/ngwaf/rule/create.go | 1 + pkg/commands/ngwaf/rule/rule_test.go | 6 +- pkg/commands/ngwaf/workspace/rule/create.go | 99 ++++++++++++++++++- .../ngwaf/workspace/rule/rule_test.go | 33 +++++++ .../rule/testdata/test_complex_rule.json | 63 ++++++++++++ 5 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 pkg/commands/ngwaf/workspace/rule/testdata/test_complex_rule.json diff --git a/pkg/commands/ngwaf/rule/create.go b/pkg/commands/ngwaf/rule/create.go index 12c420f6e..d99142e0a 100644 --- a/pkg/commands/ngwaf/rule/create.go +++ b/pkg/commands/ngwaf/rule/create.go @@ -74,6 +74,7 @@ func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { return fmt.Errorf("failed to unmarshal json data: %v", err) } } + input := &rules.CreateInput{ Actions: []*rules.CreateAction{}, Conditions: []*rules.CreateCondition{}, diff --git a/pkg/commands/ngwaf/rule/rule_test.go b/pkg/commands/ngwaf/rule/rule_test.go index 305fc358d..229f3d0fb 100644 --- a/pkg/commands/ngwaf/rule/rule_test.go +++ b/pkg/commands/ngwaf/rule/rule_test.go @@ -17,14 +17,14 @@ import ( ) const ( + complexRulePath = "testdata/test_complex_rule.json" + complexRuleID = "someComplexID" ruleDescription = "Utility requests" ruleEnabled = true ruleAction = "allow" ruleID = "someID" rulePath = "testdata/test_rule.json" - complexRulePath = "testdata/test_complex_rule.json" - complexRuleID = "someComplexID" - ruleType = "request" + ruleType = "request" ) var rule = rules.Rule{ diff --git a/pkg/commands/ngwaf/workspace/rule/create.go b/pkg/commands/ngwaf/workspace/rule/create.go index d361caa42..a1dea075e 100644 --- a/pkg/commands/ngwaf/workspace/rule/create.go +++ b/pkg/commands/ngwaf/workspace/rule/create.go @@ -61,7 +61,7 @@ func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { if err := c.workspaceID.Parse(); err != nil { return err } - input := &rules.CreateInput{} + rule := &rules.Rule{} if c.path != "" { path, err := filepath.Abs(c.path) if err != nil { @@ -79,10 +79,105 @@ func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { return fmt.Errorf("failed to read json file: %v", err) } - if err := json.Unmarshal(byteValue, input); err != nil { + if err := json.Unmarshal(byteValue, rule); err != nil { return fmt.Errorf("failed to unmarshal json data: %v", err) } } + + input := &rules.CreateInput{ + Actions: []*rules.CreateAction{}, + Conditions: []*rules.CreateCondition{}, + Description: &rule.Description, + GroupConditions: []*rules.CreateGroupCondition{}, + MultivalConditions: []*rules.CreateMultivalCondition{}, + Enabled: &rule.Enabled, + Type: &rule.Type, + GroupOperator: &rule.GroupOperator, + RequestLogging: &rule.RequestLogging, + } + + for _, action := range rule.Actions { + input.Actions = append(input.Actions, &rules.CreateAction{ + AllowInteractive: action.AllowInteractive, + DeceptionType: &action.DeceptionType, + RedirectURL: &action.RedirectURL, + ResponseCode: &action.ResponseCode, + Signal: &action.Signal, + Type: &action.Type, + }) + } + + if rule.RateLimit != nil { + input.RateLimit = &rules.CreateRateLimit{ + ClientIdentifiers: []*rules.CreateClientIdentifier{}, + Duration: &rule.RateLimit.Duration, + Interval: &rule.RateLimit.Interval, + Signal: &rule.RateLimit.Signal, + Threshold: &rule.RateLimit.Threshold, + } + + for _, rateLimit := range rule.RateLimit.ClientIdentifiers { + input.RateLimit.ClientIdentifiers = append(input.RateLimit.ClientIdentifiers, &rules.CreateClientIdentifier{ + Key: &rateLimit.Key, + Name: &rateLimit.Name, + Type: &rateLimit.Type, + }) + } + } + + for _, jsonCondition := range rule.Conditions { + switch jsonCondition.Type { + case "single": + if sc, ok := jsonCondition.Fields.(rules.SingleCondition); ok { + input.Conditions = append(input.Conditions, &rules.CreateCondition{ + Field: &sc.Field, + Operator: &sc.Operator, + Value: &sc.Value, + }) + } else { + return fmt.Errorf("expected SingleCondition, got %T", jsonCondition.Fields) + } + case "group": + if gc, ok := jsonCondition.Fields.(rules.GroupCondition); ok { + parsedGroupCondition := &rules.CreateGroupCondition{ + GroupOperator: &gc.GroupOperator, + Conditions: []*rules.CreateCondition{}, + } + for _, groupSingleCondition := range gc.Conditions { + parsedGroupCondition.Conditions = append(parsedGroupCondition.Conditions, &rules.CreateCondition{ + Field: &groupSingleCondition.Field, + Operator: &groupSingleCondition.Operator, + Value: &groupSingleCondition.Value, + }) + } + input.GroupConditions = append(input.GroupConditions, parsedGroupCondition) + } else { + return fmt.Errorf("expected GroupCondition, got %T", jsonCondition.Fields) + } + case "multival": + if mvc, ok := jsonCondition.Fields.(rules.CreateMultivalCondition); ok { + parsedMultiValCondition := &rules.CreateMultivalCondition{ + Field: mvc.Field, + GroupOperator: mvc.GroupOperator, + Operator: mvc.Operator, + Conditions: []*rules.CreateConditionMult{}, + } + for _, multiSingleCondition := range mvc.Conditions { + parsedMultiValCondition.Conditions = append(parsedMultiValCondition.Conditions, &rules.CreateConditionMult{ + Field: multiSingleCondition.Field, + Operator: multiSingleCondition.Operator, + Value: multiSingleCondition.Value, + }) + } + input.MultivalConditions = append(input.MultivalConditions, parsedMultiValCondition) + } else { + return fmt.Errorf("expected MultivalCondition, got %T", jsonCondition.Fields) + } + default: + return fmt.Errorf("unknown condition type: %s", jsonCondition.Type) + } + } + input.Scope = &scope.Scope{ Type: scope.ScopeTypeWorkspace, AppliesTo: []string{c.workspaceID.Value}, diff --git a/pkg/commands/ngwaf/workspace/rule/rule_test.go b/pkg/commands/ngwaf/workspace/rule/rule_test.go index b50421843..b2cbc3819 100644 --- a/pkg/commands/ngwaf/workspace/rule/rule_test.go +++ b/pkg/commands/ngwaf/workspace/rule/rule_test.go @@ -18,6 +18,8 @@ import ( ) const ( + complexRulePath = "testdata/test_complex_rule.json" + complexRuleID = "someComplexID" ruleDescription = "Utility requests" ruleEnabled = true ruleAction = "allow" @@ -44,6 +46,23 @@ var rule = rules.Rule{ }, } +var complexRule = rules.Rule{ + CreatedAt: testutil.Date, + Description: ruleDescription, + Enabled: ruleEnabled, + RuleID: complexRuleID, + Actions: []rules.Action{ + { + Type: ruleAction, + }, + }, + Type: ruleType, + Scope: rules.Scope{ + Type: string(scope.ScopeTypeWorkspace), + AppliesTo: []string{ruleWorkspaceID}, + }, +} + func TestRuleCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { @@ -83,6 +102,20 @@ func TestRuleCreate(t *testing.T) { }, WantOutput: fstfmt.Success("Created workspace-level rule with ID %s", ruleID), }, + { + Name: "validate API success with complex rule", + Args: fmt.Sprintf("--path %s --workspace-id %s", complexRulePath, ruleWorkspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(complexRule)))), + }, + }, + }, + WantOutput: fstfmt.Success("Created account-level rule with ID %s", complexRuleID), + }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--path %s --workspace-id %s --json", rulePath, ruleWorkspaceID), diff --git a/pkg/commands/ngwaf/workspace/rule/testdata/test_complex_rule.json b/pkg/commands/ngwaf/workspace/rule/testdata/test_complex_rule.json new file mode 100644 index 000000000..f2b04cde9 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/rule/testdata/test_complex_rule.json @@ -0,0 +1,63 @@ +{ + "type": "request", + "description": "complex_test", + "enabled": true, + "expires_at": "", + "group_operator": "all", + "conditions": [ + { + "type": "single", + "field": "ip", + "operator": "equals", + "value": "1.2.3.4" + }, + { + "type": "single", + "field": "country", + "operator": "equals", + "value": "AE" + }, + { + "type": "group", + "group_operator": "all", + "conditions": [ + { + "type": "single", + "field": "ip", + "operator": "equals", + "value": "2.4.5.6" + }, + { + "type": "single", + "field": "country", + "operator": "equals", + "value": "AD" + } + ] + }, + { + "type": "group", + "group_operator": "all", + "conditions": [ + { + "type": "single", + "field": "domain", + "operator": "equals", + "value": "test.com" + }, + { + "type": "single", + "field": "agent_name", + "operator": "equals", + "value": "test" + } + ] + } + ], + "actions": [ + { + "type": "allow" + } + ], + "request_logging": "none" +} \ No newline at end of file From f9df69795aec683a7b82722d5dcad9fabb3641c2 Mon Sep 17 00:00:00 2001 From: Anthony Gomez Date: Wed, 7 Jan 2026 14:21:58 -0500 Subject: [PATCH 5/8] lint --- pkg/commands/ngwaf/rule/rule_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/commands/ngwaf/rule/rule_test.go b/pkg/commands/ngwaf/rule/rule_test.go index 229f3d0fb..82c00aee1 100644 --- a/pkg/commands/ngwaf/rule/rule_test.go +++ b/pkg/commands/ngwaf/rule/rule_test.go @@ -24,7 +24,7 @@ const ( ruleAction = "allow" ruleID = "someID" rulePath = "testdata/test_rule.json" - ruleType = "request" + ruleType = "request" ) var rule = rules.Rule{ From 07582c487f1c68f1e3f03295601cdeaa7a71a06f Mon Sep 17 00:00:00 2001 From: Anthony Gomez Date: Wed, 7 Jan 2026 14:47:38 -0500 Subject: [PATCH 6/8] add update --- pkg/commands/ngwaf/rule/create.go | 9 +- pkg/commands/ngwaf/rule/update.go | 108 ++++++++++++++++++-- pkg/commands/ngwaf/workspace/rule/create.go | 9 +- pkg/commands/ngwaf/workspace/rule/update.go | 107 +++++++++++++++++-- 4 files changed, 209 insertions(+), 24 deletions(-) diff --git a/pkg/commands/ngwaf/rule/create.go b/pkg/commands/ngwaf/rule/create.go index d99142e0a..3c71b343e 100644 --- a/pkg/commands/ngwaf/rule/create.go +++ b/pkg/commands/ngwaf/rule/create.go @@ -85,6 +85,10 @@ func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { Type: &rule.Type, GroupOperator: &rule.GroupOperator, RequestLogging: &rule.RequestLogging, + Scope: &scope.Scope{ + Type: scope.ScopeTypeAccount, + AppliesTo: []string{"*"}, + }, } for _, action := range rule.Actions { @@ -169,11 +173,6 @@ func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { } } - input.Scope = &scope.Scope{ - Type: scope.ScopeTypeAccount, - AppliesTo: []string{"*"}, - } - fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") diff --git a/pkg/commands/ngwaf/rule/update.go b/pkg/commands/ngwaf/rule/update.go index 3ddc77e39..f7456d2a1 100644 --- a/pkg/commands/ngwaf/rule/update.go +++ b/pkg/commands/ngwaf/rule/update.go @@ -54,9 +54,8 @@ func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { return fsterr.ErrInvalidVerboseJSONCombo } var err error - input := &rules.UpdateInput{ - RuleID: &c.ruleID, - } + + rule := &rules.Rule{} if c.path != "" { path, err := filepath.Abs(c.path) if err != nil { @@ -74,13 +73,108 @@ func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { return fmt.Errorf("failed to read json file: %v", err) } - if err := json.Unmarshal(byteValue, input); err != nil { + if err := json.Unmarshal(byteValue, rule); err != nil { return fmt.Errorf("failed to unmarshal json data: %v", err) } } - input.Scope = &scope.Scope{ - Type: scope.ScopeTypeAccount, - AppliesTo: []string{"*"}, + + input := &rules.UpdateInput{ + RuleID: &c.ruleID, + Actions: []*rules.UpdateAction{}, + Conditions: []*rules.UpdateCondition{}, + Description: &rule.Description, + GroupConditions: []*rules.UpdateGroupCondition{}, + MultivalConditions: []*rules.UpdateMultivalCondition{}, + Enabled: &rule.Enabled, + Type: &rule.Type, + GroupOperator: &rule.GroupOperator, + RequestLogging: &rule.RequestLogging, + Scope: &scope.Scope{ + Type: scope.ScopeTypeAccount, + AppliesTo: []string{"*"}, + }, + } + + for _, action := range rule.Actions { + input.Actions = append(input.Actions, &rules.UpdateAction{ + AllowInteractive: action.AllowInteractive, + DeceptionType: &action.DeceptionType, + RedirectURL: &action.RedirectURL, + ResponseCode: &action.ResponseCode, + Signal: &action.Signal, + Type: &action.Type, + }) + } + + if rule.RateLimit != nil { + input.RateLimit = &rules.UpdateRateLimit{ + ClientIdentifiers: []*rules.UpdateClientIdentifier{}, + Duration: &rule.RateLimit.Duration, + Interval: &rule.RateLimit.Interval, + Signal: &rule.RateLimit.Signal, + Threshold: &rule.RateLimit.Threshold, + } + + for _, rateLimit := range rule.RateLimit.ClientIdentifiers { + input.RateLimit.ClientIdentifiers = append(input.RateLimit.ClientIdentifiers, &rules.UpdateClientIdentifier{ + Key: &rateLimit.Key, + Name: &rateLimit.Name, + Type: &rateLimit.Type, + }) + } + } + + for _, jsonCondition := range rule.Conditions { + switch jsonCondition.Type { + case "single": + if sc, ok := jsonCondition.Fields.(rules.SingleCondition); ok { + input.Conditions = append(input.Conditions, &rules.UpdateCondition{ + Field: &sc.Field, + Operator: &sc.Operator, + Value: &sc.Value, + }) + } else { + return fmt.Errorf("expected SingleCondition, got %T", jsonCondition.Fields) + } + case "group": + if gc, ok := jsonCondition.Fields.(rules.GroupCondition); ok { + parsedGroupCondition := &rules.UpdateGroupCondition{ + GroupOperator: &gc.GroupOperator, + Conditions: []*rules.UpdateCondition{}, + } + for _, groupSingleCondition := range gc.Conditions { + parsedGroupCondition.Conditions = append(parsedGroupCondition.Conditions, &rules.UpdateCondition{ + Field: &groupSingleCondition.Field, + Operator: &groupSingleCondition.Operator, + Value: &groupSingleCondition.Value, + }) + } + input.GroupConditions = append(input.GroupConditions, parsedGroupCondition) + } else { + return fmt.Errorf("expected GroupCondition, got %T", jsonCondition.Fields) + } + case "multival": + if mvc, ok := jsonCondition.Fields.(rules.UpdateMultivalCondition); ok { + parsedMultiValCondition := &rules.UpdateMultivalCondition{ + Field: mvc.Field, + GroupOperator: mvc.GroupOperator, + Operator: mvc.Operator, + Conditions: []*rules.UpdateConditionMult{}, + } + for _, multiSingleCondition := range mvc.Conditions { + parsedMultiValCondition.Conditions = append(parsedMultiValCondition.Conditions, &rules.UpdateConditionMult{ + Field: multiSingleCondition.Field, + Operator: multiSingleCondition.Operator, + Value: multiSingleCondition.Value, + }) + } + input.MultivalConditions = append(input.MultivalConditions, parsedMultiValCondition) + } else { + return fmt.Errorf("expected MultivalCondition, got %T", jsonCondition.Fields) + } + default: + return fmt.Errorf("unknown condition type: %s", jsonCondition.Type) + } } fc, ok := c.Globals.APIClient.(*fastly.Client) diff --git a/pkg/commands/ngwaf/workspace/rule/create.go b/pkg/commands/ngwaf/workspace/rule/create.go index a1dea075e..48aac4c33 100644 --- a/pkg/commands/ngwaf/workspace/rule/create.go +++ b/pkg/commands/ngwaf/workspace/rule/create.go @@ -94,6 +94,10 @@ func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { Type: &rule.Type, GroupOperator: &rule.GroupOperator, RequestLogging: &rule.RequestLogging, + Scope: &scope.Scope{ + Type: scope.ScopeTypeWorkspace, + AppliesTo: []string{c.workspaceID.Value}, + }, } for _, action := range rule.Actions { @@ -178,11 +182,6 @@ func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { } } - input.Scope = &scope.Scope{ - Type: scope.ScopeTypeWorkspace, - AppliesTo: []string{c.workspaceID.Value}, - } - fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") diff --git a/pkg/commands/ngwaf/workspace/rule/update.go b/pkg/commands/ngwaf/workspace/rule/update.go index 5591dc831..6b26c5c51 100644 --- a/pkg/commands/ngwaf/workspace/rule/update.go +++ b/pkg/commands/ngwaf/workspace/rule/update.go @@ -63,9 +63,7 @@ func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { if err := c.workspaceID.Parse(); err != nil { return err } - input := &rules.UpdateInput{ - RuleID: &c.ruleID, - } + rule := &rules.Rule{} if c.path != "" { path, err := filepath.Abs(c.path) if err != nil { @@ -83,13 +81,108 @@ func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { return fmt.Errorf("failed to read json file: %v", err) } - if err := json.Unmarshal(byteValue, input); err != nil { + if err := json.Unmarshal(byteValue, rule); err != nil { return fmt.Errorf("failed to unmarshal json data: %v", err) } } - input.Scope = &scope.Scope{ - Type: scope.ScopeTypeWorkspace, - AppliesTo: []string{c.workspaceID.Value}, + + input := &rules.UpdateInput{ + Actions: []*rules.UpdateAction{}, + Conditions: []*rules.UpdateCondition{}, + Description: &rule.Description, + GroupConditions: []*rules.UpdateGroupCondition{}, + MultivalConditions: []*rules.UpdateMultivalCondition{}, + Enabled: &rule.Enabled, + Type: &rule.Type, + GroupOperator: &rule.GroupOperator, + RequestLogging: &rule.RequestLogging, + RuleID: &c.ruleID, + Scope: &scope.Scope{ + Type: scope.ScopeTypeWorkspace, + AppliesTo: []string{c.workspaceID.Value}, + }, + } + + for _, action := range rule.Actions { + input.Actions = append(input.Actions, &rules.UpdateAction{ + AllowInteractive: action.AllowInteractive, + DeceptionType: &action.DeceptionType, + RedirectURL: &action.RedirectURL, + ResponseCode: &action.ResponseCode, + Signal: &action.Signal, + Type: &action.Type, + }) + } + + if rule.RateLimit != nil { + input.RateLimit = &rules.UpdateRateLimit{ + ClientIdentifiers: []*rules.UpdateClientIdentifier{}, + Duration: &rule.RateLimit.Duration, + Interval: &rule.RateLimit.Interval, + Signal: &rule.RateLimit.Signal, + Threshold: &rule.RateLimit.Threshold, + } + + for _, rateLimit := range rule.RateLimit.ClientIdentifiers { + input.RateLimit.ClientIdentifiers = append(input.RateLimit.ClientIdentifiers, &rules.UpdateClientIdentifier{ + Key: &rateLimit.Key, + Name: &rateLimit.Name, + Type: &rateLimit.Type, + }) + } + } + + for _, jsonCondition := range rule.Conditions { + switch jsonCondition.Type { + case "single": + if sc, ok := jsonCondition.Fields.(rules.SingleCondition); ok { + input.Conditions = append(input.Conditions, &rules.UpdateCondition{ + Field: &sc.Field, + Operator: &sc.Operator, + Value: &sc.Value, + }) + } else { + return fmt.Errorf("expected SingleCondition, got %T", jsonCondition.Fields) + } + case "group": + if gc, ok := jsonCondition.Fields.(rules.GroupCondition); ok { + parsedGroupCondition := &rules.UpdateGroupCondition{ + GroupOperator: &gc.GroupOperator, + Conditions: []*rules.UpdateCondition{}, + } + for _, groupSingleCondition := range gc.Conditions { + parsedGroupCondition.Conditions = append(parsedGroupCondition.Conditions, &rules.UpdateCondition{ + Field: &groupSingleCondition.Field, + Operator: &groupSingleCondition.Operator, + Value: &groupSingleCondition.Value, + }) + } + input.GroupConditions = append(input.GroupConditions, parsedGroupCondition) + } else { + return fmt.Errorf("expected GroupCondition, got %T", jsonCondition.Fields) + } + case "multival": + if mvc, ok := jsonCondition.Fields.(rules.UpdateMultivalCondition); ok { + parsedMultiValCondition := &rules.UpdateMultivalCondition{ + Field: mvc.Field, + GroupOperator: mvc.GroupOperator, + Operator: mvc.Operator, + Conditions: []*rules.UpdateConditionMult{}, + } + for _, multiSingleCondition := range mvc.Conditions { + parsedMultiValCondition.Conditions = append(parsedMultiValCondition.Conditions, &rules.UpdateConditionMult{ + Field: multiSingleCondition.Field, + Operator: multiSingleCondition.Operator, + Value: multiSingleCondition.Value, + }) + } + input.MultivalConditions = append(input.MultivalConditions, parsedMultiValCondition) + } else { + return fmt.Errorf("expected MultivalCondition, got %T", jsonCondition.Fields) + } + default: + return fmt.Errorf("unknown condition type: %s", jsonCondition.Type) + } } fc, ok := c.Globals.APIClient.(*fastly.Client) From 06523b63c7ceabcbae6756b3d99b9d765f24555d Mon Sep 17 00:00:00 2001 From: Anthony Gomez Date: Wed, 7 Jan 2026 14:59:20 -0500 Subject: [PATCH 7/8] fix typo --- pkg/commands/ngwaf/workspace/rule/rule_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/commands/ngwaf/workspace/rule/rule_test.go b/pkg/commands/ngwaf/workspace/rule/rule_test.go index b2cbc3819..7e1c8e27c 100644 --- a/pkg/commands/ngwaf/workspace/rule/rule_test.go +++ b/pkg/commands/ngwaf/workspace/rule/rule_test.go @@ -114,7 +114,7 @@ func TestRuleCreate(t *testing.T) { }, }, }, - WantOutput: fstfmt.Success("Created account-level rule with ID %s", complexRuleID), + WantOutput: fstfmt.Success("Created workspace-level rule with ID %s", complexRuleID), }, { Name: "validate optional --json flag", From 41c0070de64d5b57dabc8bab6798a4631093cff8 Mon Sep 17 00:00:00 2001 From: Anthony Gomez Date: Wed, 7 Jan 2026 15:31:00 -0500 Subject: [PATCH 8/8] fix error message --- pkg/commands/ngwaf/rule/create.go | 2 +- pkg/commands/ngwaf/rule/update.go | 2 +- pkg/commands/ngwaf/workspace/rule/create.go | 2 +- pkg/commands/ngwaf/workspace/rule/update.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/commands/ngwaf/rule/create.go b/pkg/commands/ngwaf/rule/create.go index 3c71b343e..9c74538b1 100644 --- a/pkg/commands/ngwaf/rule/create.go +++ b/pkg/commands/ngwaf/rule/create.go @@ -61,7 +61,7 @@ func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { jsonFile, err := os.Open(path) if err != nil { - return fmt.Errorf("error reading cert-path '%s': %q", c.path, err) + return fmt.Errorf("error reading path '%s': %q", c.path, err) } defer jsonFile.Close() diff --git a/pkg/commands/ngwaf/rule/update.go b/pkg/commands/ngwaf/rule/update.go index f7456d2a1..76086680f 100644 --- a/pkg/commands/ngwaf/rule/update.go +++ b/pkg/commands/ngwaf/rule/update.go @@ -64,7 +64,7 @@ func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { jsonFile, err := os.Open(path) if err != nil { - return fmt.Errorf("error reading cert-path '%s': %q", c.path, err) + return fmt.Errorf("error reading path '%s': %q", c.path, err) } defer jsonFile.Close() diff --git a/pkg/commands/ngwaf/workspace/rule/create.go b/pkg/commands/ngwaf/workspace/rule/create.go index 48aac4c33..1af6baf26 100644 --- a/pkg/commands/ngwaf/workspace/rule/create.go +++ b/pkg/commands/ngwaf/workspace/rule/create.go @@ -70,7 +70,7 @@ func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { jsonFile, err := os.Open(path) if err != nil { - return fmt.Errorf("error reading cert-path '%s': %q", c.path, err) + return fmt.Errorf("error reading path '%s': %q", c.path, err) } defer jsonFile.Close() diff --git a/pkg/commands/ngwaf/workspace/rule/update.go b/pkg/commands/ngwaf/workspace/rule/update.go index 6b26c5c51..e586b6e3e 100644 --- a/pkg/commands/ngwaf/workspace/rule/update.go +++ b/pkg/commands/ngwaf/workspace/rule/update.go @@ -72,7 +72,7 @@ func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { jsonFile, err := os.Open(path) if err != nil { - return fmt.Errorf("error reading cert-path '%s': %q", c.path, err) + return fmt.Errorf("error reading path '%s': %q", c.path, err) } defer jsonFile.Close()