diff --git a/README.md b/README.md index d622e3d2a6..7733c917ab 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ | [databricks_mws_customer_managed_keys](docs/resources/mws_customer_managed_keys.md) | [databricks_mws_log_delivery](docs/resources/mws_log_delivery.md) | [databricks_mws_networks](docs/resources/mws_networks.md) +| [databricks_mws_permission_assignment](docs/resources/mws_permission_assignment.md) | [databricks_mws_private_access_settings](docs/resources/mws_private_access_settings.md) | [databricks_mws_storage_configurations](docs/resources/mws_storage_configurations.md) | [databricks_mws_vpc_endpoint](docs/resources/mws_vpc_endpoint.md) diff --git a/access/resource_permission_assignment.go b/access/resource_permission_assignment.go new file mode 100644 index 0000000000..84d3f0c351 --- /dev/null +++ b/access/resource_permission_assignment.go @@ -0,0 +1,115 @@ +package access + +import ( + "context" + "fmt" + "strconv" + + "github.com/databricks/terraform-provider-databricks/common" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func NewPermissionAssignmentAPI(ctx context.Context, m any) PermissionAssignmentAPI { + return PermissionAssignmentAPI{m.(*common.DatabricksClient), ctx} +} + +type PermissionAssignmentAPI struct { + client *common.DatabricksClient + context context.Context +} + +type Permissions struct { + Permissions []string `json:"permissions"` +} + +func (a PermissionAssignmentAPI) CreateOrUpdate(principalId int64, r Permissions) error { + path := fmt.Sprintf("/preview/permissionassignments/principals/%d", principalId) + return a.client.Put(a.context, path, r) +} + +func (a PermissionAssignmentAPI) Remove(principalId string) error { + path := fmt.Sprintf("/preview/permissionassignments/principals/%s", principalId) + return a.client.Delete(a.context, path, nil) +} + +type Principal struct { + DisplayName string `json:"display_name"` + PrincipalID int64 `json:"principal_id"` + ServicePrincipalName string `json:"service_principal_name,omitempty"` + UserName string `json:"user_name,omitempty"` + GroupName string `json:"group_name,omitempty"` +} + +type PermissionAssignment struct { + Permissions []string `json:"permissions"` + Principal Principal +} + +type PermissionAssignmentList struct { + PermissionAssignments []PermissionAssignment `json:"permission_assignments"` +} + +func (l PermissionAssignmentList) ForPrincipal(principalId int64) (res Permissions, err error) { + for _, v := range l.PermissionAssignments { + if v.Principal.PrincipalID != principalId { + continue + } + return Permissions{v.Permissions}, nil + } + return res, common.NotFound(fmt.Sprintf("%d not found", principalId)) +} + +func (a PermissionAssignmentAPI) List() (list PermissionAssignmentList, err error) { + err = a.client.Get(a.context, "/preview/permissionassignments", nil, &list) + return +} + +func mustInt64(s string) int64 { + n, err := strconv.ParseInt(s, 10, 0) + if err != nil { + panic(err) + } + return n +} + +// ResourcePermissionAssignment performs of users to a workspace +// from a workspace context, though it requires additional set +// data resource for "workspace account scim", whicl will be added later. +func ResourcePermissionAssignment() *schema.Resource { + type entity struct { + PrincipalId int64 `json:"principal_id"` + Permissions []string `json:"permissions" tf:"slice_as_set"` + } + s := common.StructToSchema(entity{}, + func(m map[string]*schema.Schema) map[string]*schema.Schema { + return m + }) + return common.Resource{ + Schema: s, + Create: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { + var assignment entity + common.DataToStructPointer(d, s, &assignment) + api := NewPermissionAssignmentAPI(ctx, c) + err := api.CreateOrUpdate(assignment.PrincipalId, Permissions{assignment.Permissions}) + if err != nil { + return err + } + d.SetId(fmt.Sprintf("%d", assignment.PrincipalId)) + return nil + }, + Read: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { + list, err := NewPermissionAssignmentAPI(ctx, c).List() + if err != nil { + return err + } + permissions, err := list.ForPrincipal(mustInt64(d.Id())) + if err != nil { + return err + } + return common.StructToData(permissions, s, d) + }, + Delete: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { + return NewPermissionAssignmentAPI(ctx, c).Remove(d.Id()) + }, + }.ToResource() +} diff --git a/access/resource_permission_assignment_test.go b/access/resource_permission_assignment_test.go new file mode 100644 index 0000000000..b982d8b407 --- /dev/null +++ b/access/resource_permission_assignment_test.go @@ -0,0 +1,72 @@ +package access + +import ( + "testing" + + "github.com/databricks/terraform-provider-databricks/qa" +) + +func TestPermissionAssignmentCreate(t *testing.T) { + qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + Method: "PUT", + Resource: "/api/2.0/preview/permissionassignments/principals/345", + ExpectedRequest: Permissions{ + Permissions: []string{"USER"}, + }, + }, + { + Method: "GET", + Resource: "/api/2.0/preview/permissionassignments", + Response: PermissionAssignmentList{ + PermissionAssignments: []PermissionAssignment{ + { + Permissions: []string{"USER"}, + Principal: Principal{ + PrincipalID: 345, + }, + }, + }, + }, + }, + }, + Resource: ResourcePermissionAssignment(), + Create: true, + AccountID: "abc", + HCL: ` + principal_id = 345 + permissions = ["USER"] + `, + }.ApplyNoError(t) +} + +func TestPermissionAssignmentReadNotFound(t *testing.T) { + qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + Method: "GET", + Resource: "/api/2.0/preview/permissionassignments", + Response: PermissionAssignmentList{ + PermissionAssignments: []PermissionAssignment{ + { + Permissions: []string{"USER"}, + Principal: Principal{ + PrincipalID: 345, + }, + }, + }, + }, + }, + }, + Resource: ResourcePermissionAssignment(), + Read: true, + Removed: true, + AccountID: "abc", + ID: "123", + }.ApplyNoError(t) +} + +func TestPermissionAssignmentFuzz(t *testing.T) { + qa.ResourceCornerCases(t, ResourcePermissionAssignment()) +} diff --git a/catalog/resource_metastore_assignment.go b/catalog/resource_metastore_assignment.go index fed25e6442..20efd90595 100644 --- a/catalog/resource_metastore_assignment.go +++ b/catalog/resource_metastore_assignment.go @@ -51,7 +51,10 @@ func ResourceMetastoreAssignment() *schema.Resource { func(m map[string]*schema.Schema) map[string]*schema.Schema { return m }) - pi := common.NewPairID("workspace_id", "metastore_id") + pi := common.NewPairID("workspace_id", "metastore_id").Schema( + func(m map[string]*schema.Schema) map[string]*schema.Schema { + return s + }) return common.Resource{ Schema: s, Create: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { diff --git a/common/pair.go b/common/pair.go index 4627467be2..3fa0819fcf 100644 --- a/common/pair.go +++ b/common/pair.go @@ -56,17 +56,23 @@ func (p *Pair) Unpack(d *schema.ResourceData) (string, string, error) { d.SetId("") return "", "", fmt.Errorf("%s cannot be empty", p.right) } - d.Set(p.left, parts[0]) - if p.schema[p.right].Type == schema.TypeInt { - i64, err := strconv.ParseInt(parts[1], 10, 64) - if err != nil { - return parts[0], parts[1], err - } - d.Set(p.right, i64) - } else { - d.Set(p.right, parts[1]) + err := p.setField(d, p.left, parts[0]) + if err != nil { + return parts[0], parts[1], err } - return parts[0], parts[1], nil + err = p.setField(d, p.right, parts[1]) + return parts[0], parts[1], err +} + +func (p *Pair) setField(d *schema.ResourceData, col, val string) error { + if p.schema[col].Type != schema.TypeInt { + return d.Set(col, val) + } + i64, err := strconv.ParseInt(val, 10, 64) + if err != nil { + return err + } + return d.Set(col, i64) } // Pack data attributes to ID diff --git a/docs/resources/mws_permission_assignment.md b/docs/resources/mws_permission_assignment.md new file mode 100644 index 0000000000..638bf590a2 --- /dev/null +++ b/docs/resources/mws_permission_assignment.md @@ -0,0 +1,65 @@ +--- +subcategory: "Unity Catalog" +--- +# databricks_mws_permission_assignment Resource + +These resources are invoked in the account context. Provider must have `account_id` attribute configured. + +## Example Usage + +In account context, adding account-level group to a workspace: + +```hcl +provider "databricks" { + // + account_id = "" +} + +resource "databricks_group" "data_eng" { + display_name = "Data Engineering" +} + +resource "databricks_mws_permission_assignment" "add_admin_group" { + workspace_id = databricks_mws_workspaces.this.workspace_id + principal_id = databricks_group.data_eng.id + permissions = ["ADMIN"] +} +``` + +In account context, adding account-level user to a workspace: + +```hcl +provider "databricks" { + // + account_id = "" +} + +resource "databricks_user" "me" { + user_name = "me@example.com" +} + +resource "databricks_mws_permission_assignment" "add_user" { + workspace_id = databricks_mws_workspaces.this.workspace_id + principal_id = databricks_user.me.id + permissions = ["USER"] +} +``` + +In account context, adding account-level service principal to a workspace: + +```hcl +provider "databricks" { + // + account_id = "" +} + +resource "databricks_service_principal" "sp" { + display_name = "Automation-only SP" +} + +resource "databricks_mws_permission_assignment" "add_admin_spn" { + workspace_id = databricks_mws_workspaces.this.workspace_id + principal_id = databricks_service_principal.sp.id + permissions = ["ADMIN"] +} +``` diff --git a/internal/acceptance/acceptance.go b/internal/acceptance/acceptance.go index 374844a537..c3d5d85c63 100644 --- a/internal/acceptance/acceptance.go +++ b/internal/acceptance/acceptance.go @@ -49,7 +49,8 @@ func Test(t *testing.T, steps []Step, otherVars ...map[string]string) { awsAttrs = "aws_attributes {}" } instancePoolID := "" - if cloudEnv != "MWS" && cloudEnv != "gcp-accounts" { + if cloudEnv != "MWS" && cloudEnv != "gcp-accounts" && !strings.HasPrefix(cloudEnv, "unity-catalog-") { + // TODO: replace this with data resource instancePoolID = compute.CommonInstancePoolID() } vars := map[string]string{ diff --git a/mws/acceptance/mws_permissionassignments_test.go b/mws/acceptance/mws_permissionassignments_test.go new file mode 100644 index 0000000000..cf915f5e5e --- /dev/null +++ b/mws/acceptance/mws_permissionassignments_test.go @@ -0,0 +1,64 @@ +package acceptance + +import ( + "testing" + + "github.com/databricks/terraform-provider-databricks/internal/acceptance" + "github.com/databricks/terraform-provider-databricks/qa" +) + +func TestAccAssignGroupToWorkspace(t *testing.T) { + qa.RequireCloudEnv(t, "unity-catalog-account") + acceptance.Test(t, []acceptance.Step{ + { + Template: ` + resource "databricks_group" "this" { + display_name = "TF {var.RANDOM}" + } + resource "databricks_mws_permission_assignment" "this" { + workspace_id = {env.TEST_UC_WORKSPACE_ID} + principal_id = databricks_group.this.id + permissions = ["USER"] + }`, + }, + { + Template: ` + resource "databricks_group" "this" { + display_name = "TF {var.RANDOM}" + } + resource "databricks_mws_permission_assignment" "this" { + workspace_id = {env.TEST_UC_WORKSPACE_ID} + principal_id = databricks_group.this.id + permissions = ["ADMIN"] + }`, + }, + { + Template: ` + resource "databricks_group" "this" { + display_name = "TF {var.RANDOM}" + } + resource "databricks_mws_permission_assignment" "this" { + workspace_id = {env.TEST_UC_WORKSPACE_ID} + principal_id = databricks_group.this.id + permissions = ["USER"] + }`, + }, + }) +} + +func TestAccAssignSpnToWorkspace(t *testing.T) { + qa.RequireCloudEnv(t, "unity-catalog-account") + acceptance.Test(t, []acceptance.Step{ + { + Template: ` + resource "databricks_service_principal" "this" { + display_name = "TF {var.RANDOM}" + } + resource "databricks_mws_permission_assignment" "this" { + workspace_id = {env.TEST_UC_WORKSPACE_ID} + principal_id = databricks_service_principal.this.id + permissions = ["USER"] + }`, + }, + }) +} diff --git a/mws/resource_mws_permission_assignment.go b/mws/resource_mws_permission_assignment.go new file mode 100644 index 0000000000..e243edf79c --- /dev/null +++ b/mws/resource_mws_permission_assignment.go @@ -0,0 +1,142 @@ +package mws + +import ( + "context" + "errors" + "fmt" + "strconv" + + "github.com/databricks/terraform-provider-databricks/common" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func NewPermissionAssignmentAPI(ctx context.Context, m any) PermissionAssignmentAPI { + return PermissionAssignmentAPI{m.(*common.DatabricksClient), ctx} +} + +type PermissionAssignmentAPI struct { + client *common.DatabricksClient + context context.Context +} + +type Permissions struct { + Permissions []string `json:"permissions"` +} + +func (a PermissionAssignmentAPI) CreateOrUpdate(workspaceId, principalId int64, r Permissions) error { + if a.client.AccountID == "" { + return errors.New("must have `account_id` on provider") + } + path := fmt.Sprintf( + "/preview/accounts/%s/workspaces/%d/permissionassignments/principals/%d", + a.client.AccountID, workspaceId, principalId) + return a.client.Put(a.context, path, r) +} + +func (a PermissionAssignmentAPI) Remove(workspaceId, principalId string) error { + if a.client.AccountID == "" { + return errors.New("must have `account_id` on provider") + } + path := fmt.Sprintf( + "/preview/accounts/%s/workspaces/%s/permissionassignments/principals/%s", + a.client.AccountID, workspaceId, principalId) + return a.client.Delete(a.context, path, nil) +} + +type Principal struct { + DisplayName string `json:"display_name"` + PrincipalID int64 `json:"principal_id"` + ServicePrincipalName string `json:"service_principal_name,omitempty"` + UserName string `json:"user_name,omitempty"` + GroupName string `json:"group_name,omitempty"` +} + +type PermissionAssignment struct { + Permissions []string `json:"permissions"` + Principal Principal +} + +type PermissionAssignmentList struct { + PermissionAssignments []PermissionAssignment `json:"permission_assignments"` +} + +func (l PermissionAssignmentList) ForPrincipal(principalId int64) (res Permissions, err error) { + for _, v := range l.PermissionAssignments { + if v.Principal.PrincipalID != principalId { + continue + } + return Permissions{v.Permissions}, nil + } + return res, common.NotFound(fmt.Sprintf("%d not found", principalId)) +} + +func (a PermissionAssignmentAPI) List(workspaceId int64) (list PermissionAssignmentList, err error) { + if a.client.AccountID == "" { + return list, errors.New("must have `account_id` on provider") + } + path := fmt.Sprintf("/preview/accounts/%s/workspaces/%d/permissionassignments", + a.client.AccountID, workspaceId) + err = a.client.Get(a.context, path, nil, &list) + return +} + +func mustInt64(s string) int64 { + n, err := strconv.ParseInt(s, 10, 0) + if err != nil { + panic(err) + } + return n +} + +func ResourceMwsPermissionAssignment() *schema.Resource { + type entity struct { + WorkspaceId int64 `json:"workspace_id"` + PrincipalId int64 `json:"principal_id"` + Permissions []string `json:"permissions" tf:"slice_as_set"` + } + s := common.StructToSchema(entity{}, + func(m map[string]*schema.Schema) map[string]*schema.Schema { + return m + }) + pair := common.NewPairID("workspace_id", "principal_id").Schema( + func(m map[string]*schema.Schema) map[string]*schema.Schema { + return s + }) + return common.Resource{ + Schema: s, + Create: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { + var assignment entity + common.DataToStructPointer(d, s, &assignment) + api := NewPermissionAssignmentAPI(ctx, c) + err := api.CreateOrUpdate(assignment.WorkspaceId, assignment.PrincipalId, + Permissions{assignment.Permissions}) + if err != nil { + return err + } + pair.Pack(d) + return nil + }, + Read: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { + workspaceId, principalId, err := pair.Unpack(d) + if err != nil { + return fmt.Errorf("parse id: %w", err) + } + list, err := NewPermissionAssignmentAPI(ctx, c).List(mustInt64(workspaceId)) + if err != nil { + return err + } + permissions, err := list.ForPrincipal(mustInt64(principalId)) + if err != nil { + return err + } + return common.StructToData(permissions, s, d) + }, + Delete: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { + workspaceId, principalId, err := pair.Unpack(d) + if err != nil { + return fmt.Errorf("parse id: %w", err) + } + return NewPermissionAssignmentAPI(ctx, c).Remove(workspaceId, principalId) + }, + }.ToResource() +} diff --git a/mws/resource_mws_permission_assignment_test.go b/mws/resource_mws_permission_assignment_test.go new file mode 100644 index 0000000000..6bbace883f --- /dev/null +++ b/mws/resource_mws_permission_assignment_test.go @@ -0,0 +1,103 @@ +package mws + +import ( + "testing" + + "github.com/databricks/terraform-provider-databricks/qa" +) + +func TestPermissionAssignmentCreate(t *testing.T) { + qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + Method: "PUT", + Resource: "/api/2.0/preview/accounts/abc/workspaces/123/permissionassignments/principals/345", + ExpectedRequest: Permissions{ + Permissions: []string{"USER"}, + }, + }, + { + Method: "GET", + Resource: "/api/2.0/preview/accounts/abc/workspaces/123/permissionassignments", + Response: PermissionAssignmentList{ + PermissionAssignments: []PermissionAssignment{ + { + Permissions: []string{"USER"}, + Principal: Principal{ + PrincipalID: 345, + }, + }, + }, + }, + }, + }, + Resource: ResourceMwsPermissionAssignment(), + Create: true, + AccountID: "abc", + HCL: ` + workspace_id = 123 + principal_id = 345 + permissions = ["USER"] + `, + }.ApplyNoError(t) +} + +func TestPermissionAssignmentReadNotFound(t *testing.T) { + qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + Method: "GET", + Resource: "/api/2.0/preview/accounts/abc/workspaces/123/permissionassignments", + Response: PermissionAssignmentList{ + PermissionAssignments: []PermissionAssignment{ + { + Permissions: []string{"USER"}, + Principal: Principal{ + PrincipalID: 345, + }, + }, + }, + }, + }, + }, + Resource: ResourceMwsPermissionAssignment(), + Read: true, + Removed: true, + AccountID: "abc", + ID: "123|456", + }.ApplyNoError(t) +} + +func TestPermissionAssignmentDelete(t *testing.T) { + qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + Method: "DELETE", + Resource: "/api/2.0/preview/accounts/abc/workspaces/123/permissionassignments/principals/456", + }, + }, + Resource: ResourceMwsPermissionAssignment(), + Delete: true, + ID: "123|456", + AccountID: "abc", + }.ApplyNoError(t) +} + +func TestPermissionAssignmentFuzz_NoAccountID(t *testing.T) { + qa.ResourceCornerCases(t, ResourceMwsPermissionAssignment(), + qa.CornerCaseID("123|456"), + qa.CornerCaseExpectError("must have `account_id` on provider")) +} + +func TestPermissionAssignmentFuzz_InvalidID(t *testing.T) { + qa.ResourceCornerCases(t, ResourceMwsPermissionAssignment(), + qa.CornerCaseExpectError("parse id: invalid ID: x"), + qa.CornerCaseSkipCRUD("create"), + qa.CornerCaseAccountID("abc")) +} + +func TestPermissionAssignmentFuzz_ApiErrors(t *testing.T) { + qa.ResourceCornerCases(t, ResourceMwsPermissionAssignment(), + qa.CornerCaseAccountID("abc"), + qa.CornerCaseID("123|456")) +} diff --git a/provider/provider.go b/provider/provider.go index e23cae8057..4ab2b23805 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -95,12 +95,14 @@ func DatabricksProvider() *schema.Provider { "databricks_mws_credentials": mws.ResourceMwsCredentials(), "databricks_mws_log_delivery": mws.ResourceMwsLogDelivery(), "databricks_mws_networks": mws.ResourceMwsNetworks(), + "databricks_mws_permission_assignment": mws.ResourceMwsPermissionAssignment(), "databricks_mws_private_access_settings": mws.ResourceMwsPrivateAccessSettings(), "databricks_mws_storage_configurations": mws.ResourceMwsStorageConfigurations(), "databricks_mws_vpc_endpoint": mws.ResourceMwsVpcEndpoint(), "databricks_mws_workspaces": mws.ResourceMwsWorkspaces(), "databricks_notebook": workspace.ResourceNotebook(), "databricks_obo_token": tokens.ResourceOboToken(), + "databricks_permission_assignment": access.ResourcePermissionAssignment(), "databricks_permissions": permissions.ResourcePermissions(), "databricks_pipeline": pipelines.ResourcePipeline(), "databricks_repo": repos.ResourceRepo(), diff --git a/qa/testing.go b/qa/testing.go index 8feb67f5c0..5b361382c1 100644 --- a/qa/testing.go +++ b/qa/testing.go @@ -304,6 +304,10 @@ func CornerCaseSkipCRUD(method string) CornerCase { return CornerCase{"skip_crud", method} } +func CornerCaseAccountID(id string) CornerCase { + return CornerCase{"account_id", id} +} + var HTTPFailures = []HTTPFixture{ { MatchAny: true, @@ -322,6 +326,7 @@ func ResourceCornerCases(t *testing.T, resource *schema.Resource, cc ...CornerCa config := map[string]string{ "id": "x", "expect_error": "I'm a teapot", + "account_id": "", } m := map[string]func(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics{ "create": resource.CreateContext, @@ -337,11 +342,12 @@ func ResourceCornerCases(t *testing.T, resource *schema.Resource, cc ...CornerCa } HTTPFixturesApply(t, HTTPFailures, func(ctx context.Context, client *common.DatabricksClient) { validData := resource.TestResourceData() - validData.SetId(config["id"]) + client.AccountID = config["account_id"] for n, v := range m { if v == nil { continue } + validData.SetId(config["id"]) diags := v(ctx, validData, client) if assert.Len(t, diags, 1) { assert.Equalf(t, config["expect_error"], diags[0].Summary,