diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 1650687e24..72083eb41d 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -5,10 +5,11 @@ ### Breaking Changes * Remove stale resources/datasources/documentation related to Clean Room services. - + ### New Features and Improvements * Add `arm` option to `databricks_node_type` instead of `graviton` ([#5028](https://github.com/databricks/terraform-provider-databricks/pull/5028)) +* Perform workspace-level permission assignment by `user_name`, `group_name`, or `service_principal_name` ([#5068](https://github.com/databricks/terraform-provider-databricks/pull/5068)). ### Bug Fixes diff --git a/access/resource_permission_assignment.go b/access/resource_permission_assignment.go index bb09a35360..f201bf354d 100644 --- a/access/resource_permission_assignment.go +++ b/access/resource_permission_assignment.go @@ -2,7 +2,10 @@ package access import ( "context" + "errors" "fmt" + "net/http" + "strconv" "github.com/databricks/databricks-sdk-go/apierr" "github.com/databricks/terraform-provider-databricks/common" @@ -22,9 +25,42 @@ 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) CreateOrUpdate(assignment permissionAssignmentEntity) (principalInfo, error) { + if assignment.PrincipalId != 0 { + var resp permissionAssignmentResponseItem + path := fmt.Sprintf("/api/2.0/preview/permissionassignments/principals/%d", assignment.PrincipalId) + err := a.client.Do(a.context, http.MethodPut, path, nil, nil, + Permissions{Permissions: assignment.Permissions}, &resp) + if err == nil && resp.Error != "" { + err = errors.New(resp.Error) + } + return resp.Principal, err + } else { + var principal permissionAssignmentResponse + request := permissionAssignmentRequest{ + PermissionAssignments: []permissionAssignmentRequestItem{ + { + principalInfo: principalInfo{ + ServicePrincipalName: assignment.ServicePrincipalName, + UserName: assignment.UserName, + GroupName: assignment.GroupName, + }, + Permissions: assignment.Permissions, + }, + }, + } + err := a.client.Post(a.context, "/preview/permissionassignments", request, &principal) + if err != nil { + return principalInfo{}, err + } + if len(principal.PermissionAssignments) == 0 { + return principalInfo{}, fmt.Errorf("no permission assignment found") + } + if principal.PermissionAssignments[0].Error != "" { + return principalInfo{}, errors.New(principal.PermissionAssignments[0].Error) + } + return principal.PermissionAssignments[0].Principal, nil + } } func (a PermissionAssignmentAPI) Remove(principalId string) error { @@ -32,29 +68,46 @@ func (a PermissionAssignmentAPI) Remove(principalId string) error { return a.client.Delete(a.context, path, nil) } -type Principal struct { - DisplayName string `json:"display_name"` - PrincipalID int64 `json:"principal_id"` +type principalInfo struct { + DisplayName string `json:"display_name,omitempty"` + PrincipalID int64 `json:"principal_id,omitempty"` ServicePrincipalName string `json:"service_principal_name,omitempty"` UserName string `json:"user_name,omitempty"` GroupName string `json:"group_name,omitempty"` } -type PermissionAssignment struct { +type permissionAssignmentRequestItem struct { + principalInfo Permissions []string `json:"permissions"` - Principal Principal } -type PermissionAssignmentList struct { - PermissionAssignments []PermissionAssignment `json:"permission_assignments"` +type permissionAssignmentRequest struct { + PermissionAssignments []permissionAssignmentRequestItem `json:"permission_assignments"` } -func (l PermissionAssignmentList) ForPrincipal(principalId int64) (res Permissions, err error) { +type permissionAssignmentResponseItem struct { + Permissions []string `json:"permissions"` + Principal principalInfo + Error string `json:"error,omitempty"` +} + +type permissionAssignmentResponse struct { + PermissionAssignments []permissionAssignmentResponseItem `json:"permission_assignments"` +} + +func (l permissionAssignmentResponse) ForPrincipal(principalId int64) (res permissionAssignmentEntity, err error) { for _, v := range l.PermissionAssignments { if v.Principal.PrincipalID != principalId { continue } - return Permissions{v.Permissions}, nil + return permissionAssignmentEntity{ + PrincipalId: v.Principal.PrincipalID, + ServicePrincipalName: v.Principal.ServicePrincipalName, + UserName: v.Principal.UserName, + GroupName: v.Principal.GroupName, + Permissions: v.Permissions, + DisplayName: v.Principal.DisplayName, + }, nil } return res, &apierr.APIError{ ErrorCode: "NOT_FOUND", @@ -63,31 +116,43 @@ func (l PermissionAssignmentList) ForPrincipal(principalId int64) (res Permissio } } -func (a PermissionAssignmentAPI) List() (list PermissionAssignmentList, err error) { +func (a PermissionAssignmentAPI) List() (list permissionAssignmentResponse, err error) { err = a.client.Get(a.context, "/preview/permissionassignments", nil, &list) return } +type permissionAssignmentEntity struct { + PrincipalId int64 `json:"principal_id,omitempty" tf:"computed,force_new"` + ServicePrincipalName string `json:"service_principal_name,omitempty" tf:"computed,force_new"` + UserName string `json:"user_name,omitempty" tf:"computed,force_new"` + GroupName string `json:"group_name,omitempty" tf:"computed,force_new"` + Permissions []string `json:"permissions" tf:"slice_as_set"` + DisplayName string `json:"display_name" tf:"computed"` +} + // 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() common.Resource { - type entity struct { - PrincipalId int64 `json:"principal_id"` - Permissions []string `json:"permissions" tf:"slice_as_set"` - } - s := common.StructToSchema(entity{}, common.NoCustomize) + s := common.StructToSchema(permissionAssignmentEntity{}, func(s map[string]*schema.Schema) map[string]*schema.Schema { + fields := []string{"principal_id", "service_principal_name", "user_name", "group_name"} + for _, field := range fields { + s[field].ExactlyOneOf = fields + } + common.CustomizeSchemaPath(s, "display_name").SetReadOnly() + return s + }) return common.Resource{ Schema: s, Create: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { - var assignment entity + var assignment permissionAssignmentEntity common.DataToStructPointer(d, s, &assignment) api := NewPermissionAssignmentAPI(ctx, c) - err := api.CreateOrUpdate(assignment.PrincipalId, Permissions{assignment.Permissions}) + principal, err := api.CreateOrUpdate(assignment) if err != nil { return err } - d.SetId(fmt.Sprintf("%d", assignment.PrincipalId)) + d.SetId(strconv.FormatInt(principal.PrincipalID, 10)) return nil }, Read: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { @@ -95,16 +160,19 @@ func ResourcePermissionAssignment() common.Resource { if err != nil { return err } - data := entity{ - PrincipalId: common.MustInt64(d.Id()), - } - permissions, err := list.ForPrincipal(data.PrincipalId) + data, err := list.ForPrincipal(common.MustInt64(d.Id())) if err != nil { return err } - data.Permissions = permissions.Permissions return common.StructToData(data, s, d) }, + Update: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { + var assignment permissionAssignmentEntity + common.DataToStructPointer(d, s, &assignment) + api := NewPermissionAssignmentAPI(ctx, c) + _, err := api.CreateOrUpdate(assignment) + return err + }, Delete: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { return NewPermissionAssignmentAPI(ctx, c).Remove(d.Id()) }, diff --git a/access/resource_permission_assignment_test.go b/access/resource_permission_assignment_test.go index 2a0b2c4e7e..a52d21e193 100644 --- a/access/resource_permission_assignment_test.go +++ b/access/resource_permission_assignment_test.go @@ -15,15 +15,21 @@ func TestPermissionAssignmentCreate(t *testing.T) { ExpectedRequest: Permissions{ Permissions: []string{"USER"}, }, + Response: permissionAssignmentResponseItem{ + Permissions: []string{"USER"}, + Principal: principalInfo{ + PrincipalID: 345, + }, + }, }, { Method: "GET", Resource: "/api/2.0/preview/permissionassignments", - Response: PermissionAssignmentList{ - PermissionAssignments: []PermissionAssignment{ + Response: permissionAssignmentResponse{ + PermissionAssignments: []permissionAssignmentResponseItem{ { Permissions: []string{"USER"}, - Principal: Principal{ + Principal: principalInfo{ PrincipalID: 345, }, }, @@ -33,6 +39,7 @@ func TestPermissionAssignmentCreate(t *testing.T) { }, Resource: ResourcePermissionAssignment(), Create: true, + New: true, HCL: ` principal_id = 345 permissions = ["USER"] @@ -40,17 +47,296 @@ func TestPermissionAssignmentCreate(t *testing.T) { }.ApplyNoError(t) } +func TestPermissionAssignmentCreateWithUserName(t *testing.T) { + qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + Method: "POST", + Resource: "/api/2.0/preview/permissionassignments", + ExpectedRequest: permissionAssignmentRequest{ + PermissionAssignments: []permissionAssignmentRequestItem{ + { + principalInfo: principalInfo{ + UserName: "test.user@databricks.com", + }, + Permissions: []string{"USER"}, + }, + }, + }, + Response: permissionAssignmentResponse{ + PermissionAssignments: []permissionAssignmentResponseItem{ + { + Permissions: []string{"USER"}, + Principal: principalInfo{ + PrincipalID: 123, + UserName: "test.user@databricks.com", + }, + }, + }, + }, + }, + { + Method: "GET", + Resource: "/api/2.0/preview/permissionassignments", + Response: permissionAssignmentResponse{ + PermissionAssignments: []permissionAssignmentResponseItem{ + { + Permissions: []string{"USER"}, + Principal: principalInfo{ + PrincipalID: 123, + UserName: "test.user@databricks.com", + }, + }, + }, + }, + }, + }, + Resource: ResourcePermissionAssignment(), + Create: true, + New: true, + HCL: ` + user_name = "test.user@databricks.com" + permissions = ["USER"] + `, + }.ApplyNoError(t) +} + +func TestPermissionAssignmentCreateWithServicePrincipalName(t *testing.T) { + qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + Method: "POST", + Resource: "/api/2.0/preview/permissionassignments", + ExpectedRequest: permissionAssignmentRequest{ + PermissionAssignments: []permissionAssignmentRequestItem{ + { + principalInfo: principalInfo{ + ServicePrincipalName: "spn-123", + }, + Permissions: []string{"USER"}, + }, + }, + }, + Response: permissionAssignmentResponse{ + PermissionAssignments: []permissionAssignmentResponseItem{ + { + Permissions: []string{"USER"}, + Principal: principalInfo{ + PrincipalID: 456, + ServicePrincipalName: "spn-123", + }, + }, + }, + }, + }, + { + Method: "GET", + Resource: "/api/2.0/preview/permissionassignments", + Response: permissionAssignmentResponse{ + PermissionAssignments: []permissionAssignmentResponseItem{ + { + Permissions: []string{"USER"}, + Principal: principalInfo{ + PrincipalID: 456, + ServicePrincipalName: "spn-123", + }, + }, + }, + }, + }, + }, + Resource: ResourcePermissionAssignment(), + Create: true, + New: true, + HCL: ` + service_principal_name = "spn-123" + permissions = ["USER"] + `, + }.ApplyNoError(t) +} + +func TestPermissionAssignmentCreateWithGroupName(t *testing.T) { + qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + Method: "POST", + Resource: "/api/2.0/preview/permissionassignments", + ExpectedRequest: permissionAssignmentRequest{ + PermissionAssignments: []permissionAssignmentRequestItem{ + { + principalInfo: principalInfo{ + GroupName: "admins", + }, + Permissions: []string{"USER"}, + }, + }, + }, + Response: permissionAssignmentResponse{ + PermissionAssignments: []permissionAssignmentResponseItem{ + { + Permissions: []string{"USER"}, + Principal: principalInfo{ + PrincipalID: 789, + GroupName: "admins", + }, + }, + }, + }, + }, + { + Method: "GET", + Resource: "/api/2.0/preview/permissionassignments", + Response: permissionAssignmentResponse{ + PermissionAssignments: []permissionAssignmentResponseItem{ + { + Permissions: []string{"USER"}, + Principal: principalInfo{ + PrincipalID: 789, + GroupName: "admins", + }, + }, + }, + }, + }, + }, + Resource: ResourcePermissionAssignment(), + Create: true, + New: true, + HCL: ` + group_name = "admins" + permissions = ["USER"] + `, + }.ApplyNoError(t) +} + +func TestPermissionAssignmentCreateWithNonExistingGroup(t *testing.T) { + qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + Method: "POST", + Resource: "/api/2.0/preview/permissionassignments", + ExpectedRequest: permissionAssignmentRequest{ + PermissionAssignments: []permissionAssignmentRequestItem{ + { + principalInfo: principalInfo{ + GroupName: "nonexistent-group", + }, + Permissions: []string{"USER"}, + }, + }, + }, + Response: permissionAssignmentResponse{ + PermissionAssignments: []permissionAssignmentResponseItem{ + { + Principal: principalInfo{ + GroupName: "nonexistent-group", + }, + Error: "RESOURCE_DOES_NOT_EXIST: Principal not found in account.", + }, + }, + }, + }, + }, + Resource: ResourcePermissionAssignment(), + Create: true, + New: true, + HCL: ` + group_name = "nonexistent-group" + permissions = ["USER"] + `, + }.ExpectError(t, "RESOURCE_DOES_NOT_EXIST: Principal not found in account.") +} + +func TestPermissionAssignmentUpdate(t *testing.T) { + qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + // Simulate updating permissions for a principal by principal_id + Method: "PUT", + Resource: "/api/2.0/preview/permissionassignments/principals/123", + ExpectedRequest: Permissions{ + Permissions: []string{"ADMIN"}, + }, + Response: permissionAssignmentResponseItem{ + Principal: principalInfo{ + PrincipalID: 123, + }, + Permissions: []string{"ADMIN"}, + }, + }, + { + Method: "GET", + Resource: "/api/2.0/preview/permissionassignments", + Response: permissionAssignmentResponse{ + PermissionAssignments: []permissionAssignmentResponseItem{ + { + Permissions: []string{"ADMIN"}, + Principal: principalInfo{ + PrincipalID: 123, + }, + }, + }, + }, + }, + }, + Resource: ResourcePermissionAssignment(), + Update: true, + ID: "123", + InstanceState: map[string]string{ + "principal_id": "123", + "permissions": "[\"USER\"]", + }, + HCL: ` + principal_id = 123 + permissions = ["ADMIN"] + `, + }.ApplyNoError(t) +} + +func TestPermissionAssignmentUpdateWithError(t *testing.T) { + qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + Method: "PUT", + Resource: "/api/2.0/preview/permissionassignments/principals/123", + ExpectedRequest: Permissions{ + Permissions: []string{"ADMIN"}, + }, + Status: 200, + Response: permissionAssignmentResponseItem{ + Principal: principalInfo{ + PrincipalID: 123, + }, + Permissions: []string{"ADMIN"}, + Error: "Invalid permission", + }, + }, + }, + Resource: ResourcePermissionAssignment(), + Update: true, + ID: "123", + InstanceState: map[string]string{ + "principal_id": "123", + "permissions": "USER", + }, + HCL: ` + principal_id = 123 + permissions = ["ADMIN"] + `, + }.ExpectError(t, "Invalid permission") +} + func TestPermissionAssignmentRead(t *testing.T) { qa.ResourceFixture{ Fixtures: []qa.HTTPFixture{ { Method: "GET", Resource: "/api/2.0/preview/permissionassignments", - Response: PermissionAssignmentList{ - PermissionAssignments: []PermissionAssignment{ + Response: permissionAssignmentResponse{ + PermissionAssignments: []permissionAssignmentResponseItem{ { Permissions: []string{"USER"}, - Principal: Principal{ + Principal: principalInfo{ PrincipalID: 345, }, }, @@ -74,11 +360,11 @@ func TestPermissionAssignmentReadNotFound(t *testing.T) { { Method: "GET", Resource: "/api/2.0/preview/permissionassignments", - Response: PermissionAssignmentList{ - PermissionAssignments: []PermissionAssignment{ + Response: permissionAssignmentResponse{ + PermissionAssignments: []permissionAssignmentResponseItem{ { Permissions: []string{"USER"}, - Principal: Principal{ + Principal: principalInfo{ PrincipalID: 345, }, }, diff --git a/docs/resources/permission_assignment.md b/docs/resources/permission_assignment.md index 024d4a75a2..4ee194264f 100644 --- a/docs/resources/permission_assignment.md +++ b/docs/resources/permission_assignment.md @@ -9,6 +9,8 @@ This resource is used to assign account-level users, service principals and grou ## Example Usage +### Assign using `principal_id` + In workspace context, adding account-level user to a workspace: ```hcl @@ -68,12 +70,47 @@ output "databricks_group_id" { } ``` +### Assign using `user_name`, `group_name`, or `service_principal_name` + +In workspace context, adding account-level user to a workspace: + +```hcl +resource "databricks_permission_assignment" "add_user" { + user_name = "me@example.com" + permissions = ["USER"] + provider = databricks.workspace +} +``` + +In workspace context, adding account-level service principal to a workspace: + +```hcl +resource "databricks_permission_assignment" "add_admin_spn" { + service_principal_name = "00000000-0000-0000-0000-000000000000" + permissions = ["ADMIN"] + provider = databricks.workspace +} +``` + +In workspace context, adding account-level group to a workspace: + +```hcl +resource "databricks_permission_assignment" "this" { + group_name = "example-group" + permissions = ["USER"] + provider = databricks.workspace +} +``` + ## Argument Reference -The following arguments are required: +The following arguments are supported (exactly one of `principal_id`, `user_name`, `group_name`, or `service_principal_name` is required. Change of them triggers recreation): * `principal_id` - Databricks ID of the user, service principal, or group. The principal ID can be retrieved using the account-level SCIM API, or using [databricks_user](../data-sources/user.md), [databricks_service_principal](../data-sources/service_principal.md) or [databricks_group](../data-sources/group.md) data sources with account API (and has to be an account admin). A more sensible approach is to retrieve the list of `principal_id` as outputs from another Terraform stack. -* `permissions` - The list of workspace permissions to assign to the principal: +* `user_name` - the user name (email) to assign to a workspace. +* `service_principal_name` - the application ID of service principal to assign to a workspace. +* `group_name` - the group name to assign to a workspace. +* `permissions` (Required) - The list of workspace permissions to assign to the principal: * `"USER"` - Adds principal to the workspace `users` group. This gives basic workspace access. * `"ADMIN"` - Adds principal to the workspace `admins` group. This gives workspace admin privileges to manage users and groups, workspace configurations, and more. @@ -82,6 +119,7 @@ The following arguments are required: In addition to all arguments above, the following attributes are exported: * `id` - ID of the permission assignment - same as `principal_id`. +* `display_name` - the display name of the assigned principal. ## Import