diff --git a/client/firewall_bypass.go b/client/firewall_bypass.go new file mode 100644 index 00000000..b4f2779a --- /dev/null +++ b/client/firewall_bypass.go @@ -0,0 +1,99 @@ +package client + +import ( + "context" + "fmt" +) + +type FirewallBypassRule struct { + Domain string `json:"domain,omitempty"` + SourceIp string `json:"sourceIp"` + ProjectScope bool `json:"projectScope,omitempty"` +} + +type FirewallBypass struct { + OwnerId string `json:"OwnerId"` + Id string `json:"Id"` + Domain string `json:"Domain"` + Ip string `json:"Ip"` + IsProjectRule bool `json:"IsProjectRule"` +} + +func (c *Client) GetFirewallBypass(ctx context.Context, teamID, projectID string, request FirewallBypassRule) (a FirewallBypass, err error) { + url := fmt.Sprintf("%s/v1/security/firewall/bypass?projectId=%s", c.baseURL, projectID) + if tid := c.teamID(teamID); tid != "" { + url = fmt.Sprintf("%s&teamId=%s", url, tid) + } + url = fmt.Sprintf("%s&sourceIp=%s", url, request.SourceIp) + if request.Domain == "*" { + url = fmt.Sprintf("%s&projectScope=true", url) + } else { + url = fmt.Sprintf("%s&domain=%s", url, request.Domain) + } + + var res struct { + Result []FirewallBypass `json:"result"` + } + err = c.doRequest(clientRequest{ + ctx: ctx, + method: "GET", + url: url, + }, &res) + if err != nil || len(res.Result) == 0 { + return FirewallBypass{}, err + } + return res.Result[0], err +} + +func (c *Client) CreateFirewallBypass(ctx context.Context, teamID, projectID string, request FirewallBypassRule) (a FirewallBypass, err error) { + url := fmt.Sprintf("%s/v1/security/firewall/bypass?projectId=%s", c.baseURL, projectID) + if tid := c.teamID(teamID); tid != "" { + url = fmt.Sprintf("%s&teamId=%s", url, tid) + } + if request.Domain == "*" { + request.Domain = "" + request.ProjectScope = true + } + + payload := string(mustMarshal(request)) + var res struct { + Result []FirewallBypass `json:"result"` + } + err = c.doRequest(clientRequest{ + ctx: ctx, + method: "POST", + url: url, + body: payload, + }, &res) + if err != nil { + return FirewallBypass{}, err + } + if len(res.Result) == 0 { + return FirewallBypass{}, fmt.Errorf("no result returned") + } + return res.Result[0], err +} + +func (c *Client) RemoveFirewallBypass(ctx context.Context, teamID, projectID string, request FirewallBypassRule) (a FirewallBypass, err error) { + url := fmt.Sprintf("%s/v1/security/firewall/bypass?projectId=%s", c.baseURL, projectID) + if tid := c.teamID(teamID); tid != "" { + url = fmt.Sprintf("%s&teamId=%s", url, tid) + } + if request.Domain == "*" { + request.Domain = "" + request.ProjectScope = true + } + + payload := string(mustMarshal(request)) + var res FirewallBypass + err = c.doRequest(clientRequest{ + ctx: ctx, + method: "DELETE", + url: url, + body: payload, + }, &res) + if err != nil { + return a, err + } + return FirewallBypass{}, err +} diff --git a/docs/resources/firewall_bypass.md b/docs/resources/firewall_bypass.md new file mode 100644 index 00000000..0a15ccef --- /dev/null +++ b/docs/resources/firewall_bypass.md @@ -0,0 +1,75 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "vercel_firewall_bypass Resource - terraform-provider-vercel" +subcategory: "" +description: |- + Provides a Firewall Bypass Rule + Firewall Bypass Rules configure sets of domains and ip address to prevent bypass Vercel's system mitigations for. The hosts used in a bypass rule must be a production domain assigned to the associated project. Requests that bypass system mitigations will incur usage. +--- + +# vercel_firewall_bypass (Resource) + +Provides a Firewall Bypass Rule + +Firewall Bypass Rules configure sets of domains and ip address to prevent bypass Vercel's system mitigations for. The hosts used in a bypass rule must be a production domain assigned to the associated project. Requests that bypass system mitigations will incur usage. + +## Example Usage + +```terraform +resource "vercel_project" "example" { + name = "firewall-bypass-example" +} + +resource "vercel_firewall_bypass" "bypass_targeted" { + project_id = vercel_project.example.id + + source_ip = "5.6.7.8" + # Any project domain assigned to the project can be used + domain = "my-production-domain.com" +} + +resource "vercel_firewall_bypass" "bypass_cidr" { + project_id = vercel_project.example.id + + # CIDR ranges can be used as the source in bypass rules + source_ip = "52.33.44.0/24" + domain = "my-production-domain.com" +} + +resource "vercel_firewall_bypass" "bypass_all" { + project_id = vercel_project.example.id + + source_ip = "52.33.44.0/24" + # the wildcard only domain can be used to apply a bypass + # for all the _production_ domains assigned to the project. + domain = "*" +} +``` + + +## Schema + +### Required + +- `domain` (String) The domain to configure the bypass rule for. +- `project_id` (String) The ID of the Project to assign the bypass rule to +- `source_ip` (String) The source IP address to configure the bypass rule for. + +### Optional + +- `team_id` (String) The ID of the team the Project exists under. Required when configuring a team resource if a default team has not been set in the provider. + +### Read-Only + +- `id` (String) The identifier for the firewall bypass rule. + +## Import + +Import is supported using the following syntax: + +```shell +terraform import vercel_firewall_bypass.example team_xxxxxxxxxxxxxxxxxxxxxxxx/prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx#mybypasshost.com#3.4.5.0/24 + + +terraform import vercel_firewall_bypass.example team_xxxxxxxxxxxxxxxxxxxxxxxx/prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx#3.4.5.0/24 +``` diff --git a/examples/resources/vercel_firewall_bypass/import.sh b/examples/resources/vercel_firewall_bypass/import.sh new file mode 100644 index 00000000..0ba7cd72 --- /dev/null +++ b/examples/resources/vercel_firewall_bypass/import.sh @@ -0,0 +1,4 @@ +terraform import vercel_firewall_bypass.example team_xxxxxxxxxxxxxxxxxxxxxxxx/prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx#mybypasshost.com#3.4.5.0/24 + + +terraform import vercel_firewall_bypass.example team_xxxxxxxxxxxxxxxxxxxxxxxx/prj_xxxxxxxxxxxxxxxxxxxxxxxxxxxx#3.4.5.0/24 diff --git a/examples/resources/vercel_firewall_bypass/resource.tf b/examples/resources/vercel_firewall_bypass/resource.tf new file mode 100644 index 00000000..f6537d5a --- /dev/null +++ b/examples/resources/vercel_firewall_bypass/resource.tf @@ -0,0 +1,28 @@ +resource "vercel_project" "example" { + name = "firewall-bypass-example" +} + +resource "vercel_firewall_bypass" "bypass_targeted" { + project_id = vercel_project.example.id + + source_ip = "5.6.7.8" + # Any project domain assigned to the project can be used + domain = "my-production-domain.com" +} + +resource "vercel_firewall_bypass" "bypass_cidr" { + project_id = vercel_project.example.id + + # CIDR ranges can be used as the source in bypass rules + source_ip = "52.33.44.0/24" + domain = "my-production-domain.com" +} + +resource "vercel_firewall_bypass" "bypass_all" { + project_id = vercel_project.example.id + + source_ip = "52.33.44.0/24" + # the wildcard only domain can be used to apply a bypass + # for all the _production_ domains assigned to the project. + domain = "*" +} diff --git a/vercel/provider.go b/vercel/provider.go index 8dcb97ec..bbaec77e 100644 --- a/vercel/provider.go +++ b/vercel/provider.go @@ -62,6 +62,7 @@ func (p *vercelProvider) Resources(_ context.Context) []func() resource.Resource newEdgeConfigSchemaResource, newEdgeConfigTokenResource, newFirewallConfigResource, + newFirewallBypassResource, newLogDrainResource, newProjectDeploymentRetentionResource, newProjectDomainResource, diff --git a/vercel/resource_firewall_bypass.go b/vercel/resource_firewall_bypass.go new file mode 100644 index 00000000..fdad8949 --- /dev/null +++ b/vercel/resource_firewall_bypass.go @@ -0,0 +1,310 @@ +package vercel + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/vercel/terraform-provider-vercel/v2/client" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &firewallBypassResource{} + _ resource.ResourceWithConfigure = &firewallBypassResource{} + _ resource.ResourceWithImportState = &firewallBypassResource{} +) + +func newFirewallBypassResource() resource.Resource { + return &firewallBypassResource{} +} + +type firewallBypassResource struct { + client *client.Client +} + +func (r *firewallBypassResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_firewall_bypass" +} + +func (r *firewallBypassResource) 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", + fmt.Sprintf("Expected *client.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + r.client = client +} + +func (r *firewallBypassResource) Schema(_ context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: ` +Provides a Firewall Bypass Rule + +Firewall Bypass Rules configure sets of domains and ip address to prevent bypass Vercel's system mitigations for. The hosts used in a bypass rule must be a production domain assigned to the associated project. Requests that bypass system mitigations will incur usage.`, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The identifier for the firewall bypass rule.", + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "project_id": schema.StringAttribute{ + Description: "The ID of the Project to assign the bypass rule to ", + Required: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + "team_id": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "The ID of the team the Project exists under. Required when configuring a team resource if a default team has not been set in the provider.", + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplaceIfConfigured(), stringplanmodifier.UseStateForUnknown()}, + }, + "domain": schema.StringAttribute{ + Required: true, + Description: "The domain to configure the bypass rule for.", + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + "source_ip": schema.StringAttribute{ + Required: true, + Description: "The source IP address to configure the bypass rule for.", + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + }, + } +} + +type FirewallBypassRule struct { + ID types.String `tfsdk:"id"` + ProjectID types.String `tfsdk:"project_id"` + TeamID types.String `tfsdk:"team_id"` + Domain types.String `tfsdk:"domain"` + SourceIp types.String `tfsdk:"source_ip"` +} + +func responseToBypassRule(out client.FirewallBypass) FirewallBypassRule { + split := strings.Split(out.Id, "#") + domain := out.Domain + if out.IsProjectRule { + domain = "*" + } + return FirewallBypassRule{ + ID: types.StringValue(out.Id), + TeamID: types.StringValue(out.OwnerId), + ProjectID: types.StringValue(split[0]), + Domain: types.StringValue(domain), + SourceIp: types.StringValue(out.Ip), + } +} + +func (r *firewallBypassResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan FirewallBypassRule + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + out, err := r.client.CreateFirewallBypass( + ctx, + plan.TeamID.ValueString(), + plan.ProjectID.ValueString(), + client.FirewallBypassRule{ + Domain: plan.Domain.ValueString(), + SourceIp: plan.SourceIp.ValueString(), + }, + ) + if err != nil { + resp.Diagnostics.AddError( + "Error creating firewall bypass", + "Could not create Firewall Bypass, unexpected error: "+err.Error(), + ) + return + } + + result := responseToBypassRule(out) + tflog.Info(ctx, "created firewall bypass rule", map[string]interface{}{ + "team_id": plan.TeamID.ValueString(), + "project_id": result.ProjectID.ValueString(), + }) + + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +func (r *firewallBypassResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state FirewallBypassRule + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + out, err := r.client.GetFirewallBypass( + ctx, + state.TeamID.ValueString(), + state.ProjectID.ValueString(), + client.FirewallBypassRule{ + Domain: state.Domain.ValueString(), + SourceIp: state.SourceIp.ValueString(), + }, + ) + if client.NotFound(err) { + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error reading Firewall Bypass Rule", + fmt.Sprintf("Could not get Firewall Bypass %s %s, unexpected error: %s", + state.TeamID.ValueString(), + state.ID.ValueString(), + err, + ), + ) + return + } + + result := responseToBypassRule(out) + tflog.Info(ctx, "read firewall bypass rule", map[string]interface{}{ + "team_id": result.TeamID.ValueString(), + "project_id": result.ProjectID.ValueString(), + }) + + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Update does nothing. +func (r *firewallBypassResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan FirewallBypassRule + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +func (r *firewallBypassResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state FirewallBypassRule + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Disable on deletion + _, err := r.client.RemoveFirewallBypass(ctx, + state.TeamID.ValueString(), + state.ProjectID.ValueString(), + client.FirewallBypassRule{ + Domain: state.Domain.ValueString(), + SourceIp: state.SourceIp.ValueString(), + }, + ) + if client.NotFound(err) { + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error deleting Firewall Bypass Rule", + fmt.Sprintf( + "Could not delete Firewall Bypass Rule %s %s, unexpected error: %s", + state.TeamID.ValueString(), + state.ID.ValueString(), + err, + ), + ) + return + } + + tflog.Info(ctx, "deleted firewall bypass rule", map[string]interface{}{ + "team_id": state.TeamID.ValueString(), + "project_id": state.ProjectID.ValueString(), + }) +} + +func splitBypassID(id string) (string, string, string, string, bool) { + split := strings.SplitN(id, "/", 2) + if len(split) != 2 { + return "", "", "", "", false + } + teamId := split[0] + + idParts := strings.Split(split[1], "#") + switch len(idParts) { + case 2: + return teamId, idParts[0], "*", idParts[1], true + case 3: + return teamId, idParts[0], idParts[1], idParts[2], true + } + return "", "", "", "", false +} + +func (r *firewallBypassResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + teamID, projectID, domain, ip, ok := splitBypassID(req.ID) + if !ok { + resp.Diagnostics.AddError( + "Error importing Firewall Bypass Rule", + fmt.Sprintf("Invalid id '%s' specified. should be in format \"team_id/bypass_rule_id\"", req.ID), + ) + return + } + + out, err := r.client.GetFirewallBypass( + ctx, + teamID, + projectID, + client.FirewallBypassRule{ + Domain: domain, + SourceIp: ip, + }, + ) + if client.NotFound(err) { + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error reading Firewall Bypass", + fmt.Sprintf("Could not get Firewall Bypass %s %s, unexpected error: %s", + teamID, + projectID, + err, + ), + ) + return + } + + result := responseToBypassRule(out) + tflog.Info(ctx, "import firewall bypass", map[string]interface{}{ + "team_id": result.TeamID.ValueString(), + "project_id": result.ProjectID.ValueString(), + }) + + diags := resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} diff --git a/vercel/resource_firewall_bypass_test.go b/vercel/resource_firewall_bypass_test.go new file mode 100644 index 00000000..fcc3b5f3 --- /dev/null +++ b/vercel/resource_firewall_bypass_test.go @@ -0,0 +1,143 @@ +package vercel_test + +import ( + "fmt" + "strings" + "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 TestAcc_FirewallBypassResource(t *testing.T) { + name := acctest.RandString(16) + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccFirewallBypassConfigResource(name, teamIDConfig()), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("vercel_firewall_bypass.bypass_one", "domain", "test-acc-domain-"+name+".vercel.app"), + resource.TestCheckResourceAttr("vercel_firewall_bypass.bypass_some", "domain", "*"), + resource.TestCheckResourceAttr("vercel_firewall_bypass.bypass_one", "source_ip", "1.2.3.4"), + resource.TestCheckResourceAttr("vercel_firewall_bypass.bypass_some", "source_ip", "2.3.4.0/24"), + resource.TestCheckResourceAttrWith("vercel_firewall_bypass.bypass_one", "id", func(id string) error { + if !strings.HasSuffix(id, "#test-acc-domain-"+name+".vercel.app#1.2.3.4") { + return fmt.Errorf("expected id does not match got %s - expected %s", id, "test-acc-domain-"+name+".vercel.app#1.2.3.4") + } + return nil + }), + resource.TestCheckResourceAttrWith("vercel_firewall_bypass.bypass_some", "id", func(id string) error { + if !strings.HasSuffix(id, "#2.3.4.0/24") { + return fmt.Errorf("expected id does not match suffix got %s - expected %s", id, "#2.3.4.0/24") + } + return nil + }), + ), + }, + { + ImportState: true, + ResourceName: "vercel_firewall_bypass.bypass_one", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + rs, ok := s.RootModule().Resources["vercel_firewall_bypass.bypass_one"] + if !ok { + return "", fmt.Errorf("resource not found") + } + return fmt.Sprintf("%s/%s", rs.Primary.Attributes["team_id"], rs.Primary.ID), nil + }, + }, + { + ImportState: true, + ResourceName: "vercel_firewall_bypass.bypass_some", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + rs, ok := s.RootModule().Resources["vercel_firewall_bypass.bypass_some"] + if !ok { + return "", fmt.Errorf("resource not found") + } + return fmt.Sprintf("%s/%s", rs.Primary.Attributes["team_id"], rs.Primary.ID), nil + }, + }, + { + Config: testAccFirewallBypassConfigResourceUpdated(name, teamIDConfig()), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("vercel_firewall_bypass.bypass_one", "source_ip", "0.0.0.0/0"), + resource.TestCheckResourceAttrWith("vercel_firewall_bypass.bypass_one", "id", func(id string) error { + if !strings.HasSuffix(id, "#test-acc-domain-"+name+".vercel.app#0.0.0.0/0") { + return fmt.Errorf("expected id does not match got %s - expected %s", id, "test-acc-domain-"+name+".vercel.app#0.0.0.0/0") + } + return nil + }), + ), + }, + }, + }) +} + +func testAccFirewallBypassConfigResource(name, teamID string) string { + return fmt.Sprintf(` +resource "vercel_project" "bypass_project" { + name = "test-acc-%[1]s-enabled" + %[2]s + git_repository = { + type = "github" + repo = "%[3]s" + } +} + +resource "vercel_project_domain" "test" { + domain = "test-acc-domain-%[1]s.vercel.app" + project_id = vercel_project.bypass_project.id + %[2]s +} + +resource "vercel_firewall_bypass" "bypass_one" { + project_id = vercel_project.bypass_project.id + %[2]s + domain = vercel_project_domain.test.domain + source_ip = "1.2.3.4" + + depends_on = [vercel_project_domain.test] +} + +resource "vercel_firewall_bypass" "bypass_some" { + project_id = vercel_project.bypass_project.id + %[2]s + domain = "*" + source_ip = "2.3.4.0/24" + + depends_on = [vercel_project_domain.test] +} + +`, name, teamID, testGithubRepo()) +} + +func testAccFirewallBypassConfigResourceUpdated(name, teamID string) string { + return fmt.Sprintf(` +resource "vercel_project" "bypass_project" { + name = "test-acc-%[1]s-enabled" + %[2]s + git_repository = { + type = "github" + repo = "%[3]s" + } +} + +resource "vercel_project_domain" "test" { + domain = "test-acc-domain-%[1]s.vercel.app" + project_id = vercel_project.bypass_project.id + %[2]s +} + +resource "vercel_firewall_bypass" "bypass_one" { + project_id = vercel_project.bypass_project.id + %[2]s + domain = vercel_project_domain.test.domain + source_ip = "0.0.0.0/0" + + depends_on = [vercel_project_domain.test] +} + +`, name, teamID, testGithubRepo()) +}