diff --git a/CHANGELOG.md b/CHANGELOG.md index a7ebf6945..3060bc4d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,10 @@ ### Enhancements: +- feat(audit-log): add event-mapping command group ([#1875](https://github.com/fastly/cli/pull/1875)) + ### Dependencies: + - build(deps): `github.com/rogpeppe/go-internal` from 1.15.0 to 1.16.0 ([#1871](https://github.com/fastly/cli/pull/1871)) ## [v15.5.0](https://github.com/fastly/cli/releases/tag/v15.5.0) (2026-08-04) diff --git a/pkg/app/metadata.json b/pkg/app/metadata.json index a1cf18902..302994d6d 100644 --- a/pkg/app/metadata.json +++ b/pkg/app/metadata.json @@ -3983,5 +3983,44 @@ "https://www.fastly.com/documentation/reference/api/tls/subs/#patch-tls-sub" ] } + }, + "audit-log": { + "event-mapping": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/observability/notifications/event-mappings/#create-event-mapping" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/observability/notifications/event-mappings/#list-event-mappings" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/observability/notifications/event-mappings/#get-event-mapping" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/observability/notifications/event-mappings/#update-event-mapping" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/observability/notifications/event-mappings/#delete-event-mapping" + ] + }, + "list-event-types": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/observability/notifications/event-mappings/#get-event-mapping-event-types" + ] + }, + "list-scope-types": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/observability/notifications/event-mappings/#get-event-mapping-scope-types" + ] + } + } } } diff --git a/pkg/app/run_test.go b/pkg/app/run_test.go index 9fb352344..f0705b75c 100644 --- a/pkg/app/run_test.go +++ b/pkg/app/run_test.go @@ -67,6 +67,7 @@ complete -F _fastly_bash_autocomplete fastly WantOutput: `help auth apisecurity +audit-log compute config config-store diff --git a/pkg/commands/auditlog/doc.go b/pkg/commands/auditlog/doc.go new file mode 100644 index 000000000..75f909d5f --- /dev/null +++ b/pkg/commands/auditlog/doc.go @@ -0,0 +1,5 @@ +// Package auditlog contains commands to inspect and manipulate Fastly audit +// log configuration. +// +// https://www.fastly.com/documentation/reference/api/observability/notifications/event-mappings/ +package auditlog diff --git a/pkg/commands/auditlog/eventmapping/create.go b/pkg/commands/auditlog/eventmapping/create.go new file mode 100644 index 000000000..e01e6e6bf --- /dev/null +++ b/pkg/commands/auditlog/eventmapping/create.go @@ -0,0 +1,107 @@ +package eventmapping + +import ( + "context" + "errors" + "io" + + "github.com/fastly/go-fastly/v17/fastly" + "github.com/fastly/go-fastly/v17/fastly/notifications/v1/eventmappings" + + "github.com/fastly/kingpin" + + "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" +) + +// knownScopeTypes lists the supported scope type values, offered as +// shell-completion hints for --scope-type. +var knownScopeTypes = []string{ + eventmappings.ScopeTypeAccount, + eventmappings.ScopeTypeVCL, + eventmappings.ScopeTypeWasm, + eventmappings.ScopeTypeNGWAF, +} + +// CreateCommand calls the Fastly API to create an audit log event mapping. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + name string + scopeType string + eventTypes []string + integrationIDs []string + + // Optional. + description argparser.OptionalString + scopeIDs argparser.OptionalStringSlice +} + +// 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 audit log event mapping").Alias("add") + + // Required. + c.CmdClause.Flag("name", "A descriptive name for the mapping").Required().StringVar(&c.name) + c.CmdClause.Flag("scope-type", "The category of Fastly resource the mapping applies to (account, vcl, wasm, ngwaf)").HintOptions(knownScopeTypes...).Required().StringVar(&c.scopeType) + c.CmdClause.Flag("event-type", "An audit event type that triggers a notification. Set flag multiple times, or provide a comma-separated list, to specify multiple event types").Required().StringsVar(&c.eventTypes, kingpin.Separator(",")) + c.CmdClause.Flag("integration-id", "The ID of an integration that should receive notifications. Set flag multiple times, or provide a comma-separated list, to specify multiple integrations").Required().StringsVar(&c.integrationIDs, kingpin.Separator(",")) + + // Optional. + c.CmdClause.Flag("description", "A description of the mapping").Action(c.description.Set).StringVar(&c.description.Value) + c.CmdClause.Flag("scope-id", "The ID of a service or workspace to scope the mapping to. Set flag multiple times, or provide a comma-separated list, to specify multiple scope IDs. Omit to apply the mapping to all resources of the given scope type").Action(c.scopeIDs.Set).StringsVar(&c.scopeIDs.Value, kingpin.Separator(",")) + 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 + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + input := &eventmappings.CreateInput{ + Name: &c.name, + ScopeType: &c.scopeType, + EventTypes: c.eventTypes, + IntegrationIDs: c.integrationIDs, + } + + if c.description.WasSet { + input.Description = &c.description.Value + } + if c.scopeIDs.WasSet { + input.ScopeIDs = c.scopeIDs.Value + } + + em, err := eventmappings.Create(context.TODO(), fc, input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Name": c.name, + "Scope Type": c.scopeType, + }) + return err + } + + if ok, err := c.WriteJSON(out, em); ok { + return err + } + + text.Success(out, "Created event mapping '%s' (id: %s)", em.Name, em.ID) + return nil +} diff --git a/pkg/commands/auditlog/eventmapping/delete.go b/pkg/commands/auditlog/eventmapping/delete.go new file mode 100644 index 000000000..47553d832 --- /dev/null +++ b/pkg/commands/auditlog/eventmapping/delete.go @@ -0,0 +1,79 @@ +package eventmapping + +import ( + "context" + "errors" + "io" + + "github.com/fastly/go-fastly/v17/fastly" + "github.com/fastly/go-fastly/v17/fastly/notifications/v1/eventmappings" + + "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 audit log event mapping. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + id 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 audit log event mapping").Alias("remove") + + // Required. + c.CmdClause.Flag("id", "The unique identifier of the event mapping").Required().StringVar(&c.id) + + // 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 := eventmappings.Delete(context.TODO(), fc, &eventmappings.DeleteInput{ + MappingID: &c.id, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Mapping ID": c.id, + }) + return err + } + + if c.JSONOutput.Enabled { + o := struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` + }{ + c.id, + true, + } + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, "Deleted event mapping (id: %s)", c.id) + return nil +} diff --git a/pkg/commands/auditlog/eventmapping/describe.go b/pkg/commands/auditlog/eventmapping/describe.go new file mode 100644 index 000000000..5b5648548 --- /dev/null +++ b/pkg/commands/auditlog/eventmapping/describe.go @@ -0,0 +1,71 @@ +package eventmapping + +import ( + "context" + "errors" + "io" + + "github.com/fastly/go-fastly/v17/fastly" + "github.com/fastly/go-fastly/v17/fastly/notifications/v1/eventmappings" + + "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" +) + +// DescribeCommand calls the Fastly API to describe an audit log event mapping. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + id string +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Retrieve a single audit log event mapping").Alias("get") + + // Required. + c.CmdClause.Flag("id", "The unique identifier of the event mapping").Required().StringVar(&c.id) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) 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") + } + + em, err := eventmappings.Get(context.TODO(), fc, &eventmappings.GetInput{ + MappingID: &c.id, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Mapping ID": c.id, + }) + return err + } + + if ok, err := c.WriteJSON(out, em); ok { + return err + } + + text.PrintEventMapping(out, em) + return nil +} diff --git a/pkg/commands/auditlog/eventmapping/doc.go b/pkg/commands/auditlog/eventmapping/doc.go new file mode 100644 index 000000000..7d04b1243 --- /dev/null +++ b/pkg/commands/auditlog/eventmapping/doc.go @@ -0,0 +1,3 @@ +// Package eventmapping contains commands to manipulate Fastly audit log +// event mappings. +package eventmapping diff --git a/pkg/commands/auditlog/eventmapping/eventmapping_test.go b/pkg/commands/auditlog/eventmapping/eventmapping_test.go new file mode 100644 index 000000000..12f3d6a15 --- /dev/null +++ b/pkg/commands/auditlog/eventmapping/eventmapping_test.go @@ -0,0 +1,436 @@ +package eventmapping_test + +import ( + "bytes" + "fmt" + "io" + "net/http" + "testing" + + root "github.com/fastly/cli/pkg/commands/auditlog" + sub "github.com/fastly/cli/pkg/commands/auditlog/eventmapping" + fstfmt "github.com/fastly/cli/pkg/fmt" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/go-fastly/v17/fastly/notifications/v1/eventmappings" + "github.com/fastly/go-fastly/v17/fastly/notifications/v1/eventmappings/eventtypes" + "github.com/fastly/go-fastly/v17/fastly/notifications/v1/eventmappings/scopetypes" +) + +const ( + mappingID = "mappingID" + mappingName = "mappingName" +) + +var em = eventmappings.EventMapping{ + ID: mappingID, + CustomerID: "customerID", + Name: mappingName, + Description: "mappingDescription", + ScopeType: eventmappings.ScopeTypeAccount, + ScopeIDs: []string{}, + EventTypes: []string{"user.login"}, + IntegrationIDs: []string{"integrationID"}, + MappingStatus: eventmappings.MappingStatusActive, + CreatedAt: testutil.Date, + UpdatedAt: testutil.Date, +} + +func TestEventMappingCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --name flag", + Args: "--scope-type account --event-type user.login --integration-id integrationID", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: "validate missing --scope-type flag", + Args: "--name foo --event-type user.login --integration-id integrationID", + WantError: "error parsing arguments: required flag --scope-type not provided", + }, + { + Name: "validate missing --event-type flag", + Args: "--name foo --scope-type account --integration-id integrationID", + WantError: "error parsing arguments: required flag --event-type not provided", + }, + { + Name: "validate missing --integration-id flag", + Args: "--name foo --scope-type account --event-type user.login", + WantError: "error parsing arguments: required flag --integration-id not provided", + }, + { + Name: "validate internal server error", + Args: "--name foo --scope-type account --event-type user.login --integration-id integrationID", + 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("--name %s --scope-type %s --event-type %s --integration-id %s", mappingName, eventmappings.ScopeTypeAccount, "user.login", "integrationID"), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(em))), + }, + }, + }, + WantOutput: fstfmt.Success("Created event mapping '%s' (id: %s)", em.Name, em.ID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--name %s --scope-type %s --event-type %s --integration-id %s --json", mappingName, eventmappings.ScopeTypeAccount, "user.login", "integrationID"), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(em))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(em), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) +} + +func TestEventMappingDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --id flag", + Args: "", + WantError: "error parsing arguments: required flag --id not provided", + }, + { + Name: "validate bad request", + Args: "--id bar", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + }, + }, + }, + WantError: "400 - Bad Request", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--id %s", mappingID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + }, + }, + }, + WantOutput: fstfmt.Success("Deleted event mapping (id: %s)", mappingID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--id %s --json", mappingID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + }, + }, + }, + WantOutput: fstfmt.JSON(`{"id": %q, "deleted": true}`, mappingID), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) +} + +func TestEventMappingDescribe(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --id flag", + Args: "", + WantError: "error parsing arguments: required flag --id not provided", + }, + { + Name: "validate bad request", + Args: "--id baz", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + }, + }, + }, + WantError: "400 - Bad Request", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--id %s", mappingID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(em))), + }, + }, + }, + WantOutput: emString, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--id %s --json", mappingID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(em))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(em), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "describe"}, scenarios) +} + +func TestEventMappingList(t *testing.T) { + 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", + 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(eventmappings.Collection{ + Data: []eventmappings.EventMapping{em}, + Meta: eventmappings.Meta{Total: 1}, + }))), + }, + }, + }, + WantOutput: mappingID, + }, + { + 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(eventmappings.Collection{ + Data: []eventmappings.EventMapping{em}, + Meta: eventmappings.Meta{Total: 1}, + }))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON([]eventmappings.EventMapping{em}), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list"}, scenarios) +} + +func TestEventMappingUpdate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --id flag", + Args: "--name foo --scope-type account --event-type user.login --integration-id integrationID", + WantError: "error parsing arguments: required flag --id not provided", + }, + { + Name: "validate internal server error", + Args: fmt.Sprintf("--id %s --name foo --scope-type account --event-type user.login --integration-id integrationID", mappingID), + 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("--id %s --name %s --scope-type %s --event-type %s --integration-id %s", mappingID, mappingName, eventmappings.ScopeTypeAccount, "user.login", "integrationID"), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(em))), + }, + }, + }, + WantOutput: fstfmt.Success("Updated event mapping '%s' (id: %s)", em.Name, em.ID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--id %s --name %s --scope-type %s --event-type %s --integration-id %s --json", mappingID, mappingName, eventmappings.ScopeTypeAccount, "user.login", "integrationID"), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(em))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(em), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) +} + +func TestEventMappingListEventTypes(t *testing.T) { + types := eventtypes.Collection{ + Data: []eventtypes.EventType{ + {EventType: "user.login", DisplayName: "User Login", ScopeTypes: []string{"account"}}, + }, + } + + 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", + 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(types))), + }, + }, + }, + WantOutput: "user.login", + }, + { + 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(types))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(types), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list-event-types"}, scenarios) +} + +func TestEventMappingListScopeTypes(t *testing.T) { + types := scopetypes.Collection{ + Data: []scopetypes.ScopeType{ + {ScopeType: "account"}, + }, + } + + 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", + 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(types))), + }, + }, + }, + WantOutput: "account", + }, + { + 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(types))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(types), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list-scope-types"}, scenarios) +} + +var emString = fmt.Sprintf(`ID: %s +Name: %s +Description: %s +Scope Type: %s +Scope IDs: +Event Types: %s +Integration IDs: %s +Mapping Status: %s +Created (UTC): %s +Updated (UTC): %s +`, em.ID, em.Name, em.Description, em.ScopeType, em.EventTypes[0], em.IntegrationIDs[0], em.MappingStatus, + testutil.Date.UTC().Format("2006-01-02 15:04"), testutil.Date.UTC().Format("2006-01-02 15:04")) diff --git a/pkg/commands/auditlog/eventmapping/list.go b/pkg/commands/auditlog/eventmapping/list.go new file mode 100644 index 000000000..3b55ca634 --- /dev/null +++ b/pkg/commands/auditlog/eventmapping/list.go @@ -0,0 +1,106 @@ +package eventmapping + +import ( + "context" + "errors" + "io" + + "github.com/fastly/go-fastly/v17/fastly" + "github.com/fastly/go-fastly/v17/fastly/notifications/v1/eventmappings" + + "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" +) + +// knownMappingStatuses lists the supported mapping status values, offered as +// shell-completion hints for --mapping-status. +var knownMappingStatuses = []string{ + eventmappings.MappingStatusActive, + eventmappings.MappingStatusInactive, +} + +// knownSortValues lists the supported sort values, offered as +// shell-completion hints for --sort. +var knownSortValues = []string{"created_at", "-created_at"} + +// ListCommand calls the Fastly API to list audit log event mappings. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + // Optional. + integrationID argparser.OptionalString + mappingStatus argparser.OptionalString + name argparser.OptionalString + scopeID argparser.OptionalString + scopeType argparser.OptionalString + sort 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 audit log event mappings") + + // Optional. + c.CmdClause.Flag("integration-id", "Filters results to mappings that reference the given integration ID").Action(c.integrationID.Set).StringVar(&c.integrationID.Value) + c.CmdClause.Flag("mapping-status", "Filters results by mapping status").HintOptions(knownMappingStatuses...).Action(c.mappingStatus.Set).StringVar(&c.mappingStatus.Value) + c.CmdClause.Flag("name", "Filters results to mappings whose name contains the given string (case-insensitive)").Action(c.name.Set).StringVar(&c.name.Value) + c.CmdClause.Flag("scope-id", "Filters results to mappings that apply to the given service or workspace ID").Action(c.scopeID.Set).StringVar(&c.scopeID.Value) + c.CmdClause.Flag("scope-type", "Filters results to the given scope type").HintOptions(knownScopeTypes...).Action(c.scopeType.Set).StringVar(&c.scopeType.Value) + c.CmdClause.Flag("sort", "The order in which to return results by creation date").HintOptions(knownSortValues...).Action(c.sort.Set).StringVar(&c.sort.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 := &eventmappings.ListInput{} + if c.integrationID.WasSet { + input.IntegrationID = &c.integrationID.Value + } + if c.mappingStatus.WasSet { + input.MappingStatus = &c.mappingStatus.Value + } + if c.name.WasSet { + input.Name = &c.name.Value + } + if c.scopeID.WasSet { + input.ScopeID = &c.scopeID.Value + } + if c.scopeType.WasSet { + input.ScopeType = &c.scopeType.Value + } + if c.sort.WasSet { + input.Sort = &c.sort.Value + } + + ems, err := eventmappings.List(context.TODO(), fc, input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, ems); ok { + return err + } + + text.PrintEventMappingsTbl(out, ems) + return nil +} diff --git a/pkg/commands/auditlog/eventmapping/listeventtypes.go b/pkg/commands/auditlog/eventmapping/listeventtypes.go new file mode 100644 index 000000000..004691340 --- /dev/null +++ b/pkg/commands/auditlog/eventmapping/listeventtypes.go @@ -0,0 +1,80 @@ +package eventmapping + +import ( + "context" + "errors" + "io" + + "github.com/fastly/go-fastly/v17/fastly" + "github.com/fastly/go-fastly/v17/fastly/notifications/v1/eventmappings/eventtypes" + + "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" +) + +// ListEventTypesCommand calls the Fastly API to list the audit event types +// that can be used when creating an event mapping. +type ListEventTypesCommand struct { + argparser.Base + argparser.JSONOutput + + // Optional. + scopeType argparser.OptionalString + sort argparser.OptionalString +} + +// NewListEventTypesCommand returns a usable command registered under the parent. +func NewListEventTypesCommand(parent argparser.Registerer, g *global.Data) *ListEventTypesCommand { + c := ListEventTypesCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list-event-types", "List the audit event types supported when creating an event mapping") + + // Optional. + c.CmdClause.Flag("scope-type", "Filters results to event types compatible with the given scope type").HintOptions(knownScopeTypes...).Action(c.scopeType.Set).StringVar(&c.scopeType.Value) + c.CmdClause.Flag("sort", "The order in which to return results, alphabetically by event type").HintOptions("event_type", "-event_type").Action(c.sort.Set).StringVar(&c.sort.Value) + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListEventTypesCommand) 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 := &eventtypes.ListInput{} + if c.scopeType.WasSet { + input.ScopeType = &c.scopeType.Value + } + if c.sort.WasSet { + input.Sort = &c.sort.Value + } + + types, err := eventtypes.List(context.TODO(), fc, input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, types); ok { + return err + } + + var data []eventtypes.EventType + if types != nil { + data = types.Data + } + text.PrintEventTypesTbl(out, data) + return nil +} diff --git a/pkg/commands/auditlog/eventmapping/listscopetypes.go b/pkg/commands/auditlog/eventmapping/listscopetypes.go new file mode 100644 index 000000000..b3ff02b37 --- /dev/null +++ b/pkg/commands/auditlog/eventmapping/listscopetypes.go @@ -0,0 +1,75 @@ +package eventmapping + +import ( + "context" + "errors" + "io" + + "github.com/fastly/go-fastly/v17/fastly" + "github.com/fastly/go-fastly/v17/fastly/notifications/v1/eventmappings/scopetypes" + + "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" +) + +// ListScopeTypesCommand calls the Fastly API to list the scope types +// supported when creating an event mapping. +type ListScopeTypesCommand struct { + argparser.Base + argparser.JSONOutput + + // Optional. + sort argparser.OptionalString +} + +// NewListScopeTypesCommand returns a usable command registered under the parent. +func NewListScopeTypesCommand(parent argparser.Registerer, g *global.Data) *ListScopeTypesCommand { + c := ListScopeTypesCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list-scope-types", "List the scope types supported when creating an event mapping") + + // Optional. + c.CmdClause.Flag("sort", "The order in which to return results, alphabetically by scope type").HintOptions("scope_type", "-scope_type").Action(c.sort.Set).StringVar(&c.sort.Value) + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListScopeTypesCommand) 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 := &scopetypes.ListInput{} + if c.sort.WasSet { + input.Sort = &c.sort.Value + } + + types, err := scopetypes.List(context.TODO(), fc, input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, types); ok { + return err + } + + var data []scopetypes.ScopeType + if types != nil { + data = types.Data + } + text.PrintScopeTypesTbl(out, data) + return nil +} diff --git a/pkg/commands/auditlog/eventmapping/root.go b/pkg/commands/auditlog/eventmapping/root.go new file mode 100644 index 000000000..187806d00 --- /dev/null +++ b/pkg/commands/auditlog/eventmapping/root.go @@ -0,0 +1,31 @@ +package eventmapping + +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 = "event-mapping" + +// 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 Fastly audit log event mappings") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/auditlog/eventmapping/update.go b/pkg/commands/auditlog/eventmapping/update.go new file mode 100644 index 000000000..73324ad03 --- /dev/null +++ b/pkg/commands/auditlog/eventmapping/update.go @@ -0,0 +1,103 @@ +package eventmapping + +import ( + "context" + "errors" + "io" + + "github.com/fastly/go-fastly/v17/fastly" + "github.com/fastly/go-fastly/v17/fastly/notifications/v1/eventmappings" + + "github.com/fastly/kingpin" + + "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 audit log event mapping. +// +// Update replaces the entire event mapping, so all required fields must be +// provided; omitted fields are not preserved from the previous version. +type UpdateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + id string + name string + scopeType string + eventTypes []string + integrationIDs []string + + // Optional. + description argparser.OptionalString + scopeIDs argparser.OptionalStringSlice +} + +// 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 an audit log event mapping. This replaces the entire mapping, so all required fields must be provided") + + // Required. + c.CmdClause.Flag("id", "The unique identifier of the event mapping").Required().StringVar(&c.id) + c.CmdClause.Flag("name", "A descriptive name for the mapping").Required().StringVar(&c.name) + c.CmdClause.Flag("scope-type", "The category of Fastly resource the mapping applies to (account, vcl, wasm, ngwaf)").HintOptions(knownScopeTypes...).Required().StringVar(&c.scopeType) + c.CmdClause.Flag("event-type", "An audit event type that triggers a notification. Set flag multiple times, or provide a comma-separated list, to specify multiple event types").Required().StringsVar(&c.eventTypes, kingpin.Separator(",")) + c.CmdClause.Flag("integration-id", "The ID of an integration that should receive notifications. Set flag multiple times, or provide a comma-separated list, to specify multiple integrations").Required().StringsVar(&c.integrationIDs, kingpin.Separator(",")) + + // Optional. + c.CmdClause.Flag("description", "A description of the mapping").Action(c.description.Set).StringVar(&c.description.Value) + c.CmdClause.Flag("scope-id", "The ID of a service or workspace to scope the mapping to. Set flag multiple times, or provide a comma-separated list, to specify multiple scope IDs").Action(c.scopeIDs.Set).StringsVar(&c.scopeIDs.Value, kingpin.Separator(",")) + 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 + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + input := &eventmappings.UpdateInput{ + MappingID: &c.id, + Name: &c.name, + ScopeType: &c.scopeType, + EventTypes: c.eventTypes, + IntegrationIDs: c.integrationIDs, + } + + if c.description.WasSet { + input.Description = &c.description.Value + } + if c.scopeIDs.WasSet { + input.ScopeIDs = c.scopeIDs.Value + } + + em, err := eventmappings.Update(context.TODO(), fc, input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Mapping ID": c.id, + }) + return err + } + + if ok, err := c.WriteJSON(out, em); ok { + return err + } + + text.Success(out, "Updated event mapping '%s' (id: %s)", em.Name, em.ID) + return nil +} diff --git a/pkg/commands/auditlog/root.go b/pkg/commands/auditlog/root.go new file mode 100644 index 000000000..9b295e024 --- /dev/null +++ b/pkg/commands/auditlog/root.go @@ -0,0 +1,31 @@ +package auditlog + +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 = "audit-log" + +// 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 Fastly audit log configuration") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go index 748b08496..ede37695b 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -55,6 +55,8 @@ import ( "github.com/fastly/cli/pkg/commands/apisecurity/discoveredoperations" "github.com/fastly/cli/pkg/commands/apisecurity/operations" "github.com/fastly/cli/pkg/commands/apisecurity/tags" + "github.com/fastly/cli/pkg/commands/auditlog" + "github.com/fastly/cli/pkg/commands/auditlog/eventmapping" authcmd "github.com/fastly/cli/pkg/commands/auth" "github.com/fastly/cli/pkg/commands/authtoken" "github.com/fastly/cli/pkg/commands/compute" @@ -255,6 +257,15 @@ func Define( // nolint:revive // function-length tagsGet := tags.NewGetCommand(tagsRoot.CmdClause, data) tagsList := tags.NewListCommand(tagsRoot.CmdClause, data) tagsUpdate := tags.NewUpdateCommand(tagsRoot.CmdClause, data) + auditlogRoot := auditlog.NewRootCommand(app, data) + auditlogEventMappingRoot := eventmapping.NewRootCommand(auditlogRoot.CmdClause, data) + auditlogEventMappingCreate := eventmapping.NewCreateCommand(auditlogEventMappingRoot.CmdClause, data) + auditlogEventMappingList := eventmapping.NewListCommand(auditlogEventMappingRoot.CmdClause, data) + auditlogEventMappingDescribe := eventmapping.NewDescribeCommand(auditlogEventMappingRoot.CmdClause, data) + auditlogEventMappingUpdate := eventmapping.NewUpdateCommand(auditlogEventMappingRoot.CmdClause, data) + auditlogEventMappingDelete := eventmapping.NewDeleteCommand(auditlogEventMappingRoot.CmdClause, data) + auditlogEventMappingListEventTypes := eventmapping.NewListEventTypesCommand(auditlogEventMappingRoot.CmdClause, data) + auditlogEventMappingListScopeTypes := eventmapping.NewListScopeTypesCommand(auditlogEventMappingRoot.CmdClause, data) computeCmdRoot := compute.NewRootCommand(app, data) computeACLCmdRoot := computeacl.NewRootCommand(computeCmdRoot.CmdClause, data) computeACLCreate := computeacl.NewCreateCommand(computeACLCmdRoot.CmdClause, data) @@ -1188,6 +1199,15 @@ func Define( // nolint:revive // function-length tagsGet, tagsList, tagsUpdate, + auditlogRoot, + auditlogEventMappingRoot, + auditlogEventMappingCreate, + auditlogEventMappingList, + auditlogEventMappingDescribe, + auditlogEventMappingUpdate, + auditlogEventMappingDelete, + auditlogEventMappingListEventTypes, + auditlogEventMappingListScopeTypes, computeCmdRoot, computeACLCmdRoot, computeACLCreate, diff --git a/pkg/text/eventmapping.go b/pkg/text/eventmapping.go new file mode 100644 index 000000000..2990e50b9 --- /dev/null +++ b/pkg/text/eventmapping.go @@ -0,0 +1,66 @@ +package text + +import ( + "fmt" + "io" + "strings" + + "github.com/fastly/cli/pkg/time" + "github.com/fastly/go-fastly/v17/fastly/notifications/v1/eventmappings" + "github.com/fastly/go-fastly/v17/fastly/notifications/v1/eventmappings/eventtypes" + "github.com/fastly/go-fastly/v17/fastly/notifications/v1/eventmappings/scopetypes" +) + +// PrintEventMapping displays an audit log event mapping. +func PrintEventMapping(out io.Writer, em *eventmappings.EventMapping) { + fmt.Fprintf(out, "ID: %s\n", em.ID) + fmt.Fprintf(out, "Name: %s\n", em.Name) + fmt.Fprintf(out, "Description: %s\n", em.Description) + fmt.Fprintf(out, "Scope Type: %s\n", em.ScopeType) + fmt.Fprintf(out, "Scope IDs: %s\n", strings.Join(em.ScopeIDs, ", ")) + fmt.Fprintf(out, "Event Types: %s\n", strings.Join(em.EventTypes, ", ")) + fmt.Fprintf(out, "Integration IDs: %s\n", strings.Join(em.IntegrationIDs, ", ")) + fmt.Fprintf(out, "Mapping Status: %s\n", em.MappingStatus) + fmt.Fprintf(out, "Created (UTC): %s\n", em.CreatedAt.UTC().Format(time.Format)) + fmt.Fprintf(out, "Updated (UTC): %s\n", em.UpdatedAt.UTC().Format(time.Format)) +} + +// PrintEventMappingsTbl displays audit log event mappings in a table format. +func PrintEventMappingsTbl(out io.Writer, ems []eventmappings.EventMapping) { + tbl := NewTable(out) + tbl.AddHeader("ID", "NAME", "SCOPE TYPE", "EVENT TYPES", "INTEGRATION IDS", "STATUS") + + for _, em := range ems { + tbl.AddLine( + em.ID, + em.Name, + em.ScopeType, + strings.Join(em.EventTypes, ", "), + strings.Join(em.IntegrationIDs, ", "), + em.MappingStatus, + ) + } + tbl.Print() +} + +// PrintEventTypesTbl displays supported audit event types in a table format. +func PrintEventTypesTbl(out io.Writer, types []eventtypes.EventType) { + tbl := NewTable(out) + tbl.AddHeader("EVENT TYPE", "DISPLAY NAME", "SCOPE TYPES") + + for _, et := range types { + tbl.AddLine(et.EventType, et.DisplayName, strings.Join(et.ScopeTypes, ", ")) + } + tbl.Print() +} + +// PrintScopeTypesTbl displays supported scope types in a table format. +func PrintScopeTypesTbl(out io.Writer, types []scopetypes.ScopeType) { + tbl := NewTable(out) + tbl.AddHeader("SCOPE TYPE") + + for _, st := range types { + tbl.AddLine(st.ScopeType) + } + tbl.Print() +}