diff --git a/docs/resources/entitlements.md b/docs/resources/entitlements.md new file mode 100644 index 0000000000..746e77bb09 --- /dev/null +++ b/docs/resources/entitlements.md @@ -0,0 +1,88 @@ +--- +subcategory: "Security" +--- +# databricks_entitlements Resource + +This resource allows you to set entitlements to existing [databricks_users](user.md), [databricks_group](group.md) or [databricks_service_principal](service_principal.md) + +## Example Usage + +Setting entitlements for a regular user: + +```hcl +data "databricks_user" "me" { + user_name = "me@example.com" +} + +resource "databricks_entitlements" "me" { + user_id = data.databricks_user.me.id + allow_cluster_create = true + allow_instance_pool_create = true +} +``` + +Setting entitlements for a service principal: + +```hcl +data "databricks_service_principal" "this" { + application_id = "11111111-2222-3333-4444-555666777888" +} + +resource "databricks_entitlements" "this" { + service_principal_id = data.databricks_service_principal.this.sp_id + allow_cluster_create = true + allow_instance_pool_create = true +} +``` + +Setting entitlements to all users in a workspace - referencing special `users` [databricks_group](../data-sources/group.md) + +```hcl +data "databricks_group" "users" { + display_name = "users" +} + +resource "databricks_entitlements" "workspace-users" { + group_id = data.databricks_group.users.id + allow_cluster_create = true + allow_instance_pool_create = true +} +``` + +## Argument Reference + +The following arguments are available to specify the identity you need to enforce entitlements. You must specify exactly one of those arguments otherwise resource creation will fail. + +* `user_id` - Canonical unique identifier for the user. +* `group_id` - Canonical unique identifier for the group. +* `service_principal_id` - Canonical unique identifier for the service principal. + +The following entitlements are available. + +* `allow_cluster_create` - (Optional) Allow the user to have [cluster](cluster.md) create privileges. Defaults to false. More fine grained permissions could be assigned with [databricks_permissions](permissions.md#Cluster-usage) and `cluster_id` argument. Everyone without `allow_cluster_create` argument set, but with [permission to use](permissions.md#Cluster-Policy-usage) Cluster Policy would be able to create clusters, but within boundaries of that specific policy. +* `allow_instance_pool_create` - (Optional) Allow the user to have [instance pool](instance_pool.md) create privileges. Defaults to false. More fine grained permissions could be assigned with [databricks_permissions](permissions.md#Instance-Pool-usage) and [instance_pool_id](permissions.md#instance_pool_id) argument. +* `databricks_sql_access` - (Optional) This is a field to allow the group to have access to [Databricks SQL](https://databricks.com/product/databricks-sql) feature in User Interface and through [databricks_sql_endpoint](sql_endpoint.md). + +## Import + +The resource can be imported using a synthetic identifier. Examples of valid synthetic identifiers are: + +* `user/user_id` - user `user_id`. +* `group/group_id` - group `group_id`. +* `spn/spn_id` - service principal `spn_id`. + +```bash +terraform import databricks_entitlements.me user/ +``` + +## Related Resources + +The following resources are often used in the same context: + +* [End to end workspace management](../guides/workspace-management.md) guide. +* [databricks_group](group.md) to manage [groups in Databricks Workspace](https://docs.databricks.com/administration-guide/users-groups/groups.html) or [Account Console](https://accounts.cloud.databricks.com/) (for AWS deployments). +* [databricks_group](../data-sources/group.md) data to retrieve information about [databricks_group](group.md) members, entitlements and instance profiles. +* [databricks_group_instance_profile](group_instance_profile.md) to attach [databricks_instance_profile](instance_profile.md) (AWS) to [databricks_group](group.md). +* [databricks_group_member](group_member.md) to attach [users](user.md) and [groups](group.md) as group members. +* [databricks_instance_profile](instance_profile.md) to manage AWS EC2 instance profiles that users can launch [databricks_cluster](cluster.md) and access data, like [databricks_mount](mount.md). +* [databricks_user](../data-sources/user.md) data to retrieve information about [databricks_user](user.md). diff --git a/provider/provider.go b/provider/provider.go index 0be2e38a43..7d532a7fee 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -72,6 +72,7 @@ func DatabricksProvider() *schema.Provider { "databricks_cluster_policy": policies.ResourceClusterPolicy(), "databricks_dbfs_file": storage.ResourceDbfsFile(), "databricks_directory": workspace.ResourceDirectory(), + "databricks_entitlements": scim.ResourceEntitlements(), "databricks_external_location": catalog.ResourceExternalLocation(), "databricks_git_credential": repos.ResourceGitCredential(), "databricks_global_init_script": workspace.ResourceGlobalInitScript(), diff --git a/scim/acceptance/entitlements_test.go b/scim/acceptance/entitlements_test.go new file mode 100644 index 0000000000..a1dcce8b19 --- /dev/null +++ b/scim/acceptance/entitlements_test.go @@ -0,0 +1,104 @@ +package acceptance + +import ( + "os" + "testing" + + "github.com/databricks/terraform-provider-databricks/internal/acceptance" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccEntitlementResource(t *testing.T) { + if _, ok := os.LookupEnv("CLOUD_ENV"); !ok { + t.Skip("Acceptance tests skipped unless env 'CLOUD_ENV' is set") + } + t.Parallel() + config := acceptance.EnvironmentTemplate(t, ` + resource "databricks_user" "first" { + user_name = "tf-eerste+{var.RANDOM}@example.com" + display_name = "Eerste {var.RANDOM}" + allow_cluster_create = true + allow_instance_pool_create = true + } + + resource "databricks_group" "second" { + display_name = "{var.RANDOM} group" + allow_cluster_create = true + allow_instance_pool_create = true + } + + resource "databricks_entitlements" "first_entitlements" { + user_id = databricks_user.first.id + allow_cluster_create = true + allow_instance_pool_create = true + } + + resource "databricks_entitlements" "second_entitlements" { + group_id = databricks_group.second.id + allow_cluster_create = true + allow_instance_pool_create = true + } + `) + acceptance.AccTest(t, resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("databricks_entitlements.first_entitlements", "allow_cluster_create", "true"), + resource.TestCheckResourceAttr("databricks_entitlements.first_entitlements", "allow_instance_pool_create", "true"), + resource.TestCheckResourceAttr("databricks_entitlements.second_entitlements", "allow_cluster_create", "true"), + resource.TestCheckResourceAttr("databricks_entitlements.second_entitlements", "allow_instance_pool_create", "true"), + ), + }, + { + Config: config, + }, + }, + }) +} + +func TestAccServicePrincipalEntitlementsResourceOnAzure(t *testing.T) { + if cloud, ok := os.LookupEnv("CLOUD_ENV"); !ok || cloud != "azure" { + t.Skip("Test is only for CLOUD_ENV=azure") + } + t.Parallel() + acceptance.Test(t, []acceptance.Step{ + { + Template: `resource "databricks_service_principal" "this" { + application_id = "00000000-1234-5678-0000-000000000001" + display_name = "SPN {var.RANDOM}" + allow_cluster_create = true + allow_instance_pool_create = true + } + + resource "databricks_entitlements" "service_principal" { + service_principal_id = databricks_service_principal.this.id + allow_cluster_create = true + allow_instance_pool_create = true + }`, + }, + }) +} + +func TestAccServicePrincipalEntitlementsResourceOnAws(t *testing.T) { + if cloud, ok := os.LookupEnv("CLOUD_ENV"); !ok || cloud != "aws" { + t.Skip("Test is only for CLOUD_ENV=aws") + } + t.Parallel() + acceptance.Test(t, []acceptance.Step{ + { + Template: `resource "databricks_service_principal" "this" { + display_name = "SPN {var.RANDOM}" + allow_cluster_create = true + allow_instance_pool_create = true + } + + resource "databricks_entitlements" "service_principal" { + service_principal_id = databricks_service_principal.this.id + allow_cluster_create = true + allow_instance_pool_create = true + }`, + }, + }) +} diff --git a/scim/groups.go b/scim/groups.go index 89498cb6d1..d716354ab8 100644 --- a/scim/groups.go +++ b/scim/groups.go @@ -84,6 +84,11 @@ func (a GroupsAPI) UpdateNameAndEntitlements(groupID string, name string, extern }, nil) } +func (a GroupsAPI) UpdateEntitlements(groupID string, entitlements patchRequest) error { + return a.client.Scim(a.context, http.MethodPatch, + fmt.Sprintf("/preview/scim/v2/Groups/%v", groupID), entitlements, nil) +} + // Delete deletes a group given a group id func (a GroupsAPI) Delete(groupID string) error { return a.client.Scim(a.context, http.MethodDelete, diff --git a/scim/resource_entitlement.go b/scim/resource_entitlement.go new file mode 100644 index 0000000000..7844758d39 --- /dev/null +++ b/scim/resource_entitlement.go @@ -0,0 +1,134 @@ +package scim + +import ( + "context" + "fmt" + "strings" + + "github.com/databricks/terraform-provider-databricks/common" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +// ResourceGroup manages user groups +func ResourceEntitlements() *schema.Resource { + type entity struct { + GroupId string `json:"group_id,omitempty" tf:"force_new"` + UserId string `json:"user_id,omitempty" tf:"force_new"` + SpnId string `json:"service_principal_id,omitempty" tf:"force_new"` + } + entitlementSchema := common.StructToSchema(entity{}, + func(m map[string]*schema.Schema) map[string]*schema.Schema { + addEntitlementsToSchema(&m) + alof := []string{"group_id", "user_id", "service_principal_id"} + for _, field := range alof { + m[field].AtLeastOneOf = alof + } + return m + }) + addEntitlementsToSchema(&entitlementSchema) + return common.Resource{ + Create: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { + return patchEntitlements(ctx, d, c, "add") + }, + Read: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { + split := strings.SplitN(d.Id(), "/", 2) + if len(split) != 2 { + return fmt.Errorf("ID must be two elements: %s", d.Id()) + } + switch strings.ToLower(split[0]) { + case "group": + group, err := NewGroupsAPI(ctx, c).Read(split[1]) + if err != nil { + return err + } + return group.Entitlements.readIntoData(d) + case "user": + user, err := NewUsersAPI(ctx, c).Read(split[1]) + if err != nil { + return err + } + return user.Entitlements.readIntoData(d) + case "spn": + spn, err := NewServicePrincipalsAPI(ctx, c).Read(split[1]) + if err != nil { + return err + } + return spn.Entitlements.readIntoData(d) + } + return nil + }, + Update: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { + return enforceEntitlements(ctx, d, c) + }, + Delete: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { + return patchEntitlements(ctx, d, c, "remove") + }, + Schema: entitlementSchema, + }.ToResource() +} + +func patchEntitlements(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient, op string) error { + groupId := d.Get("group_id").(string) + userId := d.Get("user_id").(string) + spnId := d.Get("service_principal_id").(string) + request := PatchRequestComplexValue([]patchOperation{ + { + op, + "entitlements", + readEntitlementsFromData(d), + }, + }) + if groupId != "" { + groupsAPI := NewGroupsAPI(ctx, c) + err := groupsAPI.UpdateEntitlements(groupId, request) + if err != nil { + return err + } + d.SetId("group/" + groupId) + } + if userId != "" { + usersAPI := NewUsersAPI(ctx, c) + err := usersAPI.UpdateEntitlements(userId, request) + if err != nil { + return err + } + d.SetId("user/" + userId) + } + if spnId != "" { + spnAPI := NewServicePrincipalsAPI(ctx, c) + err := spnAPI.UpdateEntitlements(spnId, request) + if err != nil { + return err + } + d.SetId("spn/" + spnId) + } + return nil +} + +func enforceEntitlements(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { + split := strings.SplitN(d.Id(), "/", 2) + if len(split) != 2 { + return fmt.Errorf("ID must be two elements: %s", d.Id()) + } + identity := strings.ToLower(split[0]) + id := strings.ToLower(split[1]) + request := PatchRequestComplexValue( + []patchOperation{ + { + "remove", "entitlements", generateFullEntitlements(), + }, + { + "add", "entitlements", readEntitlementsFromData(d), + }, + }, + ) + switch identity { + case "group": + NewGroupsAPI(ctx, c).UpdateEntitlements(id, request) + case "user": + NewUsersAPI(ctx, c).UpdateEntitlements(id, request) + case "spn": + NewServicePrincipalsAPI(ctx, c).UpdateEntitlements(id, request) + } + return nil +} diff --git a/scim/resource_entitlement_test.go b/scim/resource_entitlement_test.go new file mode 100644 index 0000000000..70601a1cd0 --- /dev/null +++ b/scim/resource_entitlement_test.go @@ -0,0 +1,684 @@ +package scim + +import ( + "testing" + + "github.com/databricks/terraform-provider-databricks/common" + "github.com/databricks/terraform-provider-databricks/qa" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var oldGroup = Group{ + Schemas: []URN{"urn:ietf:params:scim:schemas:core:2.0:Group"}, + DisplayName: "Data Scientists", + ID: "abc", + Entitlements: []ComplexValue{ + { + Value: "allow-cluster-create", + }, + }, +} + +var newGroup = Group{ + Schemas: []URN{"urn:ietf:params:scim:schemas:core:2.0:Group"}, + DisplayName: "Data Scientists", + ID: "abc", + Entitlements: []ComplexValue{ + { + Value: "allow-cluster-create", + }, + { + Value: "allow-instance-pool-create", + }, + { + Value: "databricks-sql-access", + }, + }, +} + +var addRequest = PatchRequestComplexValue([]patchOperation{ + { + "add", "entitlements", []ComplexValue{ + { + Value: "allow-cluster-create", + }, + { + Value: "allow-instance-pool-create", + }, + { + Value: "databricks-sql-access", + }, + }, + }, +}) + +var updateRequest = PatchRequestComplexValue([]patchOperation{ + { + "remove", "entitlements", []ComplexValue{ + { + Value: "allow-cluster-create", + }, + { + Value: "allow-instance-pool-create", + }, + { + Value: "databricks-sql-access", + }, + { + Value: "workspace-access", + }, + }, + }, + { + "add", "entitlements", []ComplexValue{ + { + Value: "allow-cluster-create", + }, + { + Value: "allow-instance-pool-create", + }, + { + Value: "databricks-sql-access", + }, + }, + }, +}) + +var deleteRequest = PatchRequestComplexValue([]patchOperation{{"remove", "entitlements", []ComplexValue{ + { + Value: "allow-cluster-create", + }, +}}}) + +func TestResourceEntitlementsGroupCreate(t *testing.T) { + d, err := qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + Method: "GET", + Resource: "/api/2.0/preview/scim/v2/Groups/abc", + Response: oldGroup, + }, + { + Method: "PATCH", + Resource: "/api/2.0/preview/scim/v2/Groups/abc", + ExpectedRequest: addRequest, + Response: Group{ + ID: "abc", + }, + }, + { + Method: "GET", + Resource: "/api/2.0/preview/scim/v2/Groups/abc", + Response: newGroup, + }, + }, + Resource: ResourceEntitlements(), + HCL: ` + group_id = "abc" + allow_instance_pool_create = true + allow_cluster_create = true + databricks_sql_access = true + `, + Create: true, + }.Apply(t) + assert.NoError(t, err, err) + assert.Equal(t, "group/abc", d.Id()) + assert.Equal(t, true, d.Get("allow_cluster_create")) + assert.Equal(t, true, d.Get("allow_instance_pool_create")) + assert.Equal(t, true, d.Get("databricks_sql_access")) +} + +func TestResourceEntitlementsGroupRead(t *testing.T) { + qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + Method: "GET", + Resource: "/api/2.0/preview/scim/v2/Groups/abc", + Response: oldGroup, + }, + }, + Resource: ResourceEntitlements(), + HCL: `group_id = "abc"`, + New: true, + Read: true, + ID: "group/abc", + }.ApplyAndExpectData(t, map[string]any{ + "group_id": "abc", + "allow_cluster_create": true, + }) +} + +func TestResourceEntitlementsGroupRead_Error(t *testing.T) { + qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + Method: "GET", + Resource: "/api/2.0/preview/scim/v2/Groups/abc", + Status: 400, + Response: common.APIErrorBody{ + ScimDetail: "Something", + ScimStatus: "Else", + }, + }, + }, + Resource: ResourceEntitlements(), + New: true, + Read: true, + ID: "group/abc", + HCL: `group_id = "abc"`, + }.ExpectError(t, "Something") +} + +func TestResourceEntitlementsGroupUpdate(t *testing.T) { + d, err := qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + Method: "GET", + Resource: "/api/2.0/preview/scim/v2/Groups/abc", + Response: oldGroup, + }, + { + Method: "PATCH", + Resource: "/api/2.0/preview/scim/v2/Groups/abc", + ExpectedRequest: updateRequest, + Response: Group{ + ID: "abc", + }, + }, + { + Method: "GET", + Resource: "/api/2.0/preview/scim/v2/Groups/abc", + Response: newGroup, + }, + }, + Resource: ResourceEntitlements(), + Update: true, + ID: "group/abc", + InstanceState: map[string]string{ + "group_id": "abc", + "allow_cluster_create": "true", + }, + HCL: ` + group_id = "abc" + allow_cluster_create = true + allow_instance_pool_create = true + databricks_sql_access = true + `, + }.Apply(t) + require.NoError(t, err, err) + assert.Equal(t, "group/abc", d.Id(), "Id should not be empty") + assert.Equal(t, true, d.Get("allow_cluster_create")) + assert.Equal(t, true, d.Get("allow_instance_pool_create")) + assert.Equal(t, true, d.Get("databricks_sql_access")) +} + +func TestResourceEntitlementsGroupDelete(t *testing.T) { + qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + Method: "GET", + Resource: "/api/2.0/preview/scim/v2/Groups/abc", + Response: oldGroup, + }, + { + Method: "PATCH", + Resource: "/api/2.0/preview/scim/v2/Groups/abc", + ExpectedRequest: deleteRequest, + Response: Group{ + ID: "abc", + }, + }, + }, + Resource: ResourceEntitlements(), + Delete: true, + ID: "group/abc", + InstanceState: map[string]string{ + "group_id": "abc", + "allow_cluster_create": "true", + }, + HCL: ` + group_id = "abc" + allow_cluster_create = true + `, + }.Apply(t) +} + +var oldUser = User{ + DisplayName: "Example user", + Active: true, + UserName: "me@example.com", + ID: "abc", + Entitlements: []ComplexValue{ + { + Value: "allow-cluster-create", + }, + }, + Groups: []ComplexValue{ + { + Display: "admins", + Value: "4567", + }, + { + Display: "ds", + Value: "9877", + }, + }, + Roles: []ComplexValue{ + { + Value: "a", + }, + { + Value: "b", + }, + }, +} + +var newUser = User{ + DisplayName: "Example user", + Active: true, + UserName: "me@example.com", + ID: "abc", + Entitlements: []ComplexValue{ + { + Value: "allow-cluster-create", + }, + { + Value: "allow-instance-pool-create", + }, + { + Value: "databricks-sql-access", + }, + }, + Groups: []ComplexValue{ + { + Display: "admins", + Value: "4567", + }, + { + Display: "ds", + Value: "9877", + }, + }, + Roles: []ComplexValue{ + { + Value: "a", + }, + { + Value: "b", + }, + }, +} + +func TestResourceEntitlementsUserCreate(t *testing.T) { + d, err := qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + Method: "GET", + Resource: "/api/2.0/preview/scim/v2/Users/abc", + Response: oldUser, + }, + { + Method: "PATCH", + Resource: "/api/2.0/preview/scim/v2/Users/abc", + ExpectedRequest: addRequest, + Response: User{ + ID: "abc", + }, + }, + { + Method: "GET", + Resource: "/api/2.0/preview/scim/v2/Users/abc", + Response: newUser, + }, + }, + Resource: ResourceEntitlements(), + HCL: ` + user_id = "abc" + allow_instance_pool_create = true + allow_cluster_create = true + databricks_sql_access = true + `, + Create: true, + }.Apply(t) + assert.NoError(t, err, err) + assert.Equal(t, "user/abc", d.Id()) + assert.Equal(t, true, d.Get("allow_cluster_create")) + assert.Equal(t, true, d.Get("allow_instance_pool_create")) + assert.Equal(t, true, d.Get("databricks_sql_access")) +} + +func TestResourceEntitlementsUserRead(t *testing.T) { + qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + Method: "GET", + Resource: "/api/2.0/preview/scim/v2/Users/abc", + Response: oldUser, + }, + }, + Resource: ResourceEntitlements(), + HCL: `user_id = "abc"`, + New: true, + Read: true, + ID: "user/abc", + }.ApplyAndExpectData(t, map[string]any{ + "user_id": "abc", + "allow_cluster_create": true, + }) +} + +func TestResourceEntitlementsUserRead_Error(t *testing.T) { + qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + Method: "GET", + Resource: "/api/2.0/preview/scim/v2/Users/abc", + Status: 400, + Response: common.APIErrorBody{ + ScimDetail: "Something", + ScimStatus: "Else", + }, + }, + }, + Resource: ResourceEntitlements(), + New: true, + Read: true, + ID: "user/abc", + HCL: `user_id = "abc"`, + }.ExpectError(t, "Something") +} + +func TestResourceEntitlementsUserUpdate_Error(t *testing.T) { + qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + Method: "GET", + Resource: "/api/2.0/preview/scim/v2/Users/abc", + Status: 400, + Response: common.APIErrorBody{ + ScimDetail: "Something", + ScimStatus: "Else", + }, + }, + { + Method: "PATCH", + Resource: "/api/2.0/preview/scim/v2/Users/abc", + ExpectedRequest: updateRequest, + Status: 400, + Response: common.APIErrorBody{ + ScimDetail: "Something", + ScimStatus: "Else", + }, + }, + }, + Resource: ResourceEntitlements(), + Update: true, + ID: "user/abc", + InstanceState: map[string]string{ + "user_id": "abc", + "allow_cluster_create": "true", + }, + HCL: ` + user_id = "abc" + allow_cluster_create = true + allow_instance_pool_create = true + databricks_sql_access = true + `, + }.ExpectError(t, "Something") +} + +func TestResourceEntitlementsUserUpdate(t *testing.T) { + d, err := qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + Method: "GET", + Resource: "/api/2.0/preview/scim/v2/Users/abc", + Response: oldUser, + }, + { + Method: "PATCH", + Resource: "/api/2.0/preview/scim/v2/Users/abc", + ExpectedRequest: updateRequest, + Response: User{ + ID: "abc", + }, + }, + { + Method: "GET", + Resource: "/api/2.0/preview/scim/v2/Users/abc", + Response: newUser, + }, + }, + Resource: ResourceEntitlements(), + Update: true, + ID: "user/abc", + InstanceState: map[string]string{ + "user_id": "abc", + "allow_cluster_create": "true", + }, + HCL: ` + user_id = "abc" + allow_cluster_create = true + allow_instance_pool_create = true + databricks_sql_access = true + `, + }.Apply(t) + require.NoError(t, err, err) + assert.Equal(t, "user/abc", d.Id(), "Id should not be empty") + assert.Equal(t, true, d.Get("allow_cluster_create")) + assert.Equal(t, true, d.Get("allow_instance_pool_create")) + assert.Equal(t, true, d.Get("databricks_sql_access")) +} + +func TestResourceEntitlementsUserDelete(t *testing.T) { + qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + Method: "GET", + Resource: "/api/2.0/preview/scim/v2/Users/abc", + Response: oldUser, + }, + { + Method: "PATCH", + Resource: "/api/2.0/preview/scim/v2/Users/abc", + ExpectedRequest: deleteRequest, + Response: User{ + ID: "abc", + }, + }, + }, + Resource: ResourceEntitlements(), + Delete: true, + ID: "user/abc", + InstanceState: map[string]string{ + "user_id": "abc", + "allow_cluster_create": "true", + }, + HCL: ` + user_id = "abc" + allow_cluster_create = true + `, + }.ApplyNoError(t) +} + +func TestResourceEntitlementsSPNCreate(t *testing.T) { + d, err := qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + Method: "GET", + Resource: "/api/2.0/preview/scim/v2/ServicePrincipals/abc", + Response: oldUser, + }, + { + Method: "PATCH", + Resource: "/api/2.0/preview/scim/v2/ServicePrincipals/abc", + ExpectedRequest: addRequest, + Response: User{ + ID: "abc", + }, + }, + { + Method: "GET", + Resource: "/api/2.0/preview/scim/v2/ServicePrincipals/abc", + Response: newUser, + }, + }, + Resource: ResourceEntitlements(), + HCL: ` + service_principal_id = "abc" + allow_cluster_create = true + allow_instance_pool_create = true + databricks_sql_access = true + `, + Create: true, + }.Apply(t) + assert.NoError(t, err, err) + assert.Equal(t, "spn/abc", d.Id()) + assert.Equal(t, true, d.Get("allow_cluster_create")) + assert.Equal(t, true, d.Get("allow_instance_pool_create")) + assert.Equal(t, true, d.Get("databricks_sql_access")) +} + +func TestResourceEntitlementsSPNRead(t *testing.T) { + qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + Method: "GET", + Resource: "/api/2.0/preview/scim/v2/ServicePrincipals/abc", + Response: User{ + ID: "abc", + ApplicationID: "bcd", + DisplayName: "Example Service Principal", + Active: true, + Entitlements: []ComplexValue{ + { + Value: "allow-cluster-create", + }, + }, + }, + }, + }, + Resource: ResourceEntitlements(), + HCL: `service_principal_id = "abc"`, + New: true, + Read: true, + ID: "spn/abc", + }.ApplyAndExpectData(t, map[string]any{ + "service_principal_id": "abc", + "allow_cluster_create": true, + }) +} + +func TestResourceEntitlementsSPNRead_NotFound(t *testing.T) { + qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + Method: "GET", + Resource: "/api/2.0/preview/scim/v2/ServicePrincipals/abc", + Status: 404, + }, + }, + Resource: ResourceEntitlements(), + New: true, + Read: true, + Removed: true, + ID: "spn/abc", + HCL: `service_principal_id = "abc"`, + }.ApplyNoError(t) +} + +func TestResourceEntitlementsSPNRead_Error(t *testing.T) { + qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + Method: "GET", + Resource: "/api/2.0/preview/scim/v2/ServicePrincipals/abc", + Status: 400, + Response: common.APIErrorBody{ + ScimDetail: "Something", + ScimStatus: "Else", + }, + }, + }, + Resource: ResourceEntitlements(), + New: true, + Read: true, + ID: "spn/abc", + HCL: `service_principal_id = "abc"`, + }.ExpectError(t, "Something") +} + +func TestResourceEntitlementsSPNUpdate(t *testing.T) { + d, err := qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + Method: "GET", + Resource: "/api/2.0/preview/scim/v2/ServicePrincipals/abc", + Response: oldUser, + }, + { + Method: "PATCH", + Resource: "/api/2.0/preview/scim/v2/ServicePrincipals/abc", + ExpectedRequest: updateRequest, + Response: Group{ + ID: "abc", + }, + }, + { + Method: "GET", + Resource: "/api/2.0/preview/scim/v2/ServicePrincipals/abc", + Response: newUser, + }, + }, + Resource: ResourceEntitlements(), + Update: true, + ID: "spn/abc", + InstanceState: map[string]string{ + "service_principal_id": "abc", + "allow_cluster_create": "true", + }, + HCL: ` + service_principal_id = "abc" + allow_cluster_create = true + allow_instance_pool_create = true + databricks_sql_access = true + `, + }.Apply(t) + require.NoError(t, err, err) + assert.Equal(t, "spn/abc", d.Id(), "Id should not be empty") + assert.Equal(t, true, d.Get("allow_cluster_create")) + assert.Equal(t, true, d.Get("allow_instance_pool_create")) + assert.Equal(t, true, d.Get("databricks_sql_access")) +} + +func TestResourceEntitlementsSPNDelete(t *testing.T) { + qa.ResourceFixture{ + Fixtures: []qa.HTTPFixture{ + { + Method: "GET", + Resource: "/api/2.0/preview/scim/v2/ServicePrincipals/abc", + Response: oldUser, + }, + { + Method: "PATCH", + Resource: "/api/2.0/preview/scim/v2/ServicePrincipals/abc", + ExpectedRequest: deleteRequest, + Response: User{ + ID: "abc", + }, + }, + }, + Resource: ResourceEntitlements(), + Delete: true, + ID: "spn/abc", + InstanceState: map[string]string{ + "service_principal_id": "abc", + "allow_cluster_create": "true", + }, + HCL: ` + service_principal_id = "abc" + allow_cluster_create = true + `, + }.ApplyNoError(t) +} diff --git a/scim/resource_service_principal.go b/scim/resource_service_principal.go index 226fa2de75..520b0a9189 100644 --- a/scim/resource_service_principal.go +++ b/scim/resource_service_principal.go @@ -71,6 +71,11 @@ func (a ServicePrincipalsAPI) Update(servicePrincipalID string, updateRequest Us updateRequest, nil) } +func (a ServicePrincipalsAPI) UpdateEntitlements(servicePrincipalID string, entitlements patchRequest) error { + return a.client.Scim(a.context, http.MethodPatch, + fmt.Sprintf("/preview/scim/v2/ServicePrincipals/%v", servicePrincipalID), entitlements, nil) +} + // Delete will delete the servicePrincipal given the servicePrincipal id func (a ServicePrincipalsAPI) Delete(servicePrincipalID string) error { servicePrincipalPath := fmt.Sprintf("/preview/scim/v2/ServicePrincipals/%v", servicePrincipalID) diff --git a/scim/scim.go b/scim/scim.go index 80aa730afa..d176d1ea7f 100644 --- a/scim/scim.go +++ b/scim/scim.go @@ -65,6 +65,16 @@ func (e entitlements) readIntoData(d *schema.ResourceData) error { return nil } +func generateFullEntitlements() entitlements { + var e entitlements + for _, entitlement := range possibleEntitlements { + e = append(e, ComplexValue{ + Value: entitlement, + }) + } + return e +} + func readEntitlementsFromData(d *schema.ResourceData) entitlements { var e entitlements for _, entitlement := range possibleEntitlements { @@ -154,14 +164,19 @@ type patchRequest struct { func PatchRequest(op, path, value string) patchRequest { o := patchOperation{ - Op: op, - Path: path, + Op: op, + Path: path, + Value: value, } if value != "" { o.Value = []ComplexValue{{Value: value}} } + return PatchRequestComplexValue([]patchOperation{o}) +} + +func PatchRequestComplexValue(operations []patchOperation) patchRequest { return patchRequest{ Schemas: []URN{PatchOp}, - Operations: []patchOperation{o}, + Operations: operations, } } diff --git a/scim/users.go b/scim/users.go index 6f87408472..c4935e0328 100644 --- a/scim/users.go +++ b/scim/users.go @@ -87,3 +87,8 @@ func (a UsersAPI) Delete(userID string) error { userPath := fmt.Sprintf("/preview/scim/v2/Users/%v", userID) return a.client.Scim(a.context, http.MethodDelete, userPath, nil, nil) } + +func (a UsersAPI) UpdateEntitlements(userID string, entitlements patchRequest) error { + return a.client.Scim(a.context, http.MethodPatch, + fmt.Sprintf("/preview/scim/v2/Users/%v", userID), entitlements, nil) +}