From d3c523905db22d4efbc8d594de785208de19a76d Mon Sep 17 00:00:00 2001 From: Benoit Perigaud <8754100+b-per@users.noreply.github.com> Date: Wed, 24 Apr 2024 17:55:24 +0200 Subject: [PATCH 1/7] Fix linting errors --- .../databricks_credential_acceptance_test.go | 25 ++++++---- ...t_variable_job_override_acceptance_test.go | 4 +- pkg/sdkv2/resources/helpers_test.go | 7 --- pkg/sdkv2/resources/job_acceptance_test.go | 12 ++--- .../resources/user_groups_acceptance_test.go | 48 +++++++++++++++---- 5 files changed, 61 insertions(+), 35 deletions(-) diff --git a/pkg/sdkv2/data_sources/databricks_credential_acceptance_test.go b/pkg/sdkv2/data_sources/databricks_credential_acceptance_test.go index 9fe61530..5eb33223 100644 --- a/pkg/sdkv2/data_sources/databricks_credential_acceptance_test.go +++ b/pkg/sdkv2/data_sources/databricks_credential_acceptance_test.go @@ -11,17 +11,24 @@ import ( func TestAccDbtCloudDatabricksCredentialDataSource(t *testing.T) { randomProjectName := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - config := databricks_credential(randomProjectName, "moo", "baa", "maa", 64) + config := databricks_credential(randomProjectName) - var check resource.TestCheckFunc - - check = resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttrSet("data.dbtcloud_databricks_credential.test", "credential_id"), + check := resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet( + "data.dbtcloud_databricks_credential.test", + "credential_id", + ), resource.TestCheckResourceAttrSet("data.dbtcloud_databricks_credential.test", "project_id"), resource.TestCheckResourceAttrSet("data.dbtcloud_databricks_credential.test", "adapter_id"), - resource.TestCheckResourceAttrSet("data.dbtcloud_databricks_credential.test", "target_name"), + resource.TestCheckResourceAttrSet( + "data.dbtcloud_databricks_credential.test", + "target_name", + ), resource.TestCheckResourceAttrSet("data.dbtcloud_databricks_credential.test", "schema"), - resource.TestCheckResourceAttrSet("data.dbtcloud_databricks_credential.test", "num_threads"), + resource.TestCheckResourceAttrSet( + "data.dbtcloud_databricks_credential.test", + "num_threads", + ), resource.TestCheckResourceAttrSet("data.dbtcloud_databricks_credential.test", "catalog"), ) @@ -36,7 +43,9 @@ func TestAccDbtCloudDatabricksCredentialDataSource(t *testing.T) { }) } -func databricks_credential(projectName string, defaultSchema string, username string, password string, numThreads int) string { +func databricks_credential( + projectName string, +) string { return fmt.Sprintf(` resource "dbtcloud_project" "test_credential_project" { name = "%s" diff --git a/pkg/sdkv2/resources/environment_variable_job_override_acceptance_test.go b/pkg/sdkv2/resources/environment_variable_job_override_acceptance_test.go index cc8b5e10..3f602ff5 100644 --- a/pkg/sdkv2/resources/environment_variable_job_override_acceptance_test.go +++ b/pkg/sdkv2/resources/environment_variable_job_override_acceptance_test.go @@ -58,7 +58,7 @@ func TestAccDbtCloudEnvironmentVariableJobOverrideResource(t *testing.T) { resource.TestCheckResourceAttr( "dbtcloud_environment_variable_job_override.test_env_var_job_override", "raw_value", - fmt.Sprintf("%s", environmentVariableJobOverrideValue), + environmentVariableJobOverrideValue, ), resource.TestCheckResourceAttrSet( "dbtcloud_environment_variable_job_override.test_env_var_job_override", @@ -88,7 +88,7 @@ func TestAccDbtCloudEnvironmentVariableJobOverrideResource(t *testing.T) { resource.TestCheckResourceAttr( "dbtcloud_environment_variable_job_override.test_env_var_job_override", "raw_value", - fmt.Sprintf("%s", environmentVariableJobOverrideValueNew), + environmentVariableJobOverrideValueNew, ), resource.TestCheckResourceAttrSet( "dbtcloud_environment_variable_job_override.test_env_var_job_override", diff --git a/pkg/sdkv2/resources/helpers_test.go b/pkg/sdkv2/resources/helpers_test.go index c4ffa37a..a68190c5 100644 --- a/pkg/sdkv2/resources/helpers_test.go +++ b/pkg/sdkv2/resources/helpers_test.go @@ -12,13 +12,6 @@ const ( DBT_CLOUD_VERSION = "1.6.0-latest" ) -func providers() map[string]*schema.Provider { - p := provider.SDKProvider("test")() - return map[string]*schema.Provider{ - "dbtcloud": p, - } -} - var testAccProviders map[string]*schema.Provider var testAccProvider *schema.Provider diff --git a/pkg/sdkv2/resources/job_acceptance_test.go b/pkg/sdkv2/resources/job_acceptance_test.go index 772c3c1f..b0561ac0 100644 --- a/pkg/sdkv2/resources/job_acceptance_test.go +++ b/pkg/sdkv2/resources/job_acceptance_test.go @@ -227,14 +227,10 @@ func TestAccDbtCloudJobResourceTriggers(t *testing.T) { }, // IMPORT { - ResourceName: "dbtcloud_job.test_job", - ImportState: true, - ImportStateVerify: true, - // we don't check triggers.on_merge as it is currently not enforced - ImportStateVerifyIgnore: []string{ - // "triggers.%", - // "triggers.on_merge", - }, + ResourceName: "dbtcloud_job.test_job", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{}, }, }, }) diff --git a/pkg/sdkv2/resources/user_groups_acceptance_test.go b/pkg/sdkv2/resources/user_groups_acceptance_test.go index 9e2e8d91..c82c6613 100644 --- a/pkg/sdkv2/resources/user_groups_acceptance_test.go +++ b/pkg/sdkv2/resources/user_groups_acceptance_test.go @@ -31,19 +31,39 @@ func TestAccDbtCloudUserGroupsResource(t *testing.T) { { Config: testAccDbtCloudUserGroupsResourceAddRole(userID, GroupName, groupIDs), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("dbtcloud_user_groups.test_user_groups", "user_id", strconv.Itoa(userID)), - resource.TestCheckResourceAttrSet("dbtcloud_user_groups.test_user_groups", "group_ids.0"), - resource.TestCheckResourceAttrSet("dbtcloud_user_groups.test_user_groups", "group_ids.3"), + resource.TestCheckResourceAttr( + "dbtcloud_user_groups.test_user_groups", + "user_id", + strconv.Itoa(userID), + ), + resource.TestCheckResourceAttrSet( + "dbtcloud_user_groups.test_user_groups", + "group_ids.0", + ), + resource.TestCheckResourceAttrSet( + "dbtcloud_user_groups.test_user_groups", + "group_ids.3", + ), ), }, // MODIFY { Config: testAccDbtCloudUserGroupsResourceRemoveRole(userID, GroupName, groupIDs), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("dbtcloud_user_groups.test_user_groups", "user_id", strconv.Itoa(userID)), - resource.TestCheckResourceAttrSet("dbtcloud_user_groups.test_user_groups", "group_ids.0"), + resource.TestCheckResourceAttr( + "dbtcloud_user_groups.test_user_groups", + "user_id", + strconv.Itoa(userID), + ), + resource.TestCheckResourceAttrSet( + "dbtcloud_user_groups.test_user_groups", + "group_ids.0", + ), // we should only have 3 groups now that we check that there is no item at index 3 (starts at 0) - resource.TestCheckNoResourceAttr("dbtcloud_user_groups.test_user_groups", "group_ids.3"), + resource.TestCheckNoResourceAttr( + "dbtcloud_user_groups.test_user_groups", + "group_ids.3", + ), ), }, // IMPORT @@ -57,7 +77,11 @@ func TestAccDbtCloudUserGroupsResource(t *testing.T) { }) } -func testAccDbtCloudUserGroupsResourceAddRole(userID int, GroupName string, GroupIDs string) string { +func testAccDbtCloudUserGroupsResourceAddRole( + userID int, + groupName string, + groupIDs string, +) string { return fmt.Sprintf(` resource "dbtcloud_group" "test_group" { name = "%s" @@ -76,10 +100,14 @@ resource "dbtcloud_user_groups" "test_user_groups" { user_id = %d group_ids = local.new_groups } -`, GroupName, GroupIDs, userID) +`, groupName, groupIDs, userID) } -func testAccDbtCloudUserGroupsResourceRemoveRole(userID int, GroupName string, GroupIDs string) string { +func testAccDbtCloudUserGroupsResourceRemoveRole( + userID int, + groupName string, + groupIDs string, +) string { return fmt.Sprintf(` resource "dbtcloud_group" "test_group" { name = "%s" @@ -93,5 +121,5 @@ resource "dbtcloud_user_groups" "test_user_groups" { user_id = %d group_ids = %s } -`, GroupName, userID, GroupIDs) +`, groupName, userID, groupIDs) } From 628ba4f4384e796246d3714d989a5d493131012f Mon Sep 17 00:00:00 2001 From: Benoit Perigaud <8754100+b-per@users.noreply.github.com> Date: Wed, 24 Apr 2024 17:55:43 +0200 Subject: [PATCH 2/7] Fix typo in example --- docs/resources/job.md | 12 ++++++------ examples/resources/dbtcloud_job/resource.tf | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/resources/job.md b/docs/resources/job.md index 3a33fb83..b3019c51 100644 --- a/docs/resources/job.md +++ b/docs/resources/job.md @@ -36,8 +36,8 @@ resource "dbtcloud_job" "daily_job" { run_generate_sources = true target_name = "default" triggers = { - "github_webhook" : false, - "git_provider_webhook" : false, + "github_webhook" : false + "git_provider_webhook" : false "schedule" : true "on_merge" : false } @@ -62,8 +62,8 @@ resource "dbtcloud_job" "ci_job" { project_id = dbtcloud_project.dbt_project.id run_generate_sources = false triggers = { - "github_webhook" : true, - "git_provider_webhook" : true, + "github_webhook" : true + "git_provider_webhook" : true "schedule" : false "on_merge" : false } @@ -86,8 +86,8 @@ resource "dbtcloud_job" "downstream_job" { project_id = dbtcloud_project.dbt_project2.id run_generate_sources = true triggers = { - "github_webhook" : false, - "git_provider_webhook" : false, + "github_webhook" : false + "git_provider_webhook" : false "schedule" : false "on_merge" : false } diff --git a/examples/resources/dbtcloud_job/resource.tf b/examples/resources/dbtcloud_job/resource.tf index f8662331..2f0bac13 100644 --- a/examples/resources/dbtcloud_job/resource.tf +++ b/examples/resources/dbtcloud_job/resource.tf @@ -13,8 +13,8 @@ resource "dbtcloud_job" "daily_job" { run_generate_sources = true target_name = "default" triggers = { - "github_webhook" : false, - "git_provider_webhook" : false, + "github_webhook" : false + "git_provider_webhook" : false "schedule" : true "on_merge" : false } @@ -39,8 +39,8 @@ resource "dbtcloud_job" "ci_job" { project_id = dbtcloud_project.dbt_project.id run_generate_sources = false triggers = { - "github_webhook" : true, - "git_provider_webhook" : true, + "github_webhook" : true + "git_provider_webhook" : true "schedule" : false "on_merge" : false } @@ -63,8 +63,8 @@ resource "dbtcloud_job" "downstream_job" { project_id = dbtcloud_project.dbt_project2.id run_generate_sources = true triggers = { - "github_webhook" : false, - "git_provider_webhook" : false, + "github_webhook" : false + "git_provider_webhook" : false "schedule" : false "on_merge" : false } From 466d9e1012fd0a9e83144754abeae89b003d8b2d Mon Sep 17 00:00:00 2001 From: Benoit Perigaud <8754100+b-per@users.noreply.github.com> Date: Wed, 24 Apr 2024 17:56:26 +0200 Subject: [PATCH 3/7] Add methods to get objects by name, going through API pages --- pkg/dbt_cloud/paginate.go | 107 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 pkg/dbt_cloud/paginate.go diff --git a/pkg/dbt_cloud/paginate.go b/pkg/dbt_cloud/paginate.go new file mode 100644 index 00000000..88a37b72 --- /dev/null +++ b/pkg/dbt_cloud/paginate.go @@ -0,0 +1,107 @@ +package dbt_cloud + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/samber/lo" + "github.com/sirupsen/logrus" +) + +type Response struct { + Data []any `json:"data"` + Extra Extra `json:"extra"` +} + +type Extra struct { + Pagination Pagination `json:"pagination"` +} + +type Pagination struct { + Count int `json:"count"` + TotalCount int `json:"total_count"` +} + +var log = logrus.New() + +func (c *Client) GetEndpoint(url string) ([]byte, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + log.Fatalf("Error creating a new request: %v", err) + } + + resp, err := c.doRequest(req) + if err != nil { + log.Fatalf("Error fetching URL %v: %v", url, err) + } + + return resp, err +} + +func (c *Client) GetData(url string) []any { + + // get the first page + jsonPayload, err := c.GetEndpoint(url) + if err != nil { + log.Fatal(err) + } + + var response Response + + err = json.Unmarshal(jsonPayload, &response) + if err != nil { + log.Fatal(err) + } + + allResponses := response.Data + + count := response.Extra.Pagination.Count + for count < response.Extra.Pagination.TotalCount { + // get the next page + + var newURL string + lastPartURL, _ := lo.Last(strings.Split(url, "/")) + if strings.Contains(lastPartURL, "?") { + newURL = fmt.Sprintf("%s&offset=%d", url, count) + } else { + newURL = fmt.Sprintf("%s?offset=%d", url, count) + } + + jsonPayload, err := c.GetEndpoint(newURL) + if err != nil { + log.Fatal(err) + } + var response Response + + err = json.Unmarshal(jsonPayload, &response) + if err != nil { + log.Fatal(err) + } + + if response.Extra.Pagination.Count == 0 { + // Unlucky! one object might have been deleted since the first call + // if we don't stop here we will loop forever! + break + } else { + count += response.Extra.Pagination.Count + } + allResponses = append(allResponses, response.Data...) + } + + return allResponses +} + +func (c *Client) GetAllGroupIDsByName(groupName string) []int { + url := fmt.Sprintf("%s/v3/accounts/%d/groups/", c.HostURL, c.AccountID) + + allGroupsRaw := c.GetData(url) + + return lo.FilterMap(allGroupsRaw, func(group any, _ int) (int, bool) { + if group.(map[string]any)["name"].(string) == groupName { + return int(group.(map[string]any)["id"].(float64)), true + } + return 0, false + }) +} From e4ac2abe0a0d92e6a69121afeb4ea1f6227345b8 Mon Sep 17 00:00:00 2001 From: Benoit Perigaud <8754100+b-per@users.noreply.github.com> Date: Wed, 24 Apr 2024 17:57:25 +0200 Subject: [PATCH 4/7] Add `dbtcloud_group_partial_permissions` resource --- docs/resources/group_partial_permissions.md | 96 +++++ .../resource.tf | 30 ++ .../group_partial_permissions/model.go | 53 +++ .../group_partial_permissions/resource.go | 392 ++++++++++++++++++ .../resource_acceptance_test.go | 381 +++++++++++++++++ .../group_partial_permissions/schema.go | 96 +++++ pkg/helper/helper.go | 15 + pkg/provider/framework_provider.go | 2 + 8 files changed, 1065 insertions(+) create mode 100644 docs/resources/group_partial_permissions.md create mode 100644 examples/resources/dbtcloud_group_partial_permissions/resource.tf create mode 100644 pkg/framework/objects/group_partial_permissions/model.go create mode 100644 pkg/framework/objects/group_partial_permissions/resource.go create mode 100644 pkg/framework/objects/group_partial_permissions/resource_acceptance_test.go create mode 100644 pkg/framework/objects/group_partial_permissions/schema.go diff --git a/docs/resources/group_partial_permissions.md b/docs/resources/group_partial_permissions.md new file mode 100644 index 00000000..631e1f77 --- /dev/null +++ b/docs/resources/group_partial_permissions.md @@ -0,0 +1,96 @@ +--- +page_title: "dbtcloud_group_partial_permissions Resource - dbtcloud" +subcategory: "" +description: |- + Provide a partial set of permissions for a group. This is different from dbt_cloud_group as it allows to have multiple resources updating the same dbt Cloud group and is useful for companies managing a single dbt Cloud Account configuration from different Terraform projects/workspaces. + If a company uses only one Terraform project/workspace to manage all their dbt Cloud Account config, it is recommended to use dbt_cloud_group instead of dbt_cloud_group_partial_permissions. + ~> This is currently an experimental resource and any feedback is welcome in the GitHub repository. + The current behavior of the resource is the following: + when using dbt_cloud_group_partial_permissions, don't use dbt_cloud_group for the same group in any other project/workspace. Otherwise, the behavior is undefined and partial permissions might be removed.when defining a new dbt_cloud_group_partial_permissions + + if the group doesn't exist with the given name, it will be createdif a group exists with the given name, permissions will be added in the dbt Cloud group if they are not present yetin a given Terraform project/workspace, avoid having different ~~dbtcloudgrouppartialpermissions` for the same group name to prevent sync issues. Add all the permissions in the same resource.all resources for the same group name need to have the same values for assign_by_default and sso_mapping_groups. Those fields are not considered "partial". (Please raise feedback in GitHub if you think that sso_mapping_groups should be "partial" as well)when a resource is updated, the dbt Cloud group will be updated accordingly, removing and adding permissionswhen the resource is deleted/destroyed, if the resulting permission sets is empty, the group will be deleted ; otherwise, the group will be updated, removing the permissions from the deleted resource +--- + +# dbtcloud_group_partial_permissions (Resource) + + +Provide a partial set of permissions for a group. This is different from `dbt_cloud_group` as it allows to have multiple resources updating the same dbt Cloud group and is useful for companies managing a single dbt Cloud Account configuration from different Terraform projects/workspaces. + +If a company uses only one Terraform project/workspace to manage all their dbt Cloud Account config, it is recommended to use `dbt_cloud_group` instead of `dbt_cloud_group_partial_permissions`. + +~> This is currently an experimental resource and any feedback is welcome in the GitHub repository. + +The current behavior of the resource is the following: + +- when using `dbt_cloud_group_partial_permissions`, don't use `dbt_cloud_group` for the same group in any other project/workspace. Otherwise, the behavior is undefined and partial permissions might be removed. +- when defining a new `dbt_cloud_group_partial_permissions` + - if the group doesn't exist with the given `name`, it will be created + - if a group exists with the given `name`, permissions will be added in the dbt Cloud group if they are not present yet +- in a given Terraform project/workspace, avoid having different ~~dbt_cloud_group_partial_permissions` for the same group name to prevent sync issues. Add all the permissions in the same resource. +- all resources for the same group name need to have the same values for `assign_by_default` and `sso_mapping_groups`. Those fields are not considered "partial". (Please raise feedback in GitHub if you think that `sso_mapping_groups` should be "partial" as well) +- when a resource is updated, the dbt Cloud group will be updated accordingly, removing and adding permissions +- when the resource is deleted/destroyed, if the resulting permission sets is empty, the group will be deleted ; otherwise, the group will be updated, removing the permissions from the deleted resource + +## Example Usage + +```terraform +// we add some permissions to the group "TF Group 1" (existing or not) to a new project +resource "dbtcloud_group_partial_permissions" "tf_group_1" { + name = "TF Group 1" + group_permissions = [ + { + permission_set = "developer" + project_id = dbtcloud_project.dbt_project.id + all_projects = false + }, + { + permission_set = "git_admin" + project_id = dbtcloud_project.dbt_project.id + all_projects = false + } + ] +} + +// we add Admin permissions to the group "TF Group 2" (existing or not) to a new project +// it is possible to add more permissions to the same group name in other Terraform projects/workspaces, using another `dbtcloud_group_partial_permissions` resource +resource "dbtcloud_group_partial_permissions" "tf_group_2" { + name = "TF Group 2" + sso_mapping_groups = ["group2"] + group_permissions = [ + { + permission_set = "admin" + project_id = dbtcloud_project.dbt_project.id + all_projects = false + } + ] +} +``` + + +## Schema + +### Required + +- `name` (String) The name of the group. This is used to identify an existing group + +### Optional + +- `assign_by_default` (Boolean) Whether the group will be assigned by default to users. The value needs to be the same for all partial permissions for the same group. +- `group_permissions` (Attributes Set) Partial permissions for the group. Those permissions will be added/removed when config is added/removed. (see [below for nested schema](#nestedatt--group_permissions)) +- `sso_mapping_groups` (Set of String) Mapping groups from the IdP. At the moment the complete list needs to be provided in each partial permission for the same group. + +### Read-Only + +- `id` (Number) The ID of the group + + +### Nested Schema for `group_permissions` + +Required: + +- `all_projects` (Boolean) Whether access should be provided for all projects or not. +- `permission_set` (String) Set of permissions to apply. The permissions allowed are the same as the ones for the `dbtcloud_group` resource. + +Optional: + +- `project_id` (Number) Project ID to apply this permission to for this group. diff --git a/examples/resources/dbtcloud_group_partial_permissions/resource.tf b/examples/resources/dbtcloud_group_partial_permissions/resource.tf new file mode 100644 index 00000000..993aad00 --- /dev/null +++ b/examples/resources/dbtcloud_group_partial_permissions/resource.tf @@ -0,0 +1,30 @@ +// we add some permissions to the group "TF Group 1" (existing or not) to a new project +resource "dbtcloud_group_partial_permissions" "tf_group_1" { + name = "TF Group 1" + group_permissions = [ + { + permission_set = "developer" + project_id = dbtcloud_project.dbt_project.id + all_projects = false + }, + { + permission_set = "git_admin" + project_id = dbtcloud_project.dbt_project.id + all_projects = false + } + ] +} + +// we add Admin permissions to the group "TF Group 2" (existing or not) to a new project +// it is possible to add more permissions to the same group name in other Terraform projects/workspaces, using another `dbtcloud_group_partial_permissions` resource +resource "dbtcloud_group_partial_permissions" "tf_group_2" { + name = "TF Group 2" + sso_mapping_groups = ["group2"] + group_permissions = [ + { + permission_set = "admin" + project_id = dbtcloud_project.dbt_project.id + all_projects = false + } + ] +} \ No newline at end of file diff --git a/pkg/framework/objects/group_partial_permissions/model.go b/pkg/framework/objects/group_partial_permissions/model.go new file mode 100644 index 00000000..becb78fd --- /dev/null +++ b/pkg/framework/objects/group_partial_permissions/model.go @@ -0,0 +1,53 @@ +package group_partial_permissions + +import ( + "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/dbt_cloud" + "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/helper" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type GroupPartialPermissionsResourceModel struct { + ID types.Int64 `tfsdk:"id"` + Name types.String `tfsdk:"name"` + AssignByDefault types.Bool `tfsdk:"assign_by_default"` + SSOMappingGroups types.Set `tfsdk:"sso_mapping_groups"` + GroupPermissions []GroupPermission `tfsdk:"group_permissions"` +} + +type GroupPermission struct { + PermissionSet types.String `tfsdk:"permission_set"` + ProjectID types.Int64 `tfsdk:"project_id"` + AllProjects types.Bool `tfsdk:"all_projects"` +} + +func convertGroupPermissionModelToData( + requiredAllPermissions []GroupPermission, + groupID int, + accountID int, +) []dbt_cloud.GroupPermission { + allPermissionsRequest := make([]dbt_cloud.GroupPermission, len(requiredAllPermissions)) + for i, permission := range requiredAllPermissions { + allPermissionsRequest[i] = dbt_cloud.GroupPermission{ + GroupID: groupID, + AccountID: accountID, + Set: permission.PermissionSet.ValueString(), + ProjectID: int(permission.ProjectID.ValueInt64()), + AllProjects: permission.AllProjects.ValueBool(), + } + } + return allPermissionsRequest +} + +func convertGroupPermissionDataToModel( + allPermissions []dbt_cloud.GroupPermission, +) []GroupPermission { + allPermissionsModel := make([]GroupPermission, len(allPermissions)) + for i, permission := range allPermissions { + allPermissionsModel[i] = GroupPermission{ + PermissionSet: types.StringValue(permission.Set), + ProjectID: helper.SetIntToInt64OrNull(permission.ProjectID), + AllProjects: types.BoolValue(permission.AllProjects), + } + } + return allPermissionsModel +} diff --git a/pkg/framework/objects/group_partial_permissions/resource.go b/pkg/framework/objects/group_partial_permissions/resource.go new file mode 100644 index 00000000..f226d9d5 --- /dev/null +++ b/pkg/framework/objects/group_partial_permissions/resource.go @@ -0,0 +1,392 @@ +package group_partial_permissions + +import ( + "context" + "strings" + + "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/dbt_cloud" + "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/helper" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/samber/lo" +) + +var ( + _ resource.Resource = &groupPartialPermissionsResource{} + _ resource.ResourceWithConfigure = &groupPartialPermissionsResource{} +) + +func GroupPartialPermissionsResource() resource.Resource { + return &groupPartialPermissionsResource{} +} + +type groupPartialPermissionsResource struct { + client *dbt_cloud.Client +} + +func (r *groupPartialPermissionsResource) Metadata( + _ context.Context, + req resource.MetadataRequest, + resp *resource.MetadataResponse, +) { + resp.TypeName = req.ProviderTypeName + "_group_partial_permissions" +} + +func (r *groupPartialPermissionsResource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { + var state GroupPartialPermissionsResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + + groupIDFromState := state.ID.ValueInt64() + group, err := r.client.GetGroup(int(groupIDFromState)) + if err != nil { + if strings.HasPrefix(err.Error(), "resource-not-found") { + resp.Diagnostics.AddWarning( + "Resource not found", + "The notification resource was not found and has been removed from the state.", + ) + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError( + "Issue getting Group", + "Error: "+err.Error(), + ) + return + } + + if group.Name != state.Name.ValueString() { + groupIDs := r.client.GetAllGroupIDsByName(state.Name.ValueString()) + if len(groupIDs) > 1 { + resp.Diagnostics.AddError( + "More than one group with the same name", + "Error: With the `group_partial_permissions` resource, the group name needs to be unique in dbt Cloud", + ) + return + } + if len(groupIDs) == 0 { + resp.State.RemoveResource(ctx) + resp.Diagnostics.AddWarning( + "Group not found", + "Error: No group was found with the name mentioned", + ) + return + } + + groupID := groupIDs[0] + group, err = r.client.GetGroup(groupID) + if err != nil { + resp.Diagnostics.AddError( + "Issue getting Group", + "Error: "+err.Error(), + ) + return + } + } + + state.ID = types.Int64Value(int64(*group.ID)) + state.Name = types.StringValue(group.Name) + state.AssignByDefault = types.BoolValue(group.AssignByDefault) + state.SSOMappingGroups, _ = types.SetValueFrom( + context.Background(), + types.StringType, + group.SSOMappingGroups, + ) + + var remotePermissions []GroupPermission + for _, permission := range group.Permissions { + perm := GroupPermission{ + PermissionSet: types.StringValue(permission.Set), + ProjectID: helper.SetIntToInt64OrNull(permission.ProjectID), + AllProjects: types.BoolValue(permission.AllProjects), + } + remotePermissions = append(remotePermissions, perm) + } + + relevantPermissions := lo.Intersect(state.GroupPermissions, remotePermissions) + state.GroupPermissions = relevantPermissions + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) + +} + +func (r *groupPartialPermissionsResource) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +) { + var plan GroupPartialPermissionsResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + name := plan.Name.ValueString() + assignByDefault := plan.AssignByDefault.ValueBool() + var ssoMappingGroups []string + diags := plan.SSOMappingGroups.ElementsAs(context.Background(), &ssoMappingGroups, false) + if diags.HasError() { + return + } + + // check if it exists and if there is only one with the given name + groupIDs := r.client.GetAllGroupIDsByName(name) + if len(groupIDs) > 1 { + resp.Diagnostics.AddError( + "More than one group with the same name", + "Error: With the `group_partial_permissions` resource, the group name needs to be unique in dbt Cloud", + ) + return + } + + if len(groupIDs) == 1 { + // if it exists get the ID and: + // A. update the fields that are not partial, e.g. assignByDefault, ssoMappingGroups + // B. add the permission needed for the partial field + groupID := groupIDs[0] + + group, err := r.client.GetGroup(groupID) + if err != nil { + resp.Diagnostics.AddError( + "Issue getting Group", + "Error: "+err.Error(), + ) + return + } + + // A. update the "global" fields if required + sameAssignByDefault := group.AssignByDefault == assignByDefault + sameSSOGroups := lo.Every(group.SSOMappingGroups, ssoMappingGroups) && + lo.Every(ssoMappingGroups, group.SSOMappingGroups) + + if !sameAssignByDefault || !sameSSOGroups { + group.AssignByDefault = assignByDefault + group.SSOMappingGroups = ssoMappingGroups + + r.client.UpdateGroup(groupID, *group) + } + + // B. add the permissions that are missing + configPermissions := plan.GroupPermissions + remotePermissions := convertGroupPermissionDataToModel(group.Permissions) + + missingPermissions := lo.Without(configPermissions, remotePermissions...) + + if len(missingPermissions) == 0 { + plan.ID = types.Int64Value(int64(groupID)) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) + return + } + + allPermissions := append(remotePermissions, missingPermissions...) + allPermissionsRequest := convertGroupPermissionModelToData( + allPermissions, + groupID, + group.AccountID, + ) + + _, err = r.client.UpdateGroupPermissions(*group.ID, allPermissionsRequest) + if err != nil { + resp.Diagnostics.AddError( + "Unable to assign permissions to the group", + "Error: "+err.Error(), + ) + return + } + plan.ID = types.Int64Value(int64(groupID)) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) + + } else { + // if the group with the name given doesn't exist , create it + // TODO: Move this to the group resources in the Framework + + group, err := r.client.CreateGroup(name, assignByDefault, ssoMappingGroups) + if err != nil { + resp.Diagnostics.AddError( + "Unable to create group", + "Error: "+err.Error(), + ) + return + } + + groupPermissions := convertGroupPermissionModelToData(plan.GroupPermissions, *group.ID, group.AccountID) + + _, err = r.client.UpdateGroupPermissions(*group.ID, groupPermissions) + if err != nil { + resp.Diagnostics.AddError( + "Unable to assign permissions to the group", + "Error: "+err.Error(), + ) + return + } + plan.ID = types.Int64Value(int64(*group.ID)) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) + } + +} + +func (r *groupPartialPermissionsResource) Delete( + ctx context.Context, + req resource.DeleteRequest, + resp *resource.DeleteResponse, +) { + var state GroupPartialPermissionsResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + groupID := int(state.ID.ValueInt64()) + group, err := r.client.GetGroup(groupID) + if err != nil { + resp.Diagnostics.AddError( + "Issue getting Group", + "Error: "+err.Error(), + ) + } + + remotePermissions := convertGroupPermissionDataToModel(group.Permissions) + requiredAllPermissions := lo.Without(remotePermissions, state.GroupPermissions...) + + if len(requiredAllPermissions) > 0 { + // if there are permissions left, we delete the ones from the resource + // but we keep the remote group + allPermissionsRequest := convertGroupPermissionModelToData( + requiredAllPermissions, + groupID, + group.AccountID, + ) + + _, err = r.client.UpdateGroupPermissions(groupID, allPermissionsRequest) + if err != nil { + resp.Diagnostics.AddError( + "Unable to assign permissions to the group", + "Error: "+err.Error(), + ) + return + } + + } else { + // otherwise, we delete the group entirely if there is no permission + group.State = dbt_cloud.STATE_DELETED + _, err = r.client.UpdateGroup(groupID, *group) + if err != nil { + resp.Diagnostics.AddError( + "Unable to delete group", + "Error: "+err.Error(), + ) + return + } + } + +} + +func (r *groupPartialPermissionsResource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + var plan, state GroupPartialPermissionsResourceModel + + // Read plan and state values into the models + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + + groupID := int(state.ID.ValueInt64()) + group, err := r.client.GetGroup(groupID) + if err != nil { + resp.Diagnostics.AddError( + "Issue getting Group", + "Error: "+err.Error(), + ) + } + + planAssignByDefault := plan.AssignByDefault.ValueBool() + var planSsoMappingGroups []string + diags := plan.SSOMappingGroups.ElementsAs(context.Background(), &planSsoMappingGroups, false) + if diags.HasError() { + return + } + + stateAssignByDefault := state.AssignByDefault.ValueBool() + var stateSsoMappingGroups []string + diags = state.SSOMappingGroups.ElementsAs(context.Background(), &stateSsoMappingGroups, false) + if diags.HasError() { + return + } + + // A. we compare the global objects and update them if needed + sameAssignByDefault := planAssignByDefault == stateAssignByDefault + sameSSOGroups := lo.Every(planSsoMappingGroups, stateSsoMappingGroups) && + lo.Every(stateSsoMappingGroups, planSsoMappingGroups) + + if !sameAssignByDefault || !sameSSOGroups { + + group.AssignByDefault = planAssignByDefault + group.SSOMappingGroups = planSsoMappingGroups + + _, err = r.client.UpdateGroup(groupID, *group) + if err != nil { + resp.Diagnostics.AddError( + "Unable to update group", + "Error: "+err.Error(), + ) + } + state.AssignByDefault = plan.AssignByDefault + state.SSOMappingGroups = plan.SSOMappingGroups + + } + + // B. we compare the permissions and update them if needed + + statePermissions := state.GroupPermissions + planPermissions := plan.GroupPermissions + + remotePermissions := convertGroupPermissionDataToModel(group.Permissions) + + deletedPermissions := lo.Without(statePermissions, planPermissions...) + newPermissions := lo.Without(planPermissions, statePermissions...) + + requiredAllPermissions := lo.Without( + lo.Union(remotePermissions, newPermissions), + deletedPermissions...) + + if len(deletedPermissions) > 0 || len(newPermissions) > 0 { + + allPermissionsRequest := convertGroupPermissionModelToData( + requiredAllPermissions, + groupID, + group.AccountID, + ) + + _, err = r.client.UpdateGroupPermissions(groupID, allPermissionsRequest) + if err != nil { + resp.Diagnostics.AddError( + "Unable to assign permissions to the group", + "Error: "+err.Error(), + ) + return + + } + state.GroupPermissions = plan.GroupPermissions + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *groupPartialPermissionsResource) Configure( + _ context.Context, + req resource.ConfigureRequest, + _ *resource.ConfigureResponse, +) { + if req.ProviderData == nil { + return + } + + r.client = req.ProviderData.(*dbt_cloud.Client) +} diff --git a/pkg/framework/objects/group_partial_permissions/resource_acceptance_test.go b/pkg/framework/objects/group_partial_permissions/resource_acceptance_test.go new file mode 100644 index 00000000..60c4cc0d --- /dev/null +++ b/pkg/framework/objects/group_partial_permissions/resource_acceptance_test.go @@ -0,0 +1,381 @@ +package group_partial_permissions_test + +import ( + "fmt" + "os" + "strings" + "testing" + + "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/framework/acctest_helper" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccDbtCloudGroupPartialPermissionsResource(t *testing.T) { + + projectName := strings.ToUpper(acctest.RandStringFromCharSet(10, acctest.CharSetAlpha)) + groupName := strings.ToUpper(acctest.RandStringFromCharSet(10, acctest.CharSetAlpha)) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: acctest_helper.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // 1. CREATE + { + Config: testAccDbtCloudGroupPartialPermissionsResourceCreate( + projectName, + groupName, + ), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "dbtcloud_group_partial_permissions.test_group_partial_permission", + "name", + groupName, + ), + resource.TestCheckResourceAttr( + "dbtcloud_group_partial_permissions.test_group_partial_permission", + "sso_mapping_groups.#", + "2", + ), + resource.TestCheckResourceAttr( + "dbtcloud_group_partial_permissions.test_group_partial_permission", + "group_permissions.#", + "2", + ), + resource.TestCheckTypeSetElemNestedAttrs( + "dbtcloud_group_partial_permissions.test_group_partial_permission", + "group_permissions.*", + map[string]string{ + "permission_set": "developer", + }, + ), + ), + }, + // 2. ADD ANOTHER RESOURCE + { + Config: testAccDbtCloudGroupPartialPermissionsResourceAddResource( + projectName, + groupName, + ), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "dbtcloud_group_partial_permissions.test_group_partial_permission", + "name", + groupName, + ), + resource.TestCheckResourceAttr( + "dbtcloud_group_partial_permissions.test_group_partial_permission", + "sso_mapping_groups.#", + "2", + ), + resource.TestCheckResourceAttr( + "dbtcloud_group_partial_permissions.test_group_partial_permission", + "group_permissions.#", + "2", + ), + resource.TestCheckTypeSetElemNestedAttrs( + "dbtcloud_group_partial_permissions.test_group_partial_permission", + "group_permissions.*", + map[string]string{ + "permission_set": "developer", + }, + ), + resource.TestCheckResourceAttr( + "dbtcloud_group_partial_permissions.test_group_partial_permission2", + "name", + groupName, + ), + resource.TestCheckResourceAttr( + "dbtcloud_group_partial_permissions.test_group_partial_permission2", + "sso_mapping_groups.#", + "2", + ), + resource.TestCheckResourceAttr( + "dbtcloud_group_partial_permissions.test_group_partial_permission2", + "group_permissions.#", + "1", + ), + resource.TestCheckResourceAttr( + "dbtcloud_group_partial_permissions.test_group_partial_permission2", + "group_permissions.0.permission_set", + "admin", + ), + ), + }, + // 3. MODIFYING EXISTING RESOURCE + { + Config: testAccDbtCloudGroupPartialPermissionsResourceModifyExisting( + projectName, + groupName, + ), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "dbtcloud_group_partial_permissions.test_group_partial_permission", + "name", + groupName, + ), + resource.TestCheckResourceAttr( + "dbtcloud_group_partial_permissions.test_group_partial_permission", + "group_permissions.#", + "1", + ), + resource.TestCheckTypeSetElemNestedAttrs( + "dbtcloud_group_partial_permissions.test_group_partial_permission", + "group_permissions.*", + map[string]string{ + "permission_set": "developer", + }, + ), + resource.TestCheckResourceAttr( + "dbtcloud_group_partial_permissions.test_group_partial_permission2", + "name", + groupName, + ), + resource.TestCheckResourceAttr( + "dbtcloud_group_partial_permissions.test_group_partial_permission2", + "sso_mapping_groups.#", + "2", + ), + resource.TestCheckResourceAttr( + "dbtcloud_group_partial_permissions.test_group_partial_permission2", + "group_permissions.#", + "2", + ), + resource.TestCheckTypeSetElemNestedAttrs( + "dbtcloud_group_partial_permissions.test_group_partial_permission2", + "group_permissions.*", + map[string]string{ + "permission_set": "job_viewer", + }, + ), + ), + }, + // 4. RENAME RESOURCE + { + Config: testAccDbtCloudGroupPartialPermissionsResourceModifyExisting( + projectName, + groupName+"2", + ), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "dbtcloud_group_partial_permissions.test_group_partial_permission", + "name", + groupName+"2", + ), + resource.TestCheckResourceAttr( + "dbtcloud_group_partial_permissions.test_group_partial_permission", + "group_permissions.#", + "1", + ), + resource.TestCheckResourceAttr( + "dbtcloud_group_partial_permissions.test_group_partial_permission", + "group_permissions.0.permission_set", + "developer", + ), + resource.TestCheckResourceAttr( + "dbtcloud_group_partial_permissions.test_group_partial_permission2", + "name", + groupName+"2", + ), + resource.TestCheckResourceAttr( + "dbtcloud_group_partial_permissions.test_group_partial_permission2", + "sso_mapping_groups.#", + "2", + ), + resource.TestCheckResourceAttr( + "dbtcloud_group_partial_permissions.test_group_partial_permission2", + "group_permissions.#", + "2", + ), + resource.TestCheckTypeSetElemNestedAttrs( + "dbtcloud_group_partial_permissions.test_group_partial_permission2", + "group_permissions.*", + map[string]string{ + "permission_set": "admin", + }, + ), + ), + }, + // 5. REMOVE ONE RESOURCE + { + Config: testAccDbtCloudGroupPartialPermissionsResourceRemoveResource( + projectName, + groupName+"2", + ), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "dbtcloud_group_partial_permissions.test_group_partial_permission2", + "name", + groupName+"2", + ), + resource.TestCheckResourceAttr( + "dbtcloud_group_partial_permissions.test_group_partial_permission2", + "sso_mapping_groups.#", + "2", + ), + resource.TestCheckResourceAttr( + "dbtcloud_group_partial_permissions.test_group_partial_permission2", + "group_permissions.#", + "1", + ), + resource.TestCheckResourceAttr( + "dbtcloud_group_partial_permissions.test_group_partial_permission2", + "group_permissions.0.permission_set", + "admin", + ), + ), + }, + }, + }) +} + +func testAccDbtCloudGroupPartialPermissionsResourceCreate(projectName, groupName string) string { + + groupPartialPermissionConfig := fmt.Sprintf(` + +resource "dbtcloud_project" "test_group_project" { + name = "%s" +} + +resource "dbtcloud_group_partial_permissions" "test_group_partial_permission" { + name = "%s" + sso_mapping_groups = ["group1", "group2"] + group_permissions = [ + { + permission_set = "developer" + project_id = dbtcloud_project.test_group_project.id + all_projects = false + }, + { + permission_set = "analyst" + project_id = dbtcloud_project.test_group_project.id + all_projects = false + } + ] +} +`, projectName, groupName) + return groupPartialPermissionConfig +} + +func testAccDbtCloudGroupPartialPermissionsResourceAddResource( + projectName, groupName string, +) string { + + groupPartialPermissionConfig := fmt.Sprintf(` + +resource "dbtcloud_project" "test_group_project" { + name = "%s" +} + +resource "dbtcloud_group_partial_permissions" "test_group_partial_permission" { + name = "%s" + sso_mapping_groups = ["group1", "group2"] + group_permissions = [ + { + permission_set = "developer" + project_id = dbtcloud_project.test_group_project.id + all_projects = false + }, + { + permission_set = "analyst" + project_id = dbtcloud_project.test_group_project.id + all_projects = false + } + ] +} + +resource "dbtcloud_group_partial_permissions" "test_group_partial_permission2" { + name = "%s" + sso_mapping_groups = ["group1", "group2"] + group_permissions = [ + { + permission_set = "admin" + project_id = dbtcloud_project.test_group_project.id + all_projects = false + }, + ] + depends_on = [ + dbtcloud_group_partial_permissions.test_group_partial_permission + ] +} +`, projectName, groupName, groupName) + return groupPartialPermissionConfig +} + +func testAccDbtCloudGroupPartialPermissionsResourceModifyExisting( + projectName, groupName string, +) string { + + groupPartialPermissionConfig := fmt.Sprintf(` + +resource "dbtcloud_project" "test_group_project" { + name = "%s" +} + +resource "dbtcloud_group_partial_permissions" "test_group_partial_permission" { + name = "%s" + sso_mapping_groups = ["group1", "group2"] + group_permissions = [ + { + permission_set = "developer" + project_id = dbtcloud_project.test_group_project.id + all_projects = false + } + ] +} + +resource "dbtcloud_group_partial_permissions" "test_group_partial_permission2" { + name = "%s" + sso_mapping_groups = ["group1", "group2"] + group_permissions = [ + { + permission_set = "admin" + project_id = dbtcloud_project.test_group_project.id + all_projects = false + }, + { + permission_set = "job_viewer" + all_projects = true + }, + ] + depends_on = [ + dbtcloud_group_partial_permissions.test_group_partial_permission + ] +} +`, projectName, groupName, groupName) + return groupPartialPermissionConfig +} + +func testAccDbtCloudGroupPartialPermissionsResourceRemoveResource( + projectName, groupName string, +) string { + + groupPartialPermissionConfig := fmt.Sprintf(` + +resource "dbtcloud_project" "test_group_project" { + name = "%s" +} + +resource "dbtcloud_group_partial_permissions" "test_group_partial_permission2" { + name = "%s" + sso_mapping_groups = ["group1", "group2"] + group_permissions = [ + { + permission_set = "admin" + project_id = dbtcloud_project.test_group_project.id + all_projects = false + }, + ] +} +`, projectName, groupName) + return groupPartialPermissionConfig +} + +func testAccPreCheck(t *testing.T) { + if v := os.Getenv("DBT_CLOUD_ACCOUNT_ID"); v == "" { + t.Fatal("DBT_CLOUD_ACCOUNT_ID must be set for acceptance tests") + } + if v := os.Getenv("DBT_CLOUD_TOKEN"); v == "" { + t.Fatal("DBT_CLOUD_TOKEN must be set for acceptance tests") + } +} diff --git a/pkg/framework/objects/group_partial_permissions/schema.go b/pkg/framework/objects/group_partial_permissions/schema.go new file mode 100644 index 00000000..cbd47cba --- /dev/null +++ b/pkg/framework/objects/group_partial_permissions/schema.go @@ -0,0 +1,96 @@ +package group_partial_permissions + +import ( + "context" + + "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/dbt_cloud" + "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/helper" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "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/int64planmodifier" + "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" +) + +func (r *groupPartialPermissionsResource) Schema( + _ context.Context, + _ resource.SchemaRequest, + resp *resource.SchemaResponse, +) { + resp.Schema = schema.Schema{ + Description: helper.DocString( + `Provide a partial set of permissions for a group. This is different from ~~~dbt_cloud_group~~~ as it allows to have multiple resources updating the same dbt Cloud group and is useful for companies managing a single dbt Cloud Account configuration from different Terraform projects/workspaces. + + If a company uses only one Terraform project/workspace to manage all their dbt Cloud Account config, it is recommended to use ~~~dbt_cloud_group~~~ instead of ~~~dbt_cloud_group_partial_permissions~~~. + + ~> This is currently an experimental resource and any feedback is welcome in the GitHub repository. + + The current behavior of the resource is the following: + + - when using ~~~dbt_cloud_group_partial_permissions~~~, don't use ~~~dbt_cloud_group~~~ for the same group in any other project/workspace. Otherwise, the behavior is undefined and partial permissions might be removed. + - when defining a new ~~~dbt_cloud_group_partial_permissions~~~ + - if the group doesn't exist with the given ~~~name~~~, it will be created + - if a group exists with the given ~~~name~~~, permissions will be added in the dbt Cloud group if they are not present yet + - in a given Terraform project/workspace, avoid having different ~~dbt_cloud_group_partial_permissions~~~ for the same group name to prevent sync issues. Add all the permissions in the same resource. + - all resources for the same group name need to have the same values for ~~~assign_by_default~~~ and ~~~sso_mapping_groups~~~. Those fields are not considered "partial". (Please raise feedback in GitHub if you think that ~~~sso_mapping_groups~~~ should be "partial" as well) + - when a resource is updated, the dbt Cloud group will be updated accordingly, removing and adding permissions + - when the resource is deleted/destroyed, if the resulting permission sets is empty, the group will be deleted ; otherwise, the group will be updated, removing the permissions from the deleted resource + `, + ), + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{ + Computed: true, + Description: "The ID of the group", + // this is used so that we don't show that ID is going to change + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Required: true, + Description: "The name of the group. This is used to identify an existing group", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "assign_by_default": schema.BoolAttribute{ + Computed: true, + Optional: true, + Default: booldefault.StaticBool(false), + Description: "Whether the group will be assigned by default to users. The value needs to be the same for all partial permissions for the same group.", + }, + "sso_mapping_groups": schema.SetAttribute{ + Optional: true, + ElementType: types.StringType, + Description: "Mapping groups from the IdP. At the moment the complete list needs to be provided in each partial permission for the same group.", + }, + "group_permissions": schema.SetNestedAttribute{ + Description: "Partial permissions for the group. Those permissions will be added/removed when config is added/removed.", + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "permission_set": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf(dbt_cloud.PermissionSets...), + }, + Description: "Set of permissions to apply. The permissions allowed are the same as the ones for the `dbtcloud_group` resource.", + }, + "project_id": schema.Int64Attribute{ + Optional: true, + Description: "Project ID to apply this permission to for this group.", + }, + "all_projects": schema.BoolAttribute{ + Required: true, + Description: "Whether access should be provided for all projects or not.", + }, + }, + }, + Optional: true, + }, + }, + } +} diff --git a/pkg/helper/helper.go b/pkg/helper/helper.go index 4f225105..2724078b 100644 --- a/pkg/helper/helper.go +++ b/pkg/helper/helper.go @@ -1,6 +1,9 @@ package helper import ( + "regexp" + "strings" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" "github.com/hashicorp/terraform-plugin-framework/resource/schema/setdefault" @@ -15,3 +18,15 @@ func EmptySetDefault(elemType attr.Type) defaults.Set { ), ) } + +func SetIntToInt64OrNull(value int) types.Int64 { + if value == 0 { + return types.Int64Null() + } + return types.Int64Value(int64(value)) +} + +func DocString(inp string) string { + newString := strings.ReplaceAll(inp, "~~~", "`") + return regexp.MustCompile(`(?m)^\t+`).ReplaceAllString(newString, "") +} diff --git a/pkg/provider/framework_provider.go b/pkg/provider/framework_provider.go index 04fa3cff..92437bec 100644 --- a/pkg/provider/framework_provider.go +++ b/pkg/provider/framework_provider.go @@ -6,6 +6,7 @@ import ( "strconv" "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/dbt_cloud" + "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/framework/objects/group_partial_permissions" "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/framework/objects/notification" "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/framework/objects/user" @@ -176,5 +177,6 @@ func (p *dbtCloudProvider) DataSources(_ context.Context) []func() datasource.Da func (p *dbtCloudProvider) Resources(_ context.Context) []func() resource.Resource { return []func() resource.Resource{ notification.NotificationResource, + group_partial_permissions.GroupPartialPermissionsResource, } } From fecc3d988c66a9fdbdacd9402102c26b0cb5aa49 Mon Sep 17 00:00:00 2001 From: Benoit Perigaud <8754100+b-per@users.noreply.github.com> Date: Wed, 24 Apr 2024 17:57:34 +0200 Subject: [PATCH 5/7] Update dependencies --- go.mod | 8 ++++++-- go.sum | 23 ++++++++++++++--------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index cf129190..4b963dfa 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/dbt-labs/terraform-provider-dbtcloud -go 1.21 +go 1.21.0 + +toolchain go1.21.4 require ( github.com/hashicorp/terraform-plugin-docs v0.16.0 @@ -12,6 +14,7 @@ require ( github.com/hashicorp/terraform-plugin-sdk/v2 v2.33.0 github.com/hashicorp/terraform-plugin-testing v1.7.0 github.com/samber/lo v1.39.0 + github.com/sirupsen/logrus v1.9.3 ) require ( @@ -59,7 +62,8 @@ require ( github.com/posener/complete v1.2.3 // indirect github.com/russross/blackfriday v1.6.0 // indirect github.com/shopspring/decimal v1.3.1 // indirect - github.com/spf13/cast v1.5.0 // indirect + github.com/spf13/cast v1.5.1 // indirect + github.com/stretchr/testify v1.8.4 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect diff --git a/go.sum b/go.sum index 16958753..14ca09b1 100644 --- a/go.sum +++ b/go.sum @@ -37,8 +37,8 @@ github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5Kwzbycv github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= -github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= -github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= +github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= +github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= @@ -130,8 +130,8 @@ github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gav github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -172,8 +172,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= -github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= -github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA= @@ -183,18 +183,22 @@ github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNX github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ= github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= -github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= +github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA= +github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= @@ -241,6 +245,7 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= From 144c84d7b133e7b45ed1544b7dd54acbc3356d8b Mon Sep 17 00:00:00 2001 From: Benoit Perigaud <8754100+b-per@users.noreply.github.com> Date: Wed, 24 Apr 2024 17:57:52 +0200 Subject: [PATCH 6/7] Update command to generate docs --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 5d5db36c..cfbfa57e 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ install: build mv ./$(BINARY) $(HOME)/.terraform.d/plugins/$(BINARY) doc: - go generate ./... && rm docs/resources/dbt_cloud_* && rm docs/data-sources/dbt_cloud_* && cp -r guides docs/ + go generate ./... && cp -r guides docs/ test: deps go test -mod=readonly -count=1 ./... From c95082b6618366c2e57e6c840b5f8c6fe4b9a94e Mon Sep 17 00:00:00 2001 From: Benoit Perigaud <8754100+b-per@users.noreply.github.com> Date: Wed, 24 Apr 2024 17:58:08 +0200 Subject: [PATCH 7/7] Changelog for 0.3.3 --- CHANGELOG.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f6d4190..921d2f79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,15 @@ All notable changes to this project will be documented in this file. -## [Unreleased](https://github.com/dbt-labs/terraform-provider-dbtcloud/compare/v0.3.1...HEAD) +## [Unreleased](https://github.com/dbt-labs/terraform-provider-dbtcloud/compare/v0.3.3...HEAD) -## [0.3.1](https://github.com/dbt-labs/terraform-provider-dbtcloud/compare/v0.3.0...v0.3.1) +## [0.3.3](https://github.com/dbt-labs/terraform-provider-dbtcloud/compare/v0.3.2...v0.3.3) + +## Changes + +- [#250](https://github.com/dbt-labs/terraform-provider-dbtcloud/issues/250) - [Experimental] Create a new resource called `dbtcloud_group_partial_permissions` to manage permissions of a single group from different resources which can be set across different Terraform projects/workspaces. The dbt Cloud API doesn't provide endpoints for adding/removing single permissions, so the logic in the provider is more complex than other resources. If the resource works as expected for the provider users we could create similar ones for "partial" notifications and "partial" license mappings. + +## [0.3.2](https://github.com/dbt-labs/terraform-provider-dbtcloud/compare/v0.3.0...v0.3.2) ## Changes