From 4e2a0055e6db371ef913619c75c06c5ccd3bb275 Mon Sep 17 00:00:00 2001 From: Jacob Bednarz Date: Thu, 1 Jul 2021 12:03:06 +1000 Subject: [PATCH] rulesets: Add initial support for CRUD operations Updates the library to have support for interacting with Rulesets at the zone and account level. Foundational work to resolve cloudflare/terraform-provider-cloudflare#1071 and provide upstream Terraform support for all things rulesets. --- rulesets.go | 297 +++++++++++++++++++++++++++++++++++++++++++ rulesets_test.go | 324 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 621 insertions(+) create mode 100644 rulesets.go create mode 100644 rulesets_test.go diff --git a/rulesets.go b/rulesets.go new file mode 100644 index 0000000000..a9396f600b --- /dev/null +++ b/rulesets.go @@ -0,0 +1,297 @@ +package cloudflare + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/pkg/errors" +) + +const ( + // RulesetKindRoot is definition for an account level ruleset. + RulesetKindRoot RulesetKind = "root" + + // RulesetKindCustom is the user defined rulesets. + RulesetKindCustom RulesetKind = "custom" + + // RulesetKindManaged denotes Cloudflare managed rulesets. + RulesetKindManaged RulesetKind = "managed" + + // RulesetKindSchema denotes a schema ruleset. + RulesetKindSchema RulesetKind = "schema" + + // RulesetKindZone expresses a zone level ruleset. + RulesetKindZone RulesetKind = "zone" + + // RulesetPhaseDDoSL7 phase runs during DDoS mitigation stage. + RulesetPhaseDDoSL7 RulesetPhase = "ddos_l7" + + // RulesetPhaseMagicTransit phase is invoked when traffic is routed via Magic + // Transit. + RulesetPhaseMagicTransit RulesetPhase = "magic_transit" + + // RulesetPhaseHTTPRequestMain runs in the primary part of the HTTP request. + RulesetPhaseHTTPRequestMain RulesetPhase = "http_request_main" + + // RulesetPhaseHTTPRequestFirewallCustom runs on custom firewall rulesets. + RulesetPhaseHTTPRequestFirewallCustom RulesetPhase = "http_request_firewall_custom" + + // RulesetPhaseHTTPRequestFirewallManaged runs for Cloudflare managed rulesets. + RulesetPhaseHTTPRequestFirewallManaged RulesetPhase = "http_request_firewall_managed" + + // RulesetPhaseHTTPRequestTransform is performed at the HTTP request + // transformation phase. + RulesetPhaseHTTPRequestTransform RulesetPhase = "http_request_transform" + + // RulesetPhaseHTTPRequestSanitize is run during the HTTP request sanitisation + // phase. + RulesetPhaseHTTPRequestSanitize RulesetPhase = "http_request_sanitize" + + // RulesetRuleActionSkip represents the "skip" action. + RulesetRuleActionSkip RulesetRuleAction = "skip" + + // RulesetRuleActionBlock represents the "block" action. + RulesetRuleActionBlock RulesetRuleAction = "block" + + // RulesetRuleActionJSChallenge represents the "js_challenge" action. + RulesetRuleActionJSChallenge RulesetRuleAction = "js_challenge" + + // RulesetRuleActionChallenge represents the "challenge" action. + RulesetRuleActionChallenge RulesetRuleAction = "challenge" + + // RulesetRuleActionLog represents the "log" action. + RulesetRuleActionLog RulesetRuleAction = "log" +) + +// RulesetRuleAction defines a custom type that is used to express allowed +// values for the rule action. +type RulesetRuleAction string + +// RulesetKind is the custom type for allowed variances of rulesets. +type RulesetKind string + +// RulesetPhase is the custom type for defining at what point the ruleset will +// be applied and limited to expected values. +type RulesetPhase string + +// Ruleset contains the structure of a Ruleset. +type Ruleset struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + Kind string `json:"kind"` + Version string `json:"version,omitempty"` + LastUpdated *time.Time `json:"last_updated,omitempty"` + Phase RulesetPhase `json:"phase"` + Rules []RulesetRule `json:"rules"` +} + +// RulesetRuleActionParameters specifies the action parameters for a Ruleset +// rule. +type RulesetRuleActionParameters struct { + Ruleset string `json:"ruleset,omitempty"` +} + +// RulesetRule contains information about a single Ruleset Rule. +type RulesetRule struct { + ID string `json:"id,omitempty"` + Version string `json:"version,omitempty"` + Action RulesetRuleAction `json:"action"` + ActionParameters *RulesetRuleActionParameters `json:"action_parameters,omitempty"` + Expression string `json:"expression"` + Description string `json:"description"` + LastUpdated *time.Time `json:"last_updated,omitempty"` + Ref string `json:"ref,omitempty"` + Enabled bool `json:"enabled"` +} + +// UpdateRulesetRequest is the representation of a Ruleset update. +type UpdateRulesetRequest struct { + Description string `json:"description"` + Rules []RulesetRule `json:"rules"` +} + +// ListRulesetResponse contains all Rulesets. +type ListRulesetResponse struct { + Response + Result []Ruleset `json:"result"` +} + +// GetRulesetResponse contains a single Ruleset. +type GetRulesetResponse struct { + Response + Result Ruleset `json:"result"` +} + +// CreateRulesetResponse contains response data when creating a new Ruleset. +type CreateRulesetResponse struct { + Response + Result Ruleset `json:"result"` +} + +// UpdateRulesetResponse contains response data when updating an existing +// Ruleset. +type UpdateRulesetResponse struct { + Response + Result Ruleset `json:"result"` +} + +// ListZoneRulesets fetches all rulesets for a zone. +// +// API reference: https://api.cloudflare.com/#zone-rulesets-list-zone-rulesets +func (api *API) ListZoneRulesets(ctx context.Context, zoneID string) ([]Ruleset, error) { + return api.listRulesets(ctx, ZoneRouteRoot, zoneID) +} + +// ListAccountRulesets fetches all rulesets for an account. +// +// API reference: https://api.cloudflare.com/#account-rulesets-list-account-rulesets +func (api *API) ListAccountRulesets(ctx context.Context, accountID string) ([]Ruleset, error) { + return api.listRulesets(ctx, AccountRouteRoot, accountID) +} + +// listRulesets lists all Rulesets for a given zone or account depending on the +// identifier type provided. +func (api *API) listRulesets(ctx context.Context, identifierType RouteRoot, identifier string) ([]Ruleset, error) { + uri := fmt.Sprintf("/%s/%s/rulesets", identifierType, identifier) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []Ruleset{}, err + } + + result := ListRulesetResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return []Ruleset{}, errors.Wrap(err, errUnmarshalError) + } + + return result.Result, nil +} + +// GetZoneRuleset fetches a single ruleset for a zone. +// +// API reference: https://api.cloudflare.com/#zone-rulesets-get-a-zone-ruleset +func (api *API) GetZoneRuleset(ctx context.Context, zoneID, rulesetID string) (Ruleset, error) { + return api.getRuleset(ctx, ZoneRouteRoot, zoneID, rulesetID) +} + +// GetAccountRuleset fetches a single ruleset for an account. +// +// API reference: https://api.cloudflare.com/#account-rulesets-get-an-account-ruleset +func (api *API) GetAccountRuleset(ctx context.Context, accountID, rulesetID string) (Ruleset, error) { + return api.getRuleset(ctx, AccountRouteRoot, accountID, rulesetID) +} + +// getRuleset fetches a single ruleset based on the zone or account, the +// identifer and the ruleset ID. +func (api *API) getRuleset(ctx context.Context, identifierType RouteRoot, identifier, rulesetID string) (Ruleset, error) { + uri := fmt.Sprintf("/%s/%s/rulesets/%s", identifierType, identifier, rulesetID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return Ruleset{}, err + } + + result := GetRulesetResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return Ruleset{}, errors.Wrap(err, errUnmarshalError) + } + + return result.Result, nil +} + +// CreateZoneRuleset creates a new ruleset for a zone. +// +// API reference: https://api.cloudflare.com/#zone-rulesets-create-zone-ruleset +func (api *API) CreateZoneRuleset(ctx context.Context, zoneID string, ruleset Ruleset) (Ruleset, error) { + return api.createRuleset(ctx, ZoneRouteRoot, zoneID, ruleset) +} + +// CreateAccountRuleset creates a new ruleset for an account. +// +// API reference: https://api.cloudflare.com/#account-rulesets-create-account-ruleset +func (api *API) CreateAccountRuleset(ctx context.Context, accountID string, ruleset Ruleset) (Ruleset, error) { + return api.createRuleset(ctx, AccountRouteRoot, accountID, ruleset) +} + +func (api *API) createRuleset(ctx context.Context, identifierType RouteRoot, identifier string, ruleset Ruleset) (Ruleset, error) { + uri := fmt.Sprintf("/%s/%s/rulesets", identifierType, identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, ruleset) + + if err != nil { + return Ruleset{}, err + } + + result := CreateRulesetResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return Ruleset{}, errors.Wrap(err, errUnmarshalError) + } + + return result.Result, nil +} + +// DeleteZoneRuleset deletes a single ruleset for a zone. +// +// API reference: https://api.cloudflare.com/#zone-rulesets-delete-zone-ruleset +func (api *API) DeleteZoneRuleset(ctx context.Context, zoneID, rulesetID string) error { + return api.deleteRuleset(ctx, ZoneRouteRoot, zoneID, rulesetID) +} + +// DeleteAccountRuleset deletes a single ruleset for an account. +// +// API reference: https://api.cloudflare.com/#account-rulesets-delete-account-ruleset +func (api *API) DeleteAccountRuleset(ctx context.Context, accountID, rulesetID string) error { + return api.deleteRuleset(ctx, AccountRouteRoot, accountID, rulesetID) +} + +// deleteRuleset removes a ruleset based on the ruleset ID. +func (api *API) deleteRuleset(ctx context.Context, identifierType RouteRoot, identifier, rulesetID string) error { + uri := fmt.Sprintf("/%s/%s/rulesets/%s", identifierType, identifier, rulesetID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + + if err != nil { + return err + } + + // The API is not implementing the standard response blob but returns an + // empty response (204) in case of a success. So we are checking for the + // response body size here. + if len(res) > 0 { + return errors.Wrap(errors.New(string(res)), errMakeRequestError) + } + + return nil +} + +// UpdateZoneRuleset updates a single ruleset for a zone. +// +// API reference: https://api.cloudflare.com/#zone-rulesets-update-a-zone-ruleset +func (api *API) UpdateZoneRuleset(ctx context.Context, zoneID, rulesetID, description string, rules []RulesetRule) (Ruleset, error) { + return api.updateRuleset(ctx, ZoneRouteRoot, zoneID, rulesetID, description, rules) +} + +// UpdateAccountRuleset updates a single ruleset for an account. +// +// API reference: https://api.cloudflare.com/#account-rulesets-update-account-ruleset +func (api *API) UpdateAccountRuleset(ctx context.Context, accountID, rulesetID, description string, rules []RulesetRule) (Ruleset, error) { + return api.updateRuleset(ctx, AccountRouteRoot, accountID, rulesetID, description, rules) +} + +// updateRuleset updates a ruleset based on the ruleset ID. +func (api *API) updateRuleset(ctx context.Context, identifierType RouteRoot, identifier, rulesetID, description string, rules []RulesetRule) (Ruleset, error) { + uri := fmt.Sprintf("/%s/%s/rulesets/%s", identifierType, identifier, rulesetID) + payload := UpdateRulesetRequest{Description: description, Rules: rules} + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, payload) + if err != nil { + return Ruleset{}, err + } + + result := UpdateRulesetResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return Ruleset{}, errors.Wrap(err, errUnmarshalError) + } + + return result.Result, nil +} diff --git a/rulesets_test.go b/rulesets_test.go new file mode 100644 index 0000000000..d0b2118f4f --- /dev/null +++ b/rulesets_test.go @@ -0,0 +1,324 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestListRulesets(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": [ + { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "name": "my example ruleset", + "description": "Test magic transit ruleset", + "kind": "root", + "version": "1", + "last_updated": "2020-12-02T20:24:07.776073Z", + "phase": "magic_transit" + } + ], + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rulesets", handler) + mux.HandleFunc("/zones/"+testZoneID+"/rulesets", handler) + + lastUpdated, _ := time.Parse(time.RFC3339, "2020-12-02T20:24:07.776073Z") + + want := []Ruleset{ + { + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Name: "my example ruleset", + Description: "Test magic transit ruleset", + Kind: "root", + Version: "1", + LastUpdated: &lastUpdated, + Phase: RulesetPhaseMagicTransit, + }, + } + + zoneActual, err := client.ListZoneRulesets(context.Background(), testZoneID) + if assert.NoError(t, err) { + assert.Equal(t, want, zoneActual) + } + + accountActual, err := client.ListAccountRulesets(context.Background(), testAccountID) + if assert.NoError(t, err) { + assert.Equal(t, want, accountActual) + } +} + +func TestGetRuleset(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "name": "my example ruleset", + "description": "Test magic transit ruleset", + "kind": "root", + "version": "1", + "last_updated": "2020-12-02T20:24:07.776073Z", + "phase": "magic_transit" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rulesets/2c0fc9fa937b11eaa1b71c4d701ab86e", handler) + mux.HandleFunc("/zones/"+testZoneID+"/rulesets/2c0fc9fa937b11eaa1b71c4d701ab86e", handler) + + lastUpdated, _ := time.Parse(time.RFC3339, "2020-12-02T20:24:07.776073Z") + + want := Ruleset{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Name: "my example ruleset", + Description: "Test magic transit ruleset", + Kind: "root", + Version: "1", + LastUpdated: &lastUpdated, + Phase: RulesetPhaseMagicTransit, + } + + zoneActual, err := client.GetZoneRuleset(context.Background(), testZoneID, "2c0fc9fa937b11eaa1b71c4d701ab86e") + if assert.NoError(t, err) { + assert.Equal(t, want, zoneActual) + } + + accountActual, err := client.GetAccountRuleset(context.Background(), testAccountID, "2c0fc9fa937b11eaa1b71c4d701ab86e") + if assert.NoError(t, err) { + assert.Equal(t, want, accountActual) + } +} + +func TestCreateRuleset(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "name": "my example ruleset", + "description": "Test magic transit ruleset", + "kind": "root", + "version": "1", + "last_updated": "2020-12-02T20:24:07.776073Z", + "phase": "magic_transit", + "rules": [ + { + "id": "62449e2e0de149619edb35e59c10d801", + "version": "1", + "action": "skip", + "action_parameters":{ + "ruleset":"current" + }, + "expression": "tcp.dstport in { 32768..65535 }", + "description": "Allow TCP Ephemeral Ports", + "last_updated": "2020-12-02T20:24:07.776073Z", + "ref": "72449e2e0de149619edb35e59c10d801", + "enabled": true + } + ] + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rulesets", handler) + mux.HandleFunc("/zones/"+testZoneID+"/rulesets", handler) + + lastUpdated, _ := time.Parse(time.RFC3339, "2020-12-02T20:24:07.776073Z") + + rules := []RulesetRule{{ + ID: "62449e2e0de149619edb35e59c10d801", + Version: "1", + Action: RulesetRuleActionSkip, + ActionParameters: &RulesetRuleActionParameters{ + Ruleset: "current", + }, + Expression: "tcp.dstport in { 32768..65535 }", + Description: "Allow TCP Ephemeral Ports", + LastUpdated: &lastUpdated, + Ref: "72449e2e0de149619edb35e59c10d801", + Enabled: true, + }} + + newRuleset := Ruleset{ + Name: "my example ruleset", + Description: "Test magic transit ruleset", + Kind: "root", + Phase: RulesetPhaseMagicTransit, + Rules: rules, + } + + want := Ruleset{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Name: "my example ruleset", + Description: "Test magic transit ruleset", + Kind: "root", + Version: "1", + LastUpdated: &lastUpdated, + Phase: RulesetPhaseMagicTransit, + Rules: rules, + } + + zoneActual, err := client.CreateZoneRuleset(context.Background(), testZoneID, newRuleset) + if assert.NoError(t, err) { + assert.Equal(t, want, zoneActual) + } + + accountActual, err := client.CreateAccountRuleset(context.Background(), testAccountID, newRuleset) + if assert.NoError(t, err) { + assert.Equal(t, want, accountActual) + } +} + +func TestDeleteRuleset(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method, "Expected method 'DELETE', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, ``) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rulesets/2c0fc9fa937b11eaa1b71c4d701ab86e", handler) + mux.HandleFunc("/zones/"+testZoneID+"/rulesets/2c0fc9fa937b11eaa1b71c4d701ab86e", handler) + + zErr := client.DeleteZoneRuleset(context.Background(), testZoneID, "2c0fc9fa937b11eaa1b71c4d701ab86e") + assert.NoError(t, zErr) + + aErr := client.DeleteAccountRuleset(context.Background(), testAccountID, "2c0fc9fa937b11eaa1b71c4d701ab86e") + assert.NoError(t, aErr) +} + +func TestUpdateRuleset(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "name": "ruleset1", + "description": "Test Firewall Ruleset Update", + "kind": "root", + "version": "1", + "last_updated": "2020-12-02T20:24:07.776073Z", + "phase": "magic_transit", + "rules": [ + { + "id": "62449e2e0de149619edb35e59c10d801", + "version": "1", + "action": "skip", + "action_parameters":{ + "ruleset":"current" + }, + "expression": "tcp.dstport in { 32768..65535 }", + "description": "Allow TCP Ephemeral Ports", + "last_updated": "2020-12-02T20:24:07.776073Z", + "ref": "72449e2e0de149619edb35e59c10d801", + "enabled": true + }, + { + "id": "62449e2e0de149619edb35e59c10d802", + "version": "1", + "action": "skip", + "action_parameters":{ + "ruleset":"current" + }, + "expression": "udp.dstport in { 32768..65535 }", + "description": "Allow UDP Ephemeral Ports", + "last_updated": "2020-12-02T20:24:07.776073Z", + "ref": "72449e2e0de149619edb35e59c10d801", + "enabled": true + } + ] + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rulesets/2c0fc9fa937b11eaa1b71c4d701ab86e", handler) + mux.HandleFunc("/zones/"+testZoneID+"/rulesets/2c0fc9fa937b11eaa1b71c4d701ab86e", handler) + + lastUpdated, _ := time.Parse(time.RFC3339, "2020-12-02T20:24:07.776073Z") + + rules := []RulesetRule{{ + ID: "62449e2e0de149619edb35e59c10d801", + Version: "1", + Action: RulesetRuleActionSkip, + ActionParameters: &RulesetRuleActionParameters{ + Ruleset: "current", + }, + Expression: "tcp.dstport in { 32768..65535 }", + Description: "Allow TCP Ephemeral Ports", + LastUpdated: &lastUpdated, + Ref: "72449e2e0de149619edb35e59c10d801", + Enabled: true, + }, { + ID: "62449e2e0de149619edb35e59c10d802", + Version: "1", + Action: RulesetRuleActionSkip, + ActionParameters: &RulesetRuleActionParameters{ + Ruleset: "current", + }, + Expression: "udp.dstport in { 32768..65535 }", + Description: "Allow UDP Ephemeral Ports", + LastUpdated: &lastUpdated, + Ref: "72449e2e0de149619edb35e59c10d801", + Enabled: true, + }} + + want := Ruleset{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Name: "ruleset1", + Description: "Test Firewall Ruleset Update", + Kind: "root", + Version: "1", + LastUpdated: &lastUpdated, + Phase: RulesetPhaseMagicTransit, + Rules: rules, + } + + zoneActual, err := client.UpdateZoneRuleset(context.Background(), testZoneID, "2c0fc9fa937b11eaa1b71c4d701ab86e", "Test Firewall Ruleset Update", rules) + if assert.NoError(t, err) { + assert.Equal(t, want, zoneActual) + } + + accountActual, err := client.UpdateAccountRuleset(context.Background(), testAccountID, "2c0fc9fa937b11eaa1b71c4d701ab86e", "Test Firewall Ruleset Update", rules) + if assert.NoError(t, err) { + assert.Equal(t, want, accountActual) + } +}