diff --git a/CHANGELOG.md b/CHANGELOG.md index cf187d5e9..2ede2e4d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ### Enhancements: - feat(rust): Allow testing with prerelease Rust versions ([#1604](https://github.com/fastly/cli/pull/1604)) +- 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 d380d6d59..3b8846395 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" @@ -450,6 +452,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) @@ -486,6 +494,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) @@ -1009,6 +1023,12 @@ func Define( // nolint:revive // function-length ngwafIPListGet, ngwafIPListList, ngwafIPListUpdate, + ngwafRuleRoot, + ngwafRuleCreate, + ngwafRuleDelete, + ngwafRuleGet, + ngwafRuleList, + ngwafRuleUpdate, ngwafSignalListRoot, ngwafSignalListCreate, ngwafSignalListDelete, @@ -1044,6 +1064,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..e182f30ae --- /dev/null +++ b/pkg/commands/ngwaf/rule/create.go @@ -0,0 +1,98 @@ +package rule + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "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 { + return fmt.Errorf("failed to read json file: %v", err) + } + + if err := json.Unmarshal(byteValue, input); err != nil { + return fmt.Errorf("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..3ddc77e39 --- /dev/null +++ b/pkg/commands/ngwaf/rule/update.go @@ -0,0 +1,102 @@ +package rule + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "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 { + return fmt.Errorf("failed to read json file: %v", err) + } + + if err := json.Unmarshal(byteValue, input); err != nil { + return fmt.Errorf("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..d361caa42 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/rule/create.go @@ -0,0 +1,107 @@ +package rule + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "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 { + return fmt.Errorf("failed to read json file: %v", err) + } + + if err := json.Unmarshal(byteValue, input); err != nil { + return fmt.Errorf("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..5591dc831 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/rule/update.go @@ -0,0 +1,111 @@ +package rule + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "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 { + return fmt.Errorf("failed to read json file: %v", err) + } + + if err := json.Unmarshal(byteValue, input); err != nil { + return fmt.Errorf("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() +}