diff --git a/client/firewall_config.go b/client/firewall_config.go new file mode 100644 index 00000000..5b7ee8c2 --- /dev/null +++ b/client/firewall_config.go @@ -0,0 +1,120 @@ +package client + +import ( + "context" + "fmt" +) + +type FirewallConfig struct { + ProjectID string `json:"-"` + TeamID string `json:"-"` + Enabled bool `json:"firewallEnabled"` + ManagedRulesets map[string]ManagedRule `json:"managedRules,omitempty"` + + Rules []FirewallRule `json:"rules,omitempty"` + IPRules []IPRule `json:"ips,omitempty"` + CRS map[string]CoreRuleSet `json:"crs,omitempty"` +} +type ManagedRule struct { + Active bool `json:"active"` +} + +type FirewallRule struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Active bool `json:"active"` + ConditionGroup []ConditionGroup `json:"conditionGroup"` + Action Action `json:"action"` +} + +type ConditionGroup struct { + Conditions []Condition `json:"conditions"` +} + +type Condition struct { + Type string `json:"type"` + Op string `json:"op"` + Neg bool `json:"neg"` + Key string `json:"key"` + Value string `json:"value"` +} + +type Action struct { + Mitigate Mitigate `json:"mitigate"` +} +type Mitigate struct { + Action string `json:"action"` + RateLimit *RateLimit `json:"rateLimit,omitempty"` + Redirect *Redirect `json:"redirect,omitempty"` + ActionDuration string `json:"actionDuration,omitempty"` +} + +type RateLimit struct { + Algo string `json:"algo" tfsdk:"algo"` + Window int64 `json:"window" tfsdk:"window"` + Limit int64 `json:"limit" tfsdk:"limit"` + Keys []string `json:"keys" tfsdk:"keys"` + Action string `json:"action" tfsdk:"action"` +} + +type Redirect struct { + Location string `json:"location" tfsdk:"location"` + Permanent bool `json:"permanent" tfsdk:"permanent"` +} + +type IPRule struct { + ID string `json:"id,omitempty"` + Hostname string `json:"hostname"` + IP string `json:"ip"` + Notes string `json:"notes,omitempty"` + Action string `json:"action"` +} + +type CoreRuleSet struct { + Active bool `json:"active"` + Action string `json:"action"` +} + +func (c *Client) GetFirewallConfig(ctx context.Context, projectId string, teamId string) (FirewallConfig, error) { + teamId = c.teamID(teamId) + url := fmt.Sprintf( + "%s/v1/security/firewall/config/active?projectId=%s&teamId=%s", + c.baseURL, + projectId, + teamId, + ) + var res = FirewallConfig{} + err := c.doRequest(clientRequest{ + ctx: ctx, + method: "GET", + url: url, + }, &res) + res.TeamID = teamId + return res, err +} + +func (c *Client) PutFirewallConfig(ctx context.Context, cfg FirewallConfig) (FirewallConfig, error) { + teamId := c.teamID(cfg.TeamID) + url := fmt.Sprintf( + "%s/v1/security/firewall/config?projectId=%s&teamId=%s", + c.baseURL, + cfg.ProjectID, + teamId, + ) + + var res struct { + Active FirewallConfig `json:"active"` + Error map[string]string `json:"error,omitempty"` + } + payload := mustMarshal(cfg) + + err := c.doRequest(clientRequest{ + ctx: ctx, + method: "PUT", + url: url, + body: string(payload), + }, &res) + res.Active.TeamID = teamId + return res.Active, err +} diff --git a/docs/resources/attack_challenge_mode.md b/docs/resources/attack_challenge_mode.md index c82cd74d..2bccb5b9 100644 --- a/docs/resources/attack_challenge_mode.md +++ b/docs/resources/attack_challenge_mode.md @@ -32,7 +32,7 @@ resource "vercel_attack_challenge_mode" "example" { ### Required - `enabled` (Boolean) Whether Attack Challenge Mode is enabled or not. -- `project_id` (String) The ID of the Project to adjust the CPU for. +- `project_id` (String) The ID of the Project to toggle Attack Challenge Mode on. ### Optional diff --git a/docs/resources/firewall_config.md b/docs/resources/firewall_config.md new file mode 100644 index 00000000..cdb0d39d --- /dev/null +++ b/docs/resources/firewall_config.md @@ -0,0 +1,432 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "vercel_firewall_config Resource - terraform-provider-vercel" +subcategory: "" +description: |- + Define Custom Rules to shape the way your traffic is handled by the Vercel Edge Network. +--- + +# vercel_firewall_config (Resource) + +Define Custom Rules to shape the way your traffic is handled by the Vercel Edge Network. + +## Example Usage + +```terraform +resource "vercel_project" "example" { + name = "firewall-config-example" +} + +resource "vercel_firewall_config" "example" { + project_id = vercel_project.example.id + + rules { + rule { + name = "Bypass Known request" + description = "Bypass requests using internal bearer tokens" + # individual condition groups are evaluated as ORs + condition_group = [ + { + conditions = [{ + type = "header" + key = "Authorization" + op = "eq" + value = "Bearer internaltoken" + }] + }, + { + conditions = [{ + type = "header" + key = "Authorization" + op = "eq" + value = "Bearer internaltoken2" + }] + } + ] + action = { + action = "bypass" + } + } + + rule { + name = "Challenge curl" + description = "Challenge user agents containing 'curl'" + condition_group = [{ + conditions = [{ + type = "user_agent" + op = "sub" + value = "curl" + }] + }] + action = { + action = "challenge" + } + } + + rule { + name = "Deny cookieless requests" + description = "requests to /api that are missing a session cookie" + # multiple conditions in a single condition group are evaluated as ANDs + condition_group = [{ + conditions = [{ + type = "path" + op = "eq" + value = "/api" + }, + { + type = "cookie" + key = "_session" + neg = true + op = "ex" + }] + }] + action = { + action = "challenge" + } + } + + rule { + name = "Rate limit API" + description = "apply ratelimit to requests under /api" + condition_group = [{ + conditions = [{ + type = "path" + op = "pre" + value = "/api" + }] + }] + + action = { + action = "rate_limit" + rate_limit = { + limit = 100 + window = 300 + keys = ["ip", "ja4"] + algo = "fixed_window" + action = "deny" + } + actionDuration = "5m" + } + } + } +} + +resource "vercel_project" "managed_example" { + name = "firewall-managed-rule-example" +} + +resource "vercel_firewall_config" "managed" { + project_id = vercel_project.managed.id + + managed_rulesets { + owasp { + xss = { action = "deny" } + sqli = { action = "deny" } + rce = { action = "deny" } + php = { action = "deny" } + java = { action = "deny" } + lfi = { action = "deny" } + rfi = { action = "deny" } + gen = { action = "deny" } + } + } +} + +resource "vercel_project" "ip_example" { + name = "firewall-ip-blocking-example" +} + +resource "vercel_firewall_config" "ip-blocking" { + project_id = vercel_project.ip_example.id + + ip_rules { + # deny this subnet for all my hosts + rule { + action = "deny" + ip = "51.85.0.0/16" + hostname ="*" + } + + rule { + action = "challenge" + ip = "1.2.3.4" + hostname = "example.com" + } + } +} +``` + + +## Schema + +### Required + +- `project_id` (String) The ID of the project this configuration belongs to. + +### Optional + +- `enabled` (Boolean) Whether firewall is enabled or not. +- `ip_rules` (Block, Optional) IP rules to apply to the project. (see [below for nested schema](#nestedblock--ip_rules)) +- `managed_rulesets` (Block, Optional) The managed rulesets that are enabled. (see [below for nested schema](#nestedblock--managed_rulesets)) +- `rules` (Block, Optional) Custom rules to apply to the project (see [below for nested schema](#nestedblock--rules)) +- `team_id` (String) The ID of the team this project belongs to. + + +### Nested Schema for `ip_rules` + +Optional: + +- `rule` (Block List) (see [below for nested schema](#nestedblock--ip_rules--rule)) + + +### Nested Schema for `ip_rules.rule` + +Required: + +- `action` (String) +- `hostname` (String) Hosts to apply these rules to +- `ip` (String) IP or CIDR to block + +Optional: + +- `notes` (String) + +Read-Only: + +- `id` (String) The ID of this resource. + + + + +### Nested Schema for `managed_rulesets` + +Optional: + +- `owasp` (Block, Optional) Enable the owasp managed rulesets and select ruleset behaviors (see [below for nested schema](#nestedblock--managed_rulesets--owasp)) + + +### Nested Schema for `managed_rulesets.owasp` + +Optional: + +- `gen` (Attributes) Generic Attack Detection (see [below for nested schema](#nestedatt--managed_rulesets--owasp--gen)) +- `java` (Attributes) Java Attack Detection (see [below for nested schema](#nestedatt--managed_rulesets--owasp--java)) +- `lfi` (Attributes) Local File Inclusion Rules (see [below for nested schema](#nestedatt--managed_rulesets--owasp--lfi)) +- `ma` (Attributes) Multipart Rules (see [below for nested schema](#nestedatt--managed_rulesets--owasp--ma)) +- `php` (Attributes) PHP Attack Detection (see [below for nested schema](#nestedatt--managed_rulesets--owasp--php)) +- `rce` (Attributes) Remote Code Execution Rules (see [below for nested schema](#nestedatt--managed_rulesets--owasp--rce)) +- `rfi` (Attributes) Remote File Inclusion Rules (see [below for nested schema](#nestedatt--managed_rulesets--owasp--rfi)) +- `sd` (Attributes) Scanner Detection Rules (see [below for nested schema](#nestedatt--managed_rulesets--owasp--sd)) +- `sqli` (Attributes) SQL Injection Rules (see [below for nested schema](#nestedatt--managed_rulesets--owasp--sqli)) +- `xss` (Attributes) Cross Site Scripting Rules (see [below for nested schema](#nestedatt--managed_rulesets--owasp--xss)) + + +### Nested Schema for `managed_rulesets.owasp.gen` + +Required: + +- `action` (String) + +Optional: + +- `active` (Boolean) + + + +### Nested Schema for `managed_rulesets.owasp.java` + +Required: + +- `action` (String) + +Optional: + +- `active` (Boolean) + + + +### Nested Schema for `managed_rulesets.owasp.lfi` + +Required: + +- `action` (String) + +Optional: + +- `active` (Boolean) + + + +### Nested Schema for `managed_rulesets.owasp.ma` + +Required: + +- `action` (String) + +Optional: + +- `active` (Boolean) + + + +### Nested Schema for `managed_rulesets.owasp.php` + +Required: + +- `action` (String) + +Optional: + +- `active` (Boolean) + + + +### Nested Schema for `managed_rulesets.owasp.rce` + +Required: + +- `action` (String) + +Optional: + +- `active` (Boolean) + + + +### Nested Schema for `managed_rulesets.owasp.rfi` + +Required: + +- `action` (String) + +Optional: + +- `active` (Boolean) + + + +### Nested Schema for `managed_rulesets.owasp.sd` + +Required: + +- `action` (String) + +Optional: + +- `active` (Boolean) + + + +### Nested Schema for `managed_rulesets.owasp.sqli` + +Required: + +- `action` (String) + +Optional: + +- `active` (Boolean) + + + +### Nested Schema for `managed_rulesets.owasp.xss` + +Required: + +- `action` (String) + +Optional: + +- `active` (Boolean) + + + + + +### Nested Schema for `rules` + +Optional: + +- `rule` (Block List) (see [below for nested schema](#nestedblock--rules--rule)) + + +### Nested Schema for `rules.rule` + +Required: + +- `action` (Attributes) Actions to take when the condition groups match a request (see [below for nested schema](#nestedatt--rules--rule--action)) +- `condition_group` (Attributes List) Sets of conditions that may match a request (see [below for nested schema](#nestedatt--rules--rule--condition_group)) +- `name` (String) Name to identify the rule + +Optional: + +- `active` (Boolean) Rule is active or disabled +- `description` (String) + +Read-Only: + +- `id` (String) The ID of this resource. + + +### Nested Schema for `rules.rule.action` + +Required: + +- `action` (String) Base action + +Optional: + +- `action_duration` (String) Forward persistence of a rule aciton +- `rate_limit` (Attributes) Behavior or a rate limiting action. Required if action is rate_limit (see [below for nested schema](#nestedatt--rules--rule--action--rate_limit)) +- `redirect` (Attributes) How to redirect a request. Required if action is redirect (see [below for nested schema](#nestedatt--rules--rule--action--redirect)) + + +### Nested Schema for `rules.rule.action.rate_limit` + +Required: + +- `action` (String) Action to take when rate limit is exceeded +- `algo` (String) Rate limiting algorithm +- `keys` (List of String) Keys used to bucket an individual client +- `limit` (Number) number of requests allowed in the window +- `window` (Number) Time window in seconds + + + +### Nested Schema for `rules.rule.action.redirect` + +Required: + +- `location` (String) +- `permanent` (Boolean) + + + + +### Nested Schema for `rules.rule.condition_group` + +Required: + +- `conditions` (Attributes List) Conditions that must all match within a group (see [below for nested schema](#nestedatt--rules--rule--condition_group--conditions)) + + +### Nested Schema for `rules.rule.condition_group.conditions` + +Required: + +- `op` (String) How to comparse type to value +- `type` (String) Request key type to match against + +Optional: + +- `key` (String) Key within type to match against +- `neg` (Boolean) +- `value` (String) + +## Import + +Import is supported using the following syntax: + +```shell +terraform import vercel_firewall_config.example team_xxxxxxxxxxxxxxxxxxxxxxxx/prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` diff --git a/examples/resources/vercel_firewall_config/import.sh b/examples/resources/vercel_firewall_config/import.sh new file mode 100644 index 00000000..0f6e3e3a --- /dev/null +++ b/examples/resources/vercel_firewall_config/import.sh @@ -0,0 +1 @@ +terraform import vercel_firewall_config.example team_xxxxxxxxxxxxxxxxxxxxxxxx/prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx diff --git a/examples/resources/vercel_firewall_config/resource.tf b/examples/resources/vercel_firewall_config/resource.tf new file mode 100644 index 00000000..f4cf090a --- /dev/null +++ b/examples/resources/vercel_firewall_config/resource.tf @@ -0,0 +1,141 @@ +resource "vercel_project" "example" { + name = "firewall-config-example" +} + +resource "vercel_firewall_config" "example" { + project_id = vercel_project.example.id + + rules { + rule { + name = "Bypass Known request" + description = "Bypass requests using internal bearer tokens" + # individual condition groups are evaluated as ORs + condition_group = [ + { + conditions = [{ + type = "header" + key = "Authorization" + op = "eq" + value = "Bearer internaltoken" + }] + }, + { + conditions = [{ + type = "header" + key = "Authorization" + op = "eq" + value = "Bearer internaltoken2" + }] + } + ] + action = { + action = "bypass" + } + } + + rule { + name = "Challenge curl" + description = "Challenge user agents containing 'curl'" + condition_group = [{ + conditions = [{ + type = "user_agent" + op = "sub" + value = "curl" + }] + }] + action = { + action = "challenge" + } + } + + rule { + name = "Deny cookieless requests" + description = "requests to /api that are missing a session cookie" + # multiple conditions in a single condition group are evaluated as ANDs + condition_group = [{ + conditions = [{ + type = "path" + op = "eq" + value = "/api" + }, + { + type = "cookie" + key = "_session" + neg = true + op = "ex" + }] + }] + action = { + action = "challenge" + } + } + + rule { + name = "Rate limit API" + description = "apply ratelimit to requests under /api" + condition_group = [{ + conditions = [{ + type = "path" + op = "pre" + value = "/api" + }] + }] + + action = { + action = "rate_limit" + rate_limit = { + limit = 100 + window = 300 + keys = ["ip", "ja4"] + algo = "fixed_window" + action = "deny" + } + actionDuration = "5m" + } + } + } +} + +resource "vercel_project" "managed_example" { + name = "firewall-managed-rule-example" +} + +resource "vercel_firewall_config" "managed" { + project_id = vercel_project.managed.id + + managed_rulesets { + owasp { + xss = { action = "deny" } + sqli = { action = "deny" } + rce = { action = "deny" } + php = { action = "deny" } + java = { action = "deny" } + lfi = { action = "deny" } + rfi = { action = "deny" } + gen = { action = "deny" } + } + } +} + +resource "vercel_project" "ip_example" { + name = "firewall-ip-blocking-example" +} + +resource "vercel_firewall_config" "ip-blocking" { + project_id = vercel_project.ip_example.id + + ip_rules { + # deny this subnet for all my hosts + rule { + action = "deny" + ip = "51.85.0.0/16" + hostname ="*" + } + + rule { + action = "challenge" + ip = "1.2.3.4" + hostname = "example.com" + } + } +} diff --git a/go.mod b/go.mod index d61abc30..5185ae17 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,9 @@ module github.com/vercel/terraform-provider-vercel go 1.21 require ( - github.com/hashicorp/terraform-plugin-framework v1.7.0 - github.com/hashicorp/terraform-plugin-go v0.22.1 + github.com/hashicorp/terraform-plugin-framework v1.10.0 + github.com/hashicorp/terraform-plugin-framework-validators v0.13.0 + github.com/hashicorp/terraform-plugin-go v0.23.0 github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/hashicorp/terraform-plugin-testing v1.7.0 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 @@ -57,5 +58,5 @@ require ( google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect google.golang.org/grpc v1.63.2 // indirect - google.golang.org/protobuf v1.33.0 // indirect + google.golang.org/protobuf v1.34.0 // indirect ) diff --git a/go.sum b/go.sum index 965aa7fc..5c6922b1 100644 --- a/go.sum +++ b/go.sum @@ -73,10 +73,12 @@ github.com/hashicorp/terraform-exec v0.20.0 h1:DIZnPsqzPGuUnq6cH8jWcPunBfY+C+M8J github.com/hashicorp/terraform-exec v0.20.0/go.mod h1:ckKGkJWbsNqFKV1itgMnE0hY9IYf1HoiekpuN0eWoDw= github.com/hashicorp/terraform-json v0.21.0 h1:9NQxbLNqPbEMze+S6+YluEdXgJmhQykRyRNd+zTI05U= github.com/hashicorp/terraform-json v0.21.0/go.mod h1:qdeBs11ovMzo5puhrRibdD6d2Dq6TyE/28JiU4tIQxk= -github.com/hashicorp/terraform-plugin-framework v1.7.0 h1:wOULbVmfONnJo9iq7/q+iBOBJul5vRovaYJIu2cY/Pw= -github.com/hashicorp/terraform-plugin-framework v1.7.0/go.mod h1:jY9Id+3KbZ17OMpulgnWLSfwxNVYSoYBQFTgsx044CI= -github.com/hashicorp/terraform-plugin-go v0.22.1 h1:iTS7WHNVrn7uhe3cojtvWWn83cm2Z6ryIUDTRO0EV7w= -github.com/hashicorp/terraform-plugin-go v0.22.1/go.mod h1:qrjnqRghvQ6KnDbB12XeZ4FluclYwptntoWCr9QaXTI= +github.com/hashicorp/terraform-plugin-framework v1.10.0 h1:xXhICE2Fns1RYZxEQebwkB2+kXouLC932Li9qelozrc= +github.com/hashicorp/terraform-plugin-framework v1.10.0/go.mod h1:qBXLDn69kM97NNVi/MQ9qgd1uWWsVftGSnygYG1tImM= +github.com/hashicorp/terraform-plugin-framework-validators v0.13.0 h1:bxZfGo9DIUoLLtHMElsu+zwqI4IsMZQBRRy4iLzZJ8E= +github.com/hashicorp/terraform-plugin-framework-validators v0.13.0/go.mod h1:wGeI02gEhj9nPANU62F2jCaHjXulejm/X+af4PdZaNo= +github.com/hashicorp/terraform-plugin-go v0.23.0 h1:AALVuU1gD1kPb48aPQUjug9Ir/125t+AAurhqphJ2Co= +github.com/hashicorp/terraform-plugin-go v0.23.0/go.mod h1:1E3Cr9h2vMlahWMbsSEcNrOCxovCZhOOIXjFHbjc/lQ= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= github.com/hashicorp/terraform-plugin-sdk/v2 v2.33.0 h1:qHprzXy/As0rxedphECBEQAh3R4yp6pKksKHcqZx5G8= @@ -208,8 +210,8 @@ google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4= +google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/vercel/provider.go b/vercel/provider.go index bbcb7695..5162fb8d 100644 --- a/vercel/provider.go +++ b/vercel/provider.go @@ -57,6 +57,7 @@ func (p *vercelProvider) Resources(_ context.Context) []func() resource.Resource newEdgeConfigResource, newEdgeConfigSchemaResource, newEdgeConfigTokenResource, + newFirewallConfigResource, newLogDrainResource, newProjectDeploymentRetentionResource, newProjectDomainResource, diff --git a/vercel/resource_attack_challenge_mode.go b/vercel/resource_attack_challenge_mode.go index c3a7649a..b5bc0e64 100644 --- a/vercel/resource_attack_challenge_mode.go +++ b/vercel/resource_attack_challenge_mode.go @@ -63,7 +63,7 @@ Attack Challenge Mode prevent malicious traffic by showing a verification challe PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, }, "project_id": schema.StringAttribute{ - Description: "The ID of the Project to adjust the CPU for.", + Description: "The ID of the Project to toggle Attack Challenge Mode on.", Required: true, PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, }, diff --git a/vercel/resource_firewall_config.go b/vercel/resource_firewall_config.go new file mode 100644 index 00000000..b0e2c71e --- /dev/null +++ b/vercel/resource_firewall_config.go @@ -0,0 +1,991 @@ +package vercel + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/vercel/terraform-provider-vercel/client" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &firewallConfigResource{} + _ resource.ResourceWithConfigure = &firewallConfigResource{} + _ resource.ResourceWithImportState = &firewallConfigResource{} +) + +func newFirewallConfigResource() resource.Resource { return &firewallConfigResource{} } + +type firewallConfigResource struct { + client *client.Client +} + +func (r *firewallConfigResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_firewall_config" +} + +func (r *firewallConfigResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: ` +Define Custom Rules to shape the way your traffic is handled by the Vercel Edge Network.`, + Blocks: map[string]schema.Block{ + "managed_rulesets": schema.SingleNestedBlock{ + Description: "The managed rulesets that are enabled.", + Blocks: map[string]schema.Block{ + "owasp": schema.SingleNestedBlock{ + Description: "Enable the owasp managed rulesets and select ruleset behaviors", + Attributes: map[string]schema.Attribute{ + "xss": schema.SingleNestedAttribute{ + Optional: true, + Description: "Cross Site Scripting Rules", + Attributes: map[string]schema.Attribute{ + "active": schema.BoolAttribute{ + Optional: true, + }, + "action": schema.StringAttribute{ + Required: true, + }, + }, + }, + "sqli": schema.SingleNestedAttribute{ + Optional: true, + Description: "SQL Injection Rules", + Attributes: map[string]schema.Attribute{ + "active": schema.BoolAttribute{ + Optional: true, + }, + "action": schema.StringAttribute{ + Required: true, + }, + }, + }, + "lfi": schema.SingleNestedAttribute{ + Optional: true, + Description: "Local File Inclusion Rules", + Attributes: map[string]schema.Attribute{ + "active": schema.BoolAttribute{ + Optional: true, + }, + "action": schema.StringAttribute{ + Required: true, + }, + }, + }, + "rfi": schema.SingleNestedAttribute{ + Optional: true, + Description: "Remote File Inclusion Rules", + Attributes: map[string]schema.Attribute{ + "active": schema.BoolAttribute{ + Optional: true, + }, + "action": schema.StringAttribute{ + Required: true, + }, + }, + }, + "rce": schema.SingleNestedAttribute{ + Optional: true, + Description: "Remote Code Execution Rules", + Attributes: map[string]schema.Attribute{ + "active": schema.BoolAttribute{ + Optional: true, + }, + "action": schema.StringAttribute{ + Required: true, + }, + }, + }, + "sd": schema.SingleNestedAttribute{ + Optional: true, + Description: "Scanner Detection Rules", + Attributes: map[string]schema.Attribute{ + "active": schema.BoolAttribute{ + Optional: true, + }, + "action": schema.StringAttribute{ + Required: true, + }, + }, + }, + "ma": schema.SingleNestedAttribute{ + Optional: true, + Description: "Multipart Rules", + Attributes: map[string]schema.Attribute{ + "active": schema.BoolAttribute{ + Optional: true, + }, + "action": schema.StringAttribute{ + Required: true, + }, + }, + }, + "php": schema.SingleNestedAttribute{ + Optional: true, + Description: "PHP Attack Detection", + Attributes: map[string]schema.Attribute{ + "active": schema.BoolAttribute{ + Optional: true, + }, + "action": schema.StringAttribute{ + Required: true, + }, + }, + }, + "gen": schema.SingleNestedAttribute{ + Optional: true, + Description: "Generic Attack Detection", + Attributes: map[string]schema.Attribute{ + "active": schema.BoolAttribute{ + Optional: true, + }, + "action": schema.StringAttribute{ + Required: true, + }, + }, + }, + "java": schema.SingleNestedAttribute{ + Optional: true, + Description: "Java Attack Detection", + Attributes: map[string]schema.Attribute{ + "active": schema.BoolAttribute{ + Optional: true, + }, + "action": schema.StringAttribute{ + Required: true, + }, + }, + }, + }, + }, + }, + }, + "rules": schema.SingleNestedBlock{ + Description: "Custom rules to apply to the project", + Blocks: map[string]schema.Block{ + "rule": schema.ListNestedBlock{ + Validators: []validator.List{ + listvalidator.UniqueValues(), + }, + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + }, + "name": schema.StringAttribute{ + Description: "Name to identify the rule", + Required: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(4, 160), + }, + }, + "description": schema.StringAttribute{ + Optional: true, + Validators: []validator.String{ + stringvalidator.LengthAtMost(260), + }, + }, + "active": schema.BoolAttribute{ + Description: "Rule is active or disabled", + Optional: true, + }, + "action": schema.SingleNestedAttribute{ + Description: "Actions to take when the condition groups match a request", + Required: true, + Attributes: map[string]schema.Attribute{ + "action": schema.StringAttribute{ + Description: "Base action", + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf("bypass", "log", "challenge", "deny", "rate_limit", "redirect"), + }, + }, + "rate_limit": schema.SingleNestedAttribute{ + Description: "Behavior or a rate limiting action. Required if action is rate_limit", + Optional: true, + Attributes: map[string]schema.Attribute{ + "algo": schema.StringAttribute{ + Description: "Rate limiting algorithm", + Required: true, + }, + "window": schema.Int64Attribute{ + Description: "Time window in seconds", + Required: true, + }, + "limit": schema.Int64Attribute{ + Description: "number of requests allowed in the window", + Required: true, + }, + "keys": schema.ListAttribute{ + Description: "Keys used to bucket an individual client", + Required: true, + ElementType: types.StringType, + }, + "action": schema.StringAttribute{ + Description: "Action to take when rate limit is exceeded", + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf("bypass", "log", "challenge", "deny", "rate_limit"), + }, + }, + }, + }, + "redirect": schema.SingleNestedAttribute{ + Description: "How to redirect a request. Required if action is redirect", + Optional: true, + Attributes: map[string]schema.Attribute{ + "location": schema.StringAttribute{ + Required: true, + }, + "permanent": schema.BoolAttribute{ + Required: true, + }, + }, + }, + "action_duration": schema.StringAttribute{ + Description: "Forward persistence of a rule aciton", + Optional: true, + }, + }, + }, + "condition_group": schema.ListNestedAttribute{ + Description: "Sets of conditions that may match a request", + Required: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "conditions": schema.ListNestedAttribute{ + Description: "Conditions that must all match within a group", + Required: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "type": schema.StringAttribute{ + Description: "Request key type to match against", + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf( + "host", + "path", + "method", + "header", + "query", + "cookie", + "target_path", + "ip_address", + "region", + "protocol", + "scheme", + "environment", + "user_agent", + "geo_continent", + "geo_country", + "geo_country_region", + "geo_city", + "geo_as_number", + "ja4_digest", + "ja3_digest", + ), + }, + }, + "op": schema.StringAttribute{ + Description: "How to comparse type to value", + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf( + "re", + "eq", + "neq", + "ex", + "nex", + "inc", + "ninc", + "pre", + "suf", + "sub", + "gt", + "gte", + "lt", + "lte", + ), + }, + }, + "neg": schema.BoolAttribute{ + Optional: true, + }, + "key": schema.StringAttribute{ + Description: "Key within type to match against", + Optional: true, + }, + "value": schema.StringAttribute{ + Optional: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "ip_rules": schema.SingleNestedBlock{ + Description: "IP rules to apply to the project.", + Blocks: map[string]schema.Block{ + "rule": schema.ListNestedBlock{ + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + }, + "hostname": schema.StringAttribute{ + Description: "Hosts to apply these rules to", + Required: true, + }, + "notes": schema.StringAttribute{ + Optional: true, + }, + "ip": schema.StringAttribute{ + Description: "IP or CIDR to block", + Required: true, + }, + "action": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf("bypass", "log", "challenge", "deny"), + }, + }, + }, + }, + }, + }, + }, + }, + Attributes: map[string]schema.Attribute{ + "project_id": schema.StringAttribute{ + Description: "The ID of the project this configuration belongs to.", + Required: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + "team_id": schema.StringAttribute{ + Description: "The ID of the team this project belongs to.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplaceIfConfigured(), stringplanmodifier.UseStateForUnknown()}, + }, + "enabled": schema.BoolAttribute{ + Description: "Whether firewall is enabled or not.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + }, + } +} + +func (r *firewallConfigResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + "Expected *client.Client, got: %T. Please report this issue to the provider developers.", + ) + return + } + + r.client = client +} + +type FirewallConfig struct { + ProjectID types.String `tfsdk:"project_id"` + TeamID types.String `tfsdk:"team_id"` + Enabled types.Bool `tfsdk:"enabled"` + ManagedRulesets *FirewallManagedRulesets `tfsdk:"managed_rulesets"` + + Rules *FirewallRules `tfsdk:"rules"` + IPRules *IPRules `tfsdk:"ip_rules"` +} + +type FirewallManagedRulesets struct { + OWASP *CRSRule `tfsdk:"owasp"` +} + +type CRSRule struct { + XSS *CRSRuleConfig `tfsdk:"xss"` + SQLI *CRSRuleConfig `tfsdk:"sqli"` + LFI *CRSRuleConfig `tfsdk:"lfi"` + RFI *CRSRuleConfig `tfsdk:"rfi"` + RCE *CRSRuleConfig `tfsdk:"rce"` + SD *CRSRuleConfig `tfsdk:"sd"` + MA *CRSRuleConfig `tfsdk:"ma"` + PHP *CRSRuleConfig `tfsdk:"php"` + GEN *CRSRuleConfig `tfsdk:"gen"` + JAVA *CRSRuleConfig `tfsdk:"java"` +} + +func (r *CRSRule) ToMap() map[string]*CRSRuleConfig { + return map[string]*CRSRuleConfig{ + "xss": r.XSS, + "sqli": r.SQLI, + "lfi": r.LFI, + "rfi": r.RFI, + "rce": r.RCE, + "sd": r.SD, + "ma": r.MA, + "php": r.PHP, + "gen": r.GEN, + "java": r.JAVA, + } +} + +type CRSRuleConfig struct { + Active types.Bool `tfsdk:"active"` + Action types.String `tfsdk:"action"` +} + +type FirewallRules struct { + Rules []FirewallRule `tfsdk:"rule"` +} + +type FirewallRule struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + Active types.Bool `tfsdk:"active"` + ConditionGroup []ConditionGroup `tfsdk:"condition_group"` + Action Mitigate `tfsdk:"action"` +} + +func (r *FirewallRule) Conditions() []client.ConditionGroup { + var groups []client.ConditionGroup + for _, group := range r.ConditionGroup { + var conditions []client.Condition + for _, condition := range group.Conditions { + conditions = append(conditions, client.Condition{ + Type: condition.Type.ValueString(), + Op: condition.Op.ValueString(), + Neg: condition.Neg.ValueBool(), + Key: condition.Key.ValueString(), + Value: condition.Value.ValueString(), + }) + } + groups = append(groups, client.ConditionGroup{ + Conditions: conditions, + }) + } + return groups +} + +func (r *FirewallRule) Mitigate() (client.Mitigate, error) { + mit := client.Mitigate{ + Action: r.Action.Action.ValueString(), + } + if !r.Action.RateLimit.IsNull() { + rl := &client.RateLimit{} + diags := r.Action.RateLimit.As(context.Background(), rl, basetypes.ObjectAsOptions{ + UnhandledNullAsEmpty: false, + UnhandledUnknownAsEmpty: false, + }) + if diags.HasError() { + return mit, fmt.Errorf("error converting rate limit: %s - %s", diags[0].Summary(), diags[0].Detail()) + } + mit.RateLimit = rl + } + + if !r.Action.Redirect.IsNull() { + rd := &client.Redirect{} + diags := r.Action.Redirect.As(context.Background(), rd, basetypes.ObjectAsOptions{ + UnhandledNullAsEmpty: true, + UnhandledUnknownAsEmpty: true, + }) + if diags.HasError() { + return mit, fmt.Errorf("error converting rate limit: %s - %s", diags[0].Summary(), diags[0].Detail()) + } + mit.Redirect = rd + } + if !r.Action.ActionDuration.IsNull() { + mit.ActionDuration = r.Action.ActionDuration.ValueString() + } + return mit, nil +} + +func fromFirewallRule(rule client.FirewallRule, ref FirewallRule) (FirewallRule, error) { + var err error + r := FirewallRule{ + ID: types.StringValue(rule.ID), + Name: types.StringValue(rule.Name), + Description: types.StringValue(rule.Description), + Active: types.BoolValue(rule.Active), + } + if rule.Active && ref.Active == types.BoolNull() { + r.Active = ref.Active + } + + r.Action, err = fromMitigate(rule.Action.Mitigate, ref.Action) + if err != nil { + return r, err + } + var conditionGroups = make([]ConditionGroup, len(rule.ConditionGroup)) + for j, group := range rule.ConditionGroup { + var conditions = make([]Condition, len(group.Conditions)) + for k, condition := range group.Conditions { + var cond = Condition{} + if len(ref.ConditionGroup) > j && len(ref.ConditionGroup[j].Conditions) > k { + cond = ref.ConditionGroup[j].Conditions[k] + } + conditions[k] = fromCondition(condition, cond) + } + conditionGroups[j] = ConditionGroup{ + Conditions: conditions, + } + } + r.ConditionGroup = conditionGroups + // Description and active can be optional + if rule.Description == "" && ref.Description == types.StringNull() { + r.Description = ref.Description + } + if rule.Active && ref.Active == types.BoolNull() { + r.Active = ref.Active + } + + return r, nil +} + +var redirectType = types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "location": types.StringType, + "permanent": types.BoolType, + }, +} + +var ratelimitType = types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "algo": types.StringType, + "window": types.Int64Type, + "limit": types.Int64Type, + "keys": types.ListType{ + ElemType: types.StringType, + }, + "action": types.StringType, + }, +} + +type Mitigate struct { + Action types.String `tfsdk:"action"` + RateLimit types.Object `tfsdk:"rate_limit"` + Redirect types.Object `tfsdk:"redirect"` + ActionDuration types.String `tfsdk:"action_duration"` +} + +func fromMitigate(mitigate client.Mitigate, ref Mitigate) (Mitigate, error) { + m := Mitigate{ + Action: types.StringValue(mitigate.Action), + ActionDuration: types.StringValue(mitigate.ActionDuration), + Redirect: types.ObjectNull(redirectType.AttrTypes), + RateLimit: types.ObjectNull(ratelimitType.AttrTypes), + } + + if mitigate.ActionDuration == "" && ref.ActionDuration == types.StringNull() { + m.ActionDuration = ref.ActionDuration + } + + if mitigate.RateLimit != nil { + keys, diags := basetypes.NewListValueFrom(context.Background(), types.StringType, mitigate.RateLimit.Keys) + if diags.HasError() { + return m, fmt.Errorf("error converting keys: %s - %s", diags[0].Summary(), diags[0].Detail()) + } + m.RateLimit = types.ObjectValueMust( + ratelimitType.AttrTypes, + map[string]attr.Value{ + "algo": types.StringValue(mitigate.RateLimit.Algo), + "window": types.Int64Value(mitigate.RateLimit.Window), + "limit": types.Int64Value(mitigate.RateLimit.Limit), + "keys": keys, + "action": types.StringValue(mitigate.RateLimit.Action), + }, + ) + } + if mitigate.Redirect != nil { + m.Redirect = types.ObjectValueMust( + redirectType.AttrTypes, + map[string]attr.Value{ + "location": types.StringValue(mitigate.Redirect.Location), + "permanent": types.BoolValue(mitigate.Redirect.Permanent), + }, + ) + } + return m, nil +} + +type Redirect struct { + Location types.String `tfsdk:"location"` + Permanent types.Bool `tfsdk:"permanent"` +} + +type RateLimit struct { + Algo types.String `tfsdk:"algo"` + Window types.Int64 `tfsdk:"window"` + Limit types.Int64 `tfsdk:"limit"` + Keys types.List `tfsdk:"keys"` + Action types.String `tfsdk:"action"` +} + +type ConditionGroup struct { + Conditions []Condition `tfsdk:"conditions"` +} + +type Condition struct { + Type types.String `tfsdk:"type"` + Op types.String `tfsdk:"op"` + Neg types.Bool `tfsdk:"neg"` + Key types.String `tfsdk:"key"` + Value types.String `tfsdk:"value"` +} + +func fromCondition(condition client.Condition, ref Condition) Condition { + c := Condition{ + Type: types.StringValue(condition.Type), + Op: types.StringValue(condition.Op), + Value: types.StringValue(condition.Value), + Key: types.StringValue(condition.Key), + Neg: types.BoolValue(condition.Neg), + } + // Neg and Key are optional + if ref.Neg == types.BoolNull() { + c.Neg = types.BoolNull() + } + // if key is present it's possible for value to be optional + if ref.Key == types.StringNull() { + c.Key = types.StringNull() + } else { + if ref.Value == types.StringNull() { + c.Value = types.StringNull() + } + } + return c +} + +type IPRules struct { + Rules []IPRule `tfsdk:"rule"` +} + +type IPRule struct { + ID types.String `tfsdk:"id"` + Hostname types.String `tfsdk:"hostname"` + IP types.String `tfsdk:"ip"` + Notes types.String `tfsdk:"notes"` + Action types.String `tfsdk:"action"` +} + +func fromCRS(conf map[string]client.CoreRuleSet, refMr *FirewallManagedRulesets) *CRSRule { + var ref = &CRSRule{} + if refMr != nil && refMr.OWASP != nil { + ref = refMr.OWASP + } + if conf == nil || ref == nil { + return nil + } + return &CRSRule{ + XSS: fromCoreRuleset(conf["xss"], ref.XSS), + SQLI: fromCoreRuleset(conf["sqli"], ref.SQLI), + LFI: fromCoreRuleset(conf["lfi"], ref.LFI), + RFI: fromCoreRuleset(conf["rfi"], ref.RFI), + RCE: fromCoreRuleset(conf["rce"], ref.RCE), + SD: fromCoreRuleset(conf["sd"], ref.SD), + MA: fromCoreRuleset(conf["ma"], ref.MA), + PHP: fromCoreRuleset(conf["php"], ref.PHP), + GEN: fromCoreRuleset(conf["gen"], ref.GEN), + JAVA: fromCoreRuleset(conf["java"], ref.JAVA), + } +} + +func fromCoreRuleset(crsRule client.CoreRuleSet, ref *CRSRuleConfig) *CRSRuleConfig { + if ref == nil && !crsRule.Active && crsRule.Action == "log" { + return nil + } + c := &CRSRuleConfig{ + Active: types.BoolValue(crsRule.Active), + Action: types.StringValue(crsRule.Action), + } + if (ref == nil && crsRule.Active) || + ref != nil && ref.Active == types.BoolNull() { + c.Active = types.BoolNull() + } + return c +} + +func fromClient(conf client.FirewallConfig, state FirewallConfig) (FirewallConfig, error) { + var err error + cfg := FirewallConfig{ + ProjectID: state.ProjectID, + // Take the teamID from the response/provider if it wasn't provided in resource + TeamID: types.StringValue(conf.TeamID), + Enabled: state.Enabled, + } + // Enabled can be null + if conf.Enabled && state.Enabled.IsNull() { + cfg.Enabled = state.Enabled + } + + if len(conf.Rules) > 0 { + rules := make([]FirewallRule, len(conf.Rules)) + for i, rule := range conf.Rules { + // Set empty optional types + var stateRule = FirewallRule{ + Active: types.BoolNull(), + } + if state.Rules != nil && len(state.Rules.Rules)-1 > i { + stateRule = state.Rules.Rules[i] + } + rules[i], err = fromFirewallRule(rule, stateRule) + if err != nil { + return cfg, err + } + } + cfg.Rules = &FirewallRules{Rules: rules} + } + + if len(conf.IPRules) > 0 { + ipRules := make([]IPRule, len(conf.IPRules)) + for i, iprule := range conf.IPRules { + ipRules[i] = IPRule{ + ID: types.StringValue(iprule.ID), + Hostname: types.StringValue(iprule.Hostname), + IP: types.StringValue(iprule.IP), + Notes: types.StringValue(iprule.Notes), + Action: types.StringValue(iprule.Action), + } + // notes don't have to be set + if iprule.Notes == "" && state.IPRules != nil && len(state.IPRules.Rules) > i && state.IPRules.Rules[i].Notes.IsNull() { + ipRules[i].Notes = state.IPRules.Rules[i].Notes + } + } + + cfg.IPRules = &IPRules{Rules: ipRules} + } + + managedRulesets := &FirewallManagedRulesets{} + if conf.ManagedRulesets != nil && conf.CRS != nil { + cfg.ManagedRulesets = managedRulesets + cfg.ManagedRulesets.OWASP = fromCRS(conf.CRS, state.ManagedRulesets) + } + + return cfg, nil +} + +func (f *FirewallConfig) toClient() (client.FirewallConfig, error) { + conf := client.FirewallConfig{ + ProjectID: f.ProjectID.ValueString(), + TeamID: f.TeamID.ValueString(), + Enabled: f.Enabled.IsNull() || f.Enabled.ValueBool(), + } + + if f.ManagedRulesets != nil { + conf.ManagedRulesets = make(map[string]client.ManagedRule) + if f.ManagedRulesets.OWASP != nil { + conf.ManagedRulesets["owasp"] = client.ManagedRule{ + Active: true, + } + conf.CRS = make(map[string]client.CoreRuleSet) + for key, value := range f.ManagedRulesets.OWASP.ToMap() { + if value != nil { + conf.CRS[key] = client.CoreRuleSet{ + Action: value.Action.ValueString(), + Active: value.Active.IsNull() || value.Active.ValueBool(), + } + } + } + } + } + if f.Rules != nil && len(f.Rules.Rules) > 0 { + for _, rule := range f.Rules.Rules { + mit, err := rule.Mitigate() + if err != nil { + return conf, err + } + conf.Rules = append(conf.Rules, client.FirewallRule{ + ID: rule.ID.ValueString(), + Name: rule.Name.ValueString(), + Description: rule.Description.ValueString(), + Active: rule.Active.IsNull() || rule.Active.ValueBool(), + ConditionGroup: rule.Conditions(), + Action: client.Action{ + Mitigate: mit, + }, + }) + } + } + + if f.IPRules != nil && len(f.IPRules.Rules) > 0 { + for _, iprule := range f.IPRules.Rules { + conf.IPRules = append(conf.IPRules, client.IPRule{ + ID: iprule.ID.ValueString(), + Hostname: iprule.Hostname.ValueString(), + IP: iprule.IP.ValueString(), + Notes: iprule.Notes.ValueString(), + Action: iprule.Action.ValueString(), + }) + } + } + return conf, nil +} + +func (r *firewallConfigResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + + var plan FirewallConfig + diags := req.Plan.Get(ctx, &plan) + if resp.Diagnostics.HasError() { + return + } + + conf, err := plan.toClient() + if err != nil { + diags.AddError("failed to convert plan to client", err.Error()) + return + } + + out, err := r.client.PutFirewallConfig(ctx, conf) + if err != nil { + diags.AddError("failed to create firewall config", err.Error()) + return + } + + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + cfg, err := fromClient(out, plan) + if err != nil { + diags.AddError("failed to read created firewall config", err.Error()) + return + } + diags = resp.State.Set(ctx, cfg) + resp.Diagnostics.Append(diags...) +} + +func (r *firewallConfigResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state FirewallConfig + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + out, err := r.client.GetFirewallConfig(ctx, state.ProjectID.ValueString(), state.TeamID.ValueString()) + if err != nil { + diags.AddError("failed to read firewall config", err.Error()) + return + } + if client.NotFound(err) { + resp.State.RemoveResource(ctx) + return + } + cfg, err := fromClient(out, state) + if err != nil { + diags.AddError("failed to read firewall config", err.Error()) + return + } + diags = resp.State.Set(ctx, cfg) + resp.Diagnostics.Append(diags...) +} + +func (r *firewallConfigResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan FirewallConfig + diags := req.Plan.Get(ctx, &plan) + if resp.Diagnostics.HasError() { + return + } + + conf, err := plan.toClient() + if err != nil { + diags.AddError("failed to convert plan to client", err.Error()) + return + } + + out, err := r.client.PutFirewallConfig(ctx, conf) + if err != nil { + diags.AddError("failed to create firewall config", err.Error()) + return + } + + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + cfg, err := fromClient(out, plan) + if err != nil { + diags.AddError("failed to read updated firewall config", err.Error()) + return + } + diags = resp.State.Set(ctx, cfg) + resp.Diagnostics.Append(diags...) +} +func (r *firewallConfigResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state FirewallConfig + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + conf := client.FirewallConfig{ + Enabled: false, + ProjectID: state.ProjectID.ValueString(), + TeamID: state.TeamID.ValueString(), + } + + _, err := r.client.PutFirewallConfig(ctx, conf) + if err != nil { + resp.Diagnostics.AddError("failed to delete firewall config", err.Error()) + return + } + tflog.Info(ctx, "deleted firewall config", map[string]interface{}{ + "project_id": state.ProjectID.ValueString(), + "team_id": state.TeamID.ValueString(), + }) +} + +func (r *firewallConfigResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + teamID, projectID, ok := splitInto1Or2(req.ID) + if !ok { + resp.Diagnostics.AddError( + "Error importing Firewall Config", + fmt.Sprintf("Invalid id '%s' specified. should be in format \"team_id/project_id\" or \"project_id\"", req.ID), + ) + } + out, err := r.client.GetFirewallConfig(ctx, projectID, teamID) + if err != nil { + resp.Diagnostics.AddError("Error importing Firewall Config", err.Error()) + return + } + conf, err := fromClient(out, FirewallConfig{ + ProjectID: types.StringValue(projectID), + TeamID: types.StringValue(out.TeamID), // use output teamID if not provided on import + }) + if err != nil { + resp.Diagnostics.AddError("failed to read firewall config", err.Error()) + return + } + tflog.Info(ctx, "imported firewall config", map[string]interface{}{ + "team_id": conf.TeamID.ValueString(), + "project_id": conf.ProjectID.ValueString(), + }) + diags := resp.State.Set(ctx, conf) + resp.Diagnostics.Append(diags...) +} diff --git a/vercel/resource_firewall_config_test.go b/vercel/resource_firewall_config_test.go new file mode 100644 index 00000000..81c64c2a --- /dev/null +++ b/vercel/resource_firewall_config_test.go @@ -0,0 +1,543 @@ +package vercel_test + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +func getFirewallImportID(n string) resource.ImportStateIdFunc { + return func(s *terraform.State) (string, error) { + rs, ok := s.RootModule().Resources[n] + if !ok { + return "", fmt.Errorf("not found: %s", n) + } + + if rs.Primary.ID == "" { + return "", fmt.Errorf("no ID is set") + } + + if rs.Primary.Attributes["team_id"] == "" { + return rs.Primary.ID, nil + } + return fmt.Sprintf("%s/%s", rs.Primary.Attributes["team_id"], rs.Primary.Attributes["project_id"]), nil + } +} + +func TestAcc_FirewallConfigResource(t *testing.T) { + name := acctest.RandString(16) + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccFirewallConfigResource(name, teamIDConfig()), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr( + "vercel_firewall_config.managed", + "managed_rulesets.owasp.xss.action", + "deny"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.managed", + "managed_rulesets.owasp.sqli.action", + "log"), + resource.TestCheckNoResourceAttr( + "vercel_firewall_config.managed", + "managed_rulesets.owasp.sqli.active"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.managed", + "managed_rulesets.owasp.rce.active", + "false"), + resource.TestCheckNoResourceAttr( + "vercel_firewall_config.managed", + "managed_rulesets.owasp.php"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "enabled", + "true"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.0.name", + "test"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.0.id", + "rule_test"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.0.action.action", + "deny"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.0.condition_group.0.conditions.0.type", + "path"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.0.condition_group.0.conditions.1.value", + "POST"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.1.id", + "rule_test2"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.1.action.action", + "rate_limit"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.1.action.rate_limit.limit", + "100"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.1.action.rate_limit.algo", + "fixed_window"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.1.action.rate_limit.action", + "challenge"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.1.action.rate_limit.window", + "60"), + resource.TestCheckTypeSetElemAttr( + "vercel_firewall_config.custom", + "rules.rule.1.action.rate_limit.keys.*", + "ip"), + resource.TestCheckTypeSetElemAttr( + "vercel_firewall_config.custom", + "rules.rule.1.action.rate_limit.keys.*", + "ja4"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.2.id", + "rule_test3"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.2.action.redirect.location", + "/bye"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.2.action.redirect.permanent", + "false"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.ips", + "ip_rules.rule.0.action", + "deny"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.ips", + "ip_rules.rule.0.hostname", + "test.com"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.ips", + "ip_rules.rule.1.ip", + "1.2.3.4/32"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.ips", + "ip_rules.rule.1.hostname", + "*.test.com"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.ips", + "ip_rules.rule.2.ip", + "2.4.6.8"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.ips", + "ip_rules.rule.2.hostname", + "*"), + ), + }, + { + ImportState: true, + ResourceName: "vercel_firewall_config.managed", + ImportStateIdFunc: getFirewallImportID("vercel_firewall_config.managed"), + }, + { + ImportState: true, + ResourceName: "vercel_firewall_config.custom", + ImportStateIdFunc: getFirewallImportID("vercel_firewall_config.custom"), + }, + { + ImportState: true, + ResourceName: "vercel_firewall_config.ips", + ImportStateIdFunc: getFirewallImportID("vercel_firewall_config.ips"), + }, + { + Config: testAccFirewallConfigResourceUpdated(name, teamIDConfig()), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr( + "vercel_firewall_config.managed", + "managed_rulesets.owasp.xss.action", + "deny"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.managed", + "managed_rulesets.owasp.sqli.action", + "deny"), + resource.TestCheckNoResourceAttr( + "vercel_firewall_config.managed", + "managed_rulesets.owasp.sqli.active"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.managed", + "managed_rulesets.owasp.rce.active", + "false"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.managed", + "managed_rulesets.owasp.php.action", + "log"), + resource.TestCheckNoResourceAttr( + "vercel_firewall_config.managed", + "managed_rulesets.owasp.php.active"), + resource.TestCheckNoResourceAttr( + "vercel_firewall_config.managed", + "managed_rulesets.owasp.java"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "enabled", + "true"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.0.name", + "test1"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.0.id", + "rule_test1"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.0.action.action", + "deny"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.0.condition_group.0.conditions.0.type", + "path"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.0.condition_group.0.conditions.1.value", + "POST"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.1.id", + "rule_test2"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.1.action.action", + "rate_limit"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.1.action.rate_limit.limit", + "150"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.1.action.rate_limit.algo", + "fixed_window"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.1.action.rate_limit.action", + "challenge"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.1.action.rate_limit.window", + "60"), + resource.TestCheckTypeSetElemAttr( + "vercel_firewall_config.custom", + "rules.rule.1.action.rate_limit.keys.*", + "ip"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.2.id", + "rule_test3"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.2.action.redirect.location", + "/bye"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.custom", + "rules.rule.2.action.redirect.permanent", + "false"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.ips", + "ip_rules.rule.0.action", + "deny"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.ips", + "ip_rules.rule.0.hostname", + "test.com"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.ips", + "ip_rules.rule.1.ip", + "1.2.3.4/32"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.ips", + "ip_rules.rule.1.hostname", + "*.test.com"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.ips", + "ip_rules.rule.2.ip", + "2.4.6.8/32"), + resource.TestCheckResourceAttr( + "vercel_firewall_config.ips", + "ip_rules.rule.2.hostname", + "*"), + ), + }, + }, + }) +} + +func testAccFirewallConfigResource(name, teamID string) string { + return fmt.Sprintf(` +resource "vercel_project" "managed" { + name = "test-acc-%[1]s-mrs" + %[2]s +} + +resource "vercel_firewall_config" "managed" { + project_id = vercel_project.managed.id + %[2]s + + managed_rulesets { + owasp { + xss = { action = "deny" } + sqli = { action = "log" } + + rce = { action = "deny", active = false } + } + } +} + +resource "vercel_project" "custom" { + name = "test-acc-%[1]s-custom" + %[2]s +} + +resource "vercel_firewall_config" "custom" { + project_id = vercel_project.custom.id + %[2]s + + rules { + rule { + name = "test" + action = { + action = "deny" + } + condition_group = [{ + conditions = [{ + type = "path" + op = "eq" + value = "/test" + }, + { + type = "method" + op = "eq" + value = "POST" + }] + }] + } + rule { + name = "test2" + action = { + action = "rate_limit" + rate_limit = { + limit = 100 + window = 60 + algo = "fixed_window" + keys = ["ip", "ja4"] + action = "challenge" + + } + } + condition_group = [{ + conditions = [{ + type = "path" + op = "eq" + value = "/api" + }] + }] + } + rule { + name = "test3" + action = { + action = "redirect" + redirect = { + location = "/bye" + permanent = false + + } + } + condition_group = [{ + conditions = [{ + type = "path" + op = "eq" + value = "/api" + }] + }] + } + + rule { + name = "test4" + action = { + action = "log" + } + condition_group = [{ + conditions = [{ + type = "ja4_digest" + op = "eq" + value = "fakeja4" + }] + }] + } + } +} + +resource "vercel_project" "ips" { + name = "test-acc-%[1]s-ips" + %[2]s +} + +resource "vercel_firewall_config" "ips" { + project_id = vercel_project.ips.id + %[2]s + + ip_rules { + rule { + action = "deny" + ip = "5.5.0.0/16" + hostname = "test.com" + } + rule { + action = "deny" + ip = "1.2.3.4/32" + hostname = "*.test.com" + } + rule { + action = "deny" + ip = "2.4.6.8" + hostname = "*" + } + } +} + +`, name, teamID) +} + +func testAccFirewallConfigResourceUpdated(name, teamID string) string { + return fmt.Sprintf(` +resource "vercel_project" "managed" { + name = "test-acc-%[1]s-mrs" + %[2]s +} + +resource "vercel_firewall_config" "managed" { + project_id = vercel_project.managed.id + %[2]s + + managed_rulesets { + owasp { + xss = { action = "deny", active = false } + sqli = { action = "deny" } + + rce = { action = "deny", active = false } + php = { action = "log" } + } + } +} + +resource "vercel_project" "custom" { + name = "test-acc-%[1]s-custom" + %[2]s +} + +resource "vercel_firewall_config" "custom" { + project_id = vercel_project.custom.id + %[2]s + + rules { + rule { + name = "test1" + action = { + action = "deny" + } + condition_group = [{ + conditions = [{ + type = "path" + op = "eq" + value = "/test" + }, + { + type = "method" + op = "eq" + value = "POST" + }] + }] + } + rule { + name = "test2" + action = { + action = "rate_limit" + rate_limit = { + limit = 150 + window = 60 + algo = "fixed_window" + keys = ["ip"] + action = "challenge" + + } + } + condition_group = [{ + conditions = [{ + type = "path" + op = "eq" + value = "/api" + }] + }] + } + rule { + name = "test3" + action = { + action = "redirect" + redirect = { + location = "/bye" + permanent = false + + } + } + condition_group = [{ + conditions = [{ + type = "path" + op = "eq" + value = "/api" + }] + }] + } + } +} + +resource "vercel_project" "ips" { + name = "test-acc-%[1]s-ips" + %[2]s +} + +resource "vercel_firewall_config" "ips" { + project_id = vercel_project.ips.id + %[2]s + + ip_rules { + rule { + action = "deny" + ip = "5.6.0.0/16" + hostname = "test.com" + } + rule { + action = "challenge" + ip = "1.2.3.4/32" + hostname = "*.test.com" + } + rule { + action = "deny" + ip = "2.4.6.8/32" + hostname = "*" + } + } +}`, name, teamID) +}