diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4bc6c3cd..8c3548ec 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -112,6 +112,7 @@ jobs: VERCEL_TERRAFORM_TESTING_BITBUCKET_REPO: "dglsparsons-test/test" VERCEL_TERRAFORM_TESTING_DOMAIN: "dgls.dev" VERCEL_TERRAFORM_TESTING_ADDITIONAL_USER: ${{ secrets.VERCEL_TERRAFORM_TESTING_ADDITIONAL_USER }} + VERCEL_TERRAFORM_TESTING_EXISTING_INTEGRATION: ${{ secrets.VERCEL_TERRAFORM_TESTING_EXISTING_INTEGRATION }} run: | go test ./... diff --git a/README.md b/README.md index 82e05b0d..69bedf54 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ The acceptance tests require a few environment variables to be set: * `VERCEL_TERRAFORM_TESTING_BITBUCKET_REPO` - a Bitbucket repository in the form 'project/repo' that can be used to trigger deployments * `VERCEL_TERRAFORM_TESTING_GITLAB_REPO` - a GitLab repository in the form 'project/repo' that can be used to trigger deployments * `VERCEL_TERRAFORM_TESTING_DOMAIN` - a Vercel testing domain that can be used for testing +* `VERCEL_TERRAFORM_TESTING_EXISTING_INTEGRATION` - a Vercel integration that can be used for testing ```sh $ task test diff --git a/client/integrations.go b/client/integrations.go new file mode 100644 index 00000000..89c9c03b --- /dev/null +++ b/client/integrations.go @@ -0,0 +1,86 @@ +package client + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +func (c *Client) GetIntegrationProjectAccess(ctx context.Context, integrationID, projectID, teamID string) (bool, error) { + url := fmt.Sprintf("%s/v1/integrations/configuration/%s/project/%s", c.baseURL, integrationID, projectID) + if c.teamID(teamID) != "" { + url = fmt.Sprintf("%s?teamId=%s", url, c.teamID(teamID)) + } + + tflog.Info(ctx, "getting integration project access", map[string]interface{}{ + "url": url, + }) + + type resp struct { + Allowed bool `json:"allowed"` + } + + var e resp + if err := c.doRequest(clientRequest{ + ctx: ctx, + method: "GET", + url: url, + body: "", + }, &e); err != nil { + return false, err + } + return e.Allowed, nil +} + +func (c *Client) GrantIntegrationProjectAccess(ctx context.Context, integrationID, projectID, teamID string) (bool, error) { + url := fmt.Sprintf("%s/v1/integrations/configuration/%s/project/%s", c.baseURL, integrationID, projectID) + if c.teamID(teamID) != "" { + url = fmt.Sprintf("%s?teamId=%s", url, c.teamID(teamID)) + } + + tflog.Info(ctx, "getting integration project access", map[string]interface{}{ + "url": url, + }) + + type resp struct { + Allowed bool `json:"allowed"` + } + + var e resp + if err := c.doRequest(clientRequest{ + ctx: ctx, + method: "POST", + url: url, + body: `{ "allowed": true }`, + }, &e); err != nil { + return false, err + } + return true, nil +} + +func (c *Client) RevokeIntegrationProjectAccess(ctx context.Context, integrationID, projectID, teamID string) (bool, error) { + url := fmt.Sprintf("%s/v1/integrations/configuration/%s/project/%s", c.baseURL, integrationID, projectID) + if c.teamID(teamID) != "" { + url = fmt.Sprintf("%s?teamId=%s", url, c.teamID(teamID)) + } + + tflog.Info(ctx, "getting integration project access", map[string]interface{}{ + "url": url, + }) + + type resp struct { + Allowed bool `json:"allowed"` + } + + var e resp + if err := c.doRequest(clientRequest{ + ctx: ctx, + method: "POST", + url: url, + body: `{ "allowed": false }`, + }, &e); err != nil { + return false, err + } + return false, nil +} diff --git a/docs/resources/integration_project_access.md b/docs/resources/integration_project_access.md new file mode 100644 index 00000000..53ea804a --- /dev/null +++ b/docs/resources/integration_project_access.md @@ -0,0 +1,25 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "vercel_integration_project_access Resource - terraform-provider-vercel" +subcategory: "" +description: |- + Provides Project access to an existing Integration. This requires the integration already exists and is already configured for Specific Project access. +--- + +# vercel_integration_project_access (Resource) + +Provides Project access to an existing Integration. This requires the integration already exists and is already configured for Specific Project access. + + + + +## Schema + +### Required + +- `integration_id` (String) The ID of the integration. +- `project_id` (String) The ID of the Vercel project. + +### Optional + +- `team_id` (String) The ID of the Vercel team.Required when configuring a team resource if a default team has not been set in the provider. diff --git a/vercel/provider.go b/vercel/provider.go index bbaec77e..eb0ef20f 100644 --- a/vercel/provider.go +++ b/vercel/provider.go @@ -63,6 +63,7 @@ func (p *vercelProvider) Resources(_ context.Context) []func() resource.Resource newEdgeConfigTokenResource, newFirewallConfigResource, newFirewallBypassResource, + newIntegrationProjectAccessResource, newLogDrainResource, newProjectDeploymentRetentionResource, newProjectDomainResource, diff --git a/vercel/provider_test.go b/vercel/provider_test.go index fb78a196..e4c97127 100644 --- a/vercel/provider_test.go +++ b/vercel/provider_test.go @@ -28,6 +28,7 @@ func testAccPreCheck(t *testing.T) { mustHaveEnv(t, "VERCEL_TERRAFORM_TESTING_BITBUCKET_REPO") mustHaveEnv(t, "VERCEL_TERRAFORM_TESTING_DOMAIN") mustHaveEnv(t, "VERCEL_TERRAFORM_TESTING_ADDITIONAL_USER") + mustHaveEnv(t, "VERCEL_TERRAFORM_TESTING_EXISTING_INTEGRATION") } var tc *client.Client @@ -70,3 +71,7 @@ func testDomain() string { func testAdditionalUser() string { return os.Getenv("VERCEL_TERRAFORM_TESTING_ADDITIONAL_USER") } + +func testExistingIntegration() string { + return os.Getenv("VERCEL_TERRAFORM_TESTING_EXISTING_INTEGRATION") +} diff --git a/vercel/resource_integration_project_access.go b/vercel/resource_integration_project_access.go new file mode 100644 index 00000000..d9a75a2c --- /dev/null +++ b/vercel/resource_integration_project_access.go @@ -0,0 +1,225 @@ +package vercel + +import ( + "context" + "fmt" + + "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" +) + +var ( + _ resource.Resource = &integrationProjectAccessResource{} + _ resource.ResourceWithConfigure = &integrationProjectAccessResource{} +) + +func newIntegrationProjectAccessResource() resource.Resource { + return &integrationProjectAccessResource{} +} + +type integrationProjectAccessResource struct { + client *client.Client +} + +func (r *integrationProjectAccessResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_integration_project_access" +} + +func (r *integrationProjectAccessResource) 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 *integrationProjectAccessResource) Schema(_ context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: `Provides Project access to an existing Integration. This requires the integration already exists and is already configured for Specific Project access. +`, + Attributes: map[string]schema.Attribute{ + "integration_id": schema.StringAttribute{ + Required: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + Description: "The ID of the integration.", + }, + "project_id": schema.StringAttribute{ + Required: true, + Description: "The ID of the Vercel project.", + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + "team_id": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "The ID of the Vercel team.Required when configuring a team resource if a default team has not been set in the provider.", + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplaceIfConfigured(), stringplanmodifier.UseStateForUnknown()}, + }, + }, + } +} + +type IntegrationProjectAccess struct { + TeamID types.String `tfsdk:"team_id"` + ProjectID types.String `tfsdk:"project_id"` + IntegrationID types.String `tfsdk:"integration_id"` +} + +func (r *integrationProjectAccessResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan IntegrationProjectAccess + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + _, err := r.client.GrantIntegrationProjectAccess(ctx, plan.IntegrationID.ValueString(), plan.ProjectID.ValueString(), plan.TeamID.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Error granting integration project access", + "Could not grant integration project access, unexpected error: "+err.Error(), + ) + return + } + + result := IntegrationProjectAccess{ + TeamID: plan.TeamID, + IntegrationID: plan.IntegrationID, + ProjectID: plan.ProjectID, + } + + tflog.Info(ctx, "granted integration project access", map[string]interface{}{ + "team_id": result.TeamID.ValueString(), + "integration_id": result.IntegrationID.ValueString(), + "project_id": result.ProjectID.ValueString(), + }) + + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +func (r *integrationProjectAccessResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state IntegrationProjectAccess + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + allowed, err := r.client.GetIntegrationProjectAccess(ctx, state.IntegrationID.ValueString(), state.ProjectID.ValueString(), state.TeamID.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Error granting integration project access", + "Could not grant integration project access, unexpected error: "+err.Error(), + ) + return + } + + result := IntegrationProjectAccess{ + TeamID: state.TeamID, + IntegrationID: state.IntegrationID, + ProjectID: state.ProjectID, + } + tflog.Info(ctx, "read integration project access", map[string]interface{}{ + "team_id": result.TeamID.ValueString(), + "integration_id": result.IntegrationID.ValueString(), + "project_id": result.ProjectID.ValueString(), + "allowed": allowed, + }) + + if allowed { + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } else { + resp.State.RemoveResource(ctx) + } +} + +func (r *integrationProjectAccessResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan IntegrationProjectAccess + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + allowed, err := r.client.GrantIntegrationProjectAccess(ctx, plan.IntegrationID.ValueString(), plan.ProjectID.ValueString(), plan.TeamID.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Error granting integration project access", + "Could not grant integration project access, unexpected error: "+err.Error(), + ) + return + } + + result := IntegrationProjectAccess{ + TeamID: plan.TeamID, + IntegrationID: plan.IntegrationID, + ProjectID: plan.ProjectID, + } + + tflog.Info(ctx, "granted integration project access", map[string]interface{}{ + "team_id": result.TeamID.ValueString(), + "integration_id": result.IntegrationID.ValueString(), + "project_id": result.ProjectID.ValueString(), + "allowed": allowed, + }) + + diags = resp.State.Set(ctx, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +func (r *integrationProjectAccessResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var plan IntegrationProjectAccess + diags := req.State.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + allowed, err := r.client.RevokeIntegrationProjectAccess(ctx, plan.IntegrationID.ValueString(), plan.ProjectID.ValueString(), plan.TeamID.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Error revoking integration project access", + "Could not revoke integration project access, unexpected error: "+err.Error(), + ) + return + } + + result := IntegrationProjectAccess{ + TeamID: plan.TeamID, + IntegrationID: plan.IntegrationID, + ProjectID: plan.ProjectID, + } + + tflog.Info(ctx, "revoked integration project access", map[string]interface{}{ + "team_id": result.TeamID.ValueString(), + "integration_id": result.IntegrationID.ValueString(), + "project_id": result.ProjectID.ValueString(), + "allowed": allowed, + }) +} diff --git a/vercel/resource_integration_project_access_test.go b/vercel/resource_integration_project_access_test.go new file mode 100644 index 00000000..70e76839 --- /dev/null +++ b/vercel/resource_integration_project_access_test.go @@ -0,0 +1,85 @@ +package vercel_test + +import ( + "context" + "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 testCheckIntegrationProjectAccessDestroyed(n, teamID string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("not found: %s", n) + } + + allowed, err := testClient().GetIntegrationProjectAccess(context.TODO(), rs.Primary.Attributes["integration_id"], rs.Primary.Attributes["project_id"], teamID) + if err != nil { + return err + } + if allowed { + return fmt.Errorf("expected project to not allow access to integration") + } + + return nil + } +} + +func testCheckIntegrationProjectAccessExists(n, teamID string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("not found: %s", n) + } + + allowed, err := testClient().GetIntegrationProjectAccess(context.TODO(), rs.Primary.Attributes["integration_id"], rs.Primary.Attributes["project_id"], teamID) + if err != nil { + return err + } + if !allowed { + return fmt.Errorf("expected project to allow access to integration") + } + + return nil + } +} + +func TestAcc_IntegrationProjectAccess(t *testing.T) { + name := acctest.RandString(16) + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: testCheckIntegrationProjectAccessDestroyed("vercel_integration_project_access.test_integration_access", testTeam()), + Steps: []resource.TestStep{ + { + Config: testAccIntegrationProjectAccess(name, teamIDConfig(), testExistingIntegration()), + Check: resource.ComposeAggregateTestCheckFunc( + testCheckIntegrationProjectAccessExists("vercel_integration_project_access.test_integration_access", testTeam()), + ), + }, + }, + }) +} + +func testAccIntegrationProjectAccess(name, team, integration string) string { + return fmt.Sprintf(` +data "vercel_endpoint_verification" "test" { + %[2]s +} + +resource "vercel_project" "test" { + name = "test-acc-%[1]s" + %[2]s +} + +resource "vercel_integration_project_access" "test_integration_access" { + integration_id = "%[3]s" + project_id = vercel_project.test.id + %[2]s +} +`, name, team, integration) +}