diff --git a/bitbucket/helpers.go b/bitbucket/helpers.go new file mode 100644 index 0000000..1abd213 --- /dev/null +++ b/bitbucket/helpers.go @@ -0,0 +1,21 @@ +package bitbucket + +import ( + "fmt" +) + +func baseConfigForRepositoryBasedTests(projectKey string) string { + config := fmt.Sprintf(` + resource "bitbucketserver_project" "test" { + key = "%v" + name = "test-project-%v" + } + + resource "bitbucketserver_repository" "test" { + project = bitbucketserver_project.test.key + name = "repo" + } + `, projectKey, projectKey) + + return config +} diff --git a/bitbucket/provider.go b/bitbucket/provider.go index ff43920..1c3ac7a 100644 --- a/bitbucket/provider.go +++ b/bitbucket/provider.go @@ -57,6 +57,7 @@ func Provider() terraform.ResourceProvider { "bitbucketserver_plugin": resourcePlugin(), "bitbucketserver_plugin_config": resourcePluginConfig(), "bitbucketserver_project": resourceProject(), + "bitbucketserver_project_branch_permissions": resourceBranchPermissions(), "bitbucketserver_project_hook": resourceProjectHook(), "bitbucketserver_project_permissions_group": resourceProjectPermissionsGroup(), "bitbucketserver_project_permissions_user": resourceProjectPermissionsUser(), diff --git a/bitbucket/resource_project_branch_permissions.go b/bitbucket/resource_project_branch_permissions.go new file mode 100644 index 0000000..0964739 --- /dev/null +++ b/bitbucket/resource_project_branch_permissions.go @@ -0,0 +1,341 @@ +package bitbucket + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "strings" + + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/helper/validation" +) + +type BranchPermissionPayload struct { + Type string `json:"type,omitempty"` + Matcher MatcherStruct `json:"matcher,omitempty"` + Users []string `json:"users,omitempty"` + Groups []string `json:"groups,omitempty"` + AccessKeys []string `json:"accessKeys,omitempty"` +} + +type MatcherStruct struct { + Id string `json:"id,omitempty"` + DisplayId string `json:"displayId,omitempty"` + Type MatcherStructType `json:"type,omitempty"` + Active bool `json:"active,omitempty"` +} + +type MatcherStructType struct { + Id string `json:"id,omitempty"` + Name string `json:"name,omitempty"` +} + +type BranchPermissionResponse struct { + Id int `json:"id"` + Scope struct { + ResourceID int `json:"resourceId"` + Type string `json:"type"` + } `json:"scope"` + Type string `json:"type"` + Users []struct { + Name string `json:"name"` + EmailAddress string `json:"emailAddress"` + ID int `json:"id"` + DisplayName string `json:"displayName"` + Active bool `json:"active"` + Slug string `json:"slug"` + Type string `json:"type"` + } `json:"users"` + Groups []string `json:"groups"` + AccessKeys []struct { + Key struct { + ID int `json:"id"` + Text string `json:"text"` + Label string `json:"label"` + } `json:"key"` + } `json:"accessKeys"` +} + +type AllRepositoryBranchPermissionsResponse struct { + Size int `json:"size"` + Limit int `json:"limit"` + IsLastPage bool `json:"isLastPage"` + Values []BranchPermissionResponse `json:"values"` +} + +func resourceBranchPermissions() *schema.Resource { + return &schema.Resource{ + Create: resourceBranchPermissionsCreate, + Read: resourceBranchPermissionsRead, + Update: resourceBranchPermissionsCreate, + Delete: resourceBranchPermissionsDelete, + Importer: &schema.ResourceImporter{ + State: schema.ImportStatePassthrough, + }, + + Schema: map[string]*schema.Schema{ + "project": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "repository": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "ref_pattern": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "type": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringInSlice([]string{"pull-request-only", "fast-forward-only", "no-deletes", "read-only"}, false), + }, + "exception_users": { + Type: schema.TypeList, + Elem: &schema.Schema{Type: schema.TypeString}, + Optional: true, + }, + "exception_groups": { + Type: schema.TypeList, + Elem: &schema.Schema{Type: schema.TypeString}, + Optional: true, + }, + "exception_access_keys": { + Type: schema.TypeList, + Elem: &schema.Schema{Type: schema.TypeString}, + Optional: true, + }, + "permission_id": { + Type: schema.TypeInt, + Computed: true, + }, + }, + } +} + +func newBranchPermissionPayloadFromResource(d *schema.ResourceData) *BranchPermissionPayload { + branchPermissionPayload := &BranchPermissionPayload{ + Type: d.Get("type").(string), + } + + for _, item := range d.Get("exception_users").([]interface{}) { + branchPermissionPayload.Users = append(branchPermissionPayload.Users, item.(string)) + } + + for _, item := range d.Get("exception_groups").([]interface{}) { + branchPermissionPayload.Groups = append(branchPermissionPayload.Groups, item.(string)) + } + + for _, item := range d.Get("exception_access_keys").([]interface{}) { + branchPermissionPayload.AccessKeys = append(branchPermissionPayload.AccessKeys, item.(string)) + } + + matcherConfig := &MatcherStruct{ + Id: d.Get("ref_pattern").(string), + DisplayId: d.Get("ref_pattern").(string), + Type: MatcherStructType{ + Id: "PATTERN", + Name: "Pattern", + }, + Active: true, + } + + branchPermissionPayload.Matcher = *matcherConfig + + return branchPermissionPayload +} + +func resourceBranchPermissionsCreate(d *schema.ResourceData, m interface{}) error { + client := m.(*BitbucketServerProvider).BitbucketClient + + project := d.Get("project").(string) + repository := d.Get("repository").(string) + branchPermission := newBranchPermissionPayloadFromResource(d) + + request, err := json.Marshal(branchPermission) + + if err != nil { + return err + } + + res, err := client.Post(fmt.Sprintf("/rest/branch-permissions/2.0/projects/%s/repos/%s/restrictions", + project, + repository, + ), bytes.NewBuffer(request)) + + if err != nil { + return err + } + + var branchPermissionResponse BranchPermissionResponse + + body, err := ioutil.ReadAll(res.Body) + + if err != nil { + return err + } + + err = json.Unmarshal(body, &branchPermissionResponse) + + if err != nil { + return err + } + + _ = d.Set("permission_id", branchPermissionResponse.Id) + + d.SetId(fmt.Sprintf("%s|%s|%s|%s", + d.Get("project").(string), + d.Get("repository").(string), + d.Get("ref_pattern").(string), + d.Get("type").(string)), + ) + return resourceBranchPermissionsRead(d, m) +} + +func resourceBranchPermissionsRead(d *schema.ResourceData, m interface{}) error { + id := d.Id() + if id != "" { + parts := strings.Split(id, "|") + if len(parts) == 4 { + _ = d.Set("project", parts[0]) + _ = d.Set("repository", parts[1]) + _ = d.Set("ref_pattern", parts[2]) + _ = d.Set("type", parts[3]) + } else { + return fmt.Errorf("incorrect ID format, should match `project|repository|ref_pattern|type`") + } + } + + branchPermissionId := d.Get("permission_id") + + var err error + + if branchPermissionId == nil { + err = getBranchPermissionFromList(d, m) + } else { + err = getBranchPermissionById(d, m) + } + + if err != nil { + return err + } + + return nil +} + +func getBranchPermissionById(d *schema.ResourceData, m interface{}) error { + project := d.Get("project").(string) + repository := d.Get("repository").(string) + id := d.Get("permission_id").(int) + + client := m.(*BitbucketServerProvider).BitbucketClient + + resp, err := client.Get(fmt.Sprintf("/rest/branch-permissions/2.0/projects/%s/repos/%s/restrictions/%d", + project, + repository, + id, + )) + + if err != nil { + return err + } + + var branchPermissionResponse BranchPermissionResponse + + decoder := json.NewDecoder(resp.Body) + + err = decoder.Decode(&branchPermissionResponse) + + if err != nil { + return err + } + + _ = d.Set("permission_id", branchPermissionResponse.Id) + _ = d.Set("type", branchPermissionResponse.Type) + _ = d.Set("exception_groups", branchPermissionResponse.Groups) + + // Convert slice of structs back to slice object for branchPermissionResponse.Users + exceptionUsers := make([]string, 0, len(branchPermissionResponse.Users)) + for _, item := range branchPermissionResponse.Users { + exceptionUsers = append(exceptionUsers, item.Name) + } + _ = d.Set("exception_users", exceptionUsers) + + // Convert slice of structs back to slice object for branchPermissionResponse.Users + exceptionAccessKeys := make([]int, 0, len(branchPermissionResponse.AccessKeys)) + for _, item := range branchPermissionResponse.AccessKeys { + exceptionAccessKeys = append(exceptionAccessKeys, item.Key.ID) + } + _ = d.Set("exception_access_keys", exceptionAccessKeys) + + return nil +} + +func getBranchPermissionFromList(d *schema.ResourceData, m interface{}) error { + project := d.Get("project").(string) + repository := d.Get("repository").(string) + restrictionType := d.Get("type").(string) + + client := m.(*BitbucketServerProvider).BitbucketClient + + resp, err := client.Get(fmt.Sprintf("/rest/branch-permissions/2.0/projects/%s/repos/%s/restrictions", + project, + repository, + )) + + if err != nil { + return err + } + + var allRepositoryBranchPermissionsResponse AllRepositoryBranchPermissionsResponse + + decoder := json.NewDecoder(resp.Body) + + err = decoder.Decode(&allRepositoryBranchPermissionsResponse) + + if err != nil { + return err + } + + for _, item := range allRepositoryBranchPermissionsResponse.Values { + if strings.ToLower(strings.Replace(item.Type, "_", "-", -1)) == restrictionType { + _ = d.Set("permission_id", item.Id) + _ = d.Set("type", item.Type) + _ = d.Set("exception_groups", item.Groups) + + // Convert slice of structs back to slice object for exception_users + exceptionUsers := make([]string, 0, len(item.Users)) + for _, item := range item.Users { + exceptionUsers = append(exceptionUsers, item.Name) + } + _ = d.Set("exception_users", exceptionUsers) + + // Convert slice of structs back to slice object for exception_access_keys + exceptionAccessKeys := make([]int, 0, len(item.AccessKeys)) + for _, item := range item.AccessKeys { + exceptionAccessKeys = append(exceptionAccessKeys, item.Key.ID) + } + _ = d.Set("exception_access_keys", exceptionAccessKeys) + + return nil + } + } + + return fmt.Errorf("incorrect ID format, should match `project|repository|ref_pattern`") +} + +func resourceBranchPermissionsDelete(d *schema.ResourceData, m interface{}) error { + client := m.(*BitbucketServerProvider).BitbucketClient + _, err := client.Delete(fmt.Sprintf("/rest/branch-permissions/2.0/projects/%s/repos/%s/restrictions/%d", + d.Get("project").(string), + d.Get("repository").(string), + d.Get("permission_id").(int))) + + return err +} diff --git a/bitbucket/resource_project_branch_permissions_test.go b/bitbucket/resource_project_branch_permissions_test.go new file mode 100644 index 0000000..51431ac --- /dev/null +++ b/bitbucket/resource_project_branch_permissions_test.go @@ -0,0 +1,102 @@ +package bitbucket + +import ( + "fmt" + "math/rand" + "testing" + "time" + + "github.com/hashicorp/terraform/helper/resource" +) + +func TestAccBitbucketResourceBranchPermission_requiredArgumentsOnly(t *testing.T) { + projectKey := fmt.Sprintf("TEST%v", rand.New(rand.NewSource(time.Now().UnixNano())).Int()) + + config := baseConfigForRepositoryBasedTests(projectKey) + ` + resource "bitbucketserver_project_branch_permissions" "test" { + project = bitbucketserver_project.test.key + repository = bitbucketserver_repository.test.slug + ref_pattern = "refs/heads/master" + type = "pull-request-only" + }` + + resourceName := "bitbucketserver_project_branch_permissions.test" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "id", fmt.Sprintf("%v|repo|refs/heads/master|pull-request-only", projectKey)), + resource.TestCheckResourceAttr(resourceName, "project", projectKey), + resource.TestCheckResourceAttr(resourceName, "repository", "repo"), + resource.TestCheckResourceAttr(resourceName, "ref_pattern", "refs/heads/master"), + resource.TestCheckResourceAttr(resourceName, "type", "pull-request-only"), + resource.TestCheckResourceAttrSet(resourceName, "permission_id"), + ), + }, + }, + }) +} + +func TestAccBitbucketResourceBranchPermission_allArguments(t *testing.T) { + projectKey := fmt.Sprintf("TEST%v", rand.New(rand.NewSource(time.Now().UnixNano())).Int()) + + config := baseConfigForRepositoryBasedTests(projectKey) + ` + resource "bitbucketserver_group" "test" { + name = bitbucketserver_project.test.name + } + + resource "bitbucketserver_group" "test_2" { + name = format("%s-%s", bitbucketserver_project.test.name, "2") + } + + resource "bitbucketserver_project_branch_permissions" "test" { + project = bitbucketserver_project.test.key + repository = bitbucketserver_repository.test.slug + ref_pattern = "refs/heads/master" + type = "pull-request-only" + exception_users = ["admin"] + exception_groups = [bitbucketserver_group.test.name, bitbucketserver_group.test_2.name] + } + + resource "bitbucketserver_project_branch_permissions" "test_2" { + project = bitbucketserver_project.test.key + repository = bitbucketserver_repository.test.slug + ref_pattern = "refs/heads/master" + type = "no-deletes" + depends_on = [bitbucketserver_project_branch_permissions.test] + } + ` + + resourceName := "bitbucketserver_project_branch_permissions.test" + resourceName2 := "bitbucketserver_project_branch_permissions.test_2" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "id", fmt.Sprintf("%v|repo|refs/heads/master|pull-request-only", projectKey)), + resource.TestCheckResourceAttr(resourceName, "project", projectKey), + resource.TestCheckResourceAttr(resourceName, "repository", "repo"), + resource.TestCheckResourceAttr(resourceName, "ref_pattern", "refs/heads/master"), + resource.TestCheckResourceAttr(resourceName, "type", "pull-request-only"), + resource.TestCheckResourceAttrSet(resourceName, "permission_id"), + resource.TestCheckResourceAttr(resourceName, "exception_users.#", "1"), + resource.TestCheckResourceAttr(resourceName, "exception_users.0", "admin"), + resource.TestCheckResourceAttr(resourceName, "exception_groups.#", "2"), + resource.TestCheckResourceAttr(resourceName, "exception_groups.0", fmt.Sprintf("test-project-%s", projectKey)), + resource.TestCheckResourceAttr(resourceName, "exception_groups.1", fmt.Sprintf("test-project-%s-2", projectKey)), + + resource.TestCheckResourceAttr(resourceName2, "type", "no-deletes"), + resource.TestCheckResourceAttrSet(resourceName2, "permission_id"), + ), + }, + }, + }) +} diff --git a/docs/resources/bitbucketserver_project_branch_permissions.md b/docs/resources/bitbucketserver_project_branch_permissions.md new file mode 100644 index 0000000..6076d27 --- /dev/null +++ b/docs/resources/bitbucketserver_project_branch_permissions.md @@ -0,0 +1,33 @@ +# Resource: bitbucketserver_project_branch_permissions + +Provides the ability to apply project ref restrictions to enforce branch permissions. A restriction means preventing writes on the specified branch(es) by all except a set of users and/or groups, or preventing specific operations such as branch deletion. + +## Example Usage + +```hcl +resource "bitbucketserver_project_branch_permissions" "pr_only" { + project = "MYPROJ" + repository = "repo" + ref_pattern = "refs/heads/master" + type = "pull-request-only" +} + +resource "bitbucketserver_project_branch_permissions" "no_deletes" { + project = "MYPROJ" + repository = "repo" + ref_pattern = "heads/**/master" + type = "no-deletes" + exception_users = ["admin"] + exception_groups = ["group_1", "group_2"] +} +``` + +## Argument Reference + +* `project` - Required. Project Key that contains target repository. +* `repository` - Required. Repository slug of target repository. +* `ref_pattern` - Required. A wildcard pattern that may match multiple branches. You must specify a valid [Branch Permission Pattern](https://confluence.atlassian.com/bitbucketserver/branch-permission-patterns-776639814.html). +* `type` - Required. Type of the restriction. Must be one of `pull-request-only`, `fast-forward-only`, `no-deletes`, `read-only`. +* `exception_users` - Optional. List of usernames to whom restrictions do not apply. +* `exception_groups` - Optional. List of group names to which restrictions do not apply. +* `exception_access_keys` - Optional. List of access keys IDs to which restrictions do not apply.