From 9c2bf50e4fbfcc19774c38583a02745c727a8d20 Mon Sep 17 00:00:00 2001 From: Miles Yucht Date: Tue, 1 Oct 2024 07:26:13 -0400 Subject: [PATCH] [Fix] Refactor `databricks_permissions` and allow the current user to set their own permissions (#3956) ## Changes In https://github.com/databricks/terraform-provider-databricks/commit/c441517af5ed3f2c6d793d64d5cf5d4e1ca0dc68, a check was added to prevent users from assigning any permissions for themselves in `databricks_permissions`. This unfortunately makes it impossible for users to legitimately assign themselves as the owner of certain resources, such as jobs, if they are currently owned by a different principal. This PR removes this unnecessary restriction. If the user requests to set permissions for an object in a way that is incompatible with the object, such as removing themselves as owner, the failure will be propagated from the backend to the user instead. This does not make any changes to the way the ownership ACLs are set up (e.g. for resources that require an owner, like jobs, if the Terraform user did not specify an owner, the provider will still set the current user as the owner). This PR also refactors the permissions resource substantially. The logic for implementing each resource type's permissions, including the field name, object type and resource-specific modifications, are all colocated with the resource's own definition. The type encapsulating this is called`resourcePermissions`. As a result, the control flow is easier to follow: * Read reads from the API, customizes the response in a resource-specific way, maps the response to the TF state representation, and stores, or marks as deleted if there are no permissions. * Create and Update read the desired permissions from ResourceData, perform some validation, perform resource-specific, then puts the update with an owner if not specified. * Delete resets the ACLs to only admins + resource-specific customizations. Customizations are defined in the permissions/read and permissions/update packages. For update, a mini expression language is defined to support conditional application of customizations. Lastly, this PR also migrates the resource to the Databricks SDK. Fixes #2407. ## Tests This PR adds integration test coverage for the `databricks_permissions` resource for nearly all supported resource types. I wasn't able to run the integration test for `authorization = "passwords"` because password-based auth is deprecated, nor for serving endpoints because of a likely race condition. Integration tests cover all permission levels, and all principal types. Included is special edge case testing for root directory and all registered models. - [ ] `make test` run locally - [x] relevant change in `docs/` folder - [x] covered with integration tests in `internal/acceptance` - [x] relevant acceptance tests are passing - [ ] using Go SDK --- docs/resources/permissions.md | 6 +- exporter/exporter_test.go | 48 +- exporter/importables.go | 4 +- exporter/importables_test.go | 5 +- internal/acceptance/permissions_test.go | 970 ++++++++-- permissions/entity/permissions_entity.go | 18 + permissions/permission_definitions.go | 731 ++++++++ permissions/read/customizers.go | 54 + permissions/resource_permissions.go | 540 ++---- permissions/resource_permissions_test.go | 2110 ++++++++++------------ permissions/update/customizers.go | 97 + 11 files changed, 2833 insertions(+), 1750 deletions(-) create mode 100644 permissions/entity/permissions_entity.go create mode 100644 permissions/permission_definitions.go create mode 100644 permissions/read/customizers.go create mode 100644 permissions/update/customizers.go diff --git a/docs/resources/permissions.md b/docs/resources/permissions.md index ef5e1c4de8..b47a43aba3 100644 --- a/docs/resources/permissions.md +++ b/docs/resources/permissions.md @@ -4,11 +4,11 @@ subcategory: "Security" # databricks_permissions Resource -This resource allows you to generically manage [access control](https://docs.databricks.com/security/access-control/index.html) in Databricks workspace. It would guarantee that only _admins_, _authenticated principal_ and those declared within `access_control` blocks would have specified access. It is not possible to remove management rights from _admins_ group. +This resource allows you to generically manage [access control](https://docs.databricks.com/security/access-control/index.html) in Databricks workspaces. It ensures that only _admins_, _authenticated principal_ and those declared within `access_control` blocks would have specified access. It is not possible to remove management rights from _admins_ group. --> **Note** Configuring this resource for an object will **OVERWRITE** any existing permissions of the same type unless imported, and changes made outside of Terraform will be reset unless the changes are also reflected in the configuration. +-> **Note** This resource is _authoritative_ for permissions on objects. Configuring this resource for an object will **OVERWRITE** any existing permissions of the same type unless imported, and changes made outside of Terraform will be reset. --> **Note** It is not possible to lower permissions for `admins` or your own user anywhere from `CAN_MANAGE` level, so Databricks Terraform Provider [removes](https://github.com/databricks/terraform-provider-databricks/blob/main/permissions/resource_permissions.go#L324-L332) those `access_control` blocks automatically. +-> **Note** It is not possible to lower permissions for `admins`, so Databricks Terraform Provider removes those `access_control` blocks automatically. -> **Note** If multiple permission levels are specified for an identity (e.g. `CAN_RESTART` and `CAN_MANAGE` for a cluster), only the highest level permission is returned and will cause permanent drift. diff --git a/exporter/exporter_test.go b/exporter/exporter_test.go index a027005090..43c6c10916 100644 --- a/exporter/exporter_test.go +++ b/exporter/exporter_test.go @@ -862,7 +862,7 @@ func TestImportingClusters(t *testing.T) { }, { Method: "GET", - Resource: "/api/2.0/permissions/clusters/test1", + Resource: "/api/2.0/permissions/clusters/test1?", Response: getJSONObject("test-data/get-cluster-permissions-test1-response.json"), }, { @@ -913,7 +913,7 @@ func TestImportingClusters(t *testing.T) { }, { Method: "GET", - Resource: "/api/2.0/permissions/clusters/test2", + Resource: "/api/2.0/permissions/clusters/test2?", Response: getJSONObject("test-data/get-cluster-permissions-test2-response.json"), }, { @@ -923,7 +923,7 @@ func TestImportingClusters(t *testing.T) { }, { Method: "GET", - Resource: "/api/2.0/permissions/cluster-policies/123", + Resource: "/api/2.0/permissions/cluster-policies/123?", Response: getJSONObject("test-data/get-cluster-policy-permissions.json"), }, { @@ -949,7 +949,7 @@ func TestImportingClusters(t *testing.T) { }, { Method: "GET", - Resource: "/api/2.0/permissions/clusters/awscluster", + Resource: "/api/2.0/permissions/clusters/awscluster?", Response: getJSONObject("test-data/get-cluster-permissions-awscluster-response.json"), }, { @@ -971,7 +971,7 @@ func TestImportingClusters(t *testing.T) { }, { Method: "GET", - Resource: "/api/2.0/permissions/instance-pools/pool1", + Resource: "/api/2.0/permissions/instance-pools/pool1?", ReuseRequest: true, Response: getJSONObject("test-data/get-job-permissions-14.json"), }, @@ -1089,7 +1089,7 @@ func TestImportingJobs_JobList(t *testing.T) { }, { Method: "GET", - Resource: "/api/2.0/permissions/jobs/14", + Resource: "/api/2.0/permissions/jobs/14?", Response: getJSONObject("test-data/get-job-permissions-14.json"), }, { @@ -1112,7 +1112,7 @@ func TestImportingJobs_JobList(t *testing.T) { }, { Method: "GET", - Resource: "/api/2.0/permissions/instance-pools/pool1", + Resource: "/api/2.0/permissions/instance-pools/pool1?", ReuseRequest: true, Response: getJSONObject("test-data/get-job-permissions-14.json"), }, @@ -1202,7 +1202,7 @@ func TestImportingJobs_JobList(t *testing.T) { }, { Method: "GET", - Resource: "/api/2.0/permissions/cluster-policies/123", + Resource: "/api/2.0/permissions/cluster-policies/123?", Response: getJSONObject("test-data/get-cluster-policy-permissions.json"), }, { @@ -1218,7 +1218,7 @@ func TestImportingJobs_JobList(t *testing.T) { }, { Method: "GET", - Resource: "/api/2.0/permissions/instance-pools/pool1", + Resource: "/api/2.0/permissions/instance-pools/pool1?", ReuseRequest: true, Response: getJSONObject("test-data/get-job-permissions-14.json"), }, @@ -1307,7 +1307,7 @@ func TestImportingJobs_JobListMultiTask(t *testing.T) { }, { Method: "GET", - Resource: "/api/2.0/permissions/jobs/14", + Resource: "/api/2.0/permissions/jobs/14?", Response: getJSONObject("test-data/get-job-permissions-14.json"), ReuseRequest: true, }, @@ -1331,7 +1331,7 @@ func TestImportingJobs_JobListMultiTask(t *testing.T) { }, { Method: "GET", - Resource: "/api/2.0/permissions/instance-pools/pool1", + Resource: "/api/2.0/permissions/instance-pools/pool1?", ReuseRequest: true, Response: getJSONObject("test-data/get-job-permissions-14.json"), }, @@ -1470,7 +1470,7 @@ func TestImportingJobs_JobListMultiTask(t *testing.T) { }, { Method: "GET", - Resource: "/api/2.0/permissions/cluster-policies/123", + Resource: "/api/2.0/permissions/cluster-policies/123?", Response: getJSONObject("test-data/get-cluster-policy-permissions.json"), }, { @@ -1486,7 +1486,7 @@ func TestImportingJobs_JobListMultiTask(t *testing.T) { }, { Method: "GET", - Resource: "/api/2.0/permissions/instance-pools/pool1", + Resource: "/api/2.0/permissions/instance-pools/pool1?", ReuseRequest: true, Response: getJSONObject("test-data/get-job-permissions-14.json"), }, @@ -1777,7 +1777,7 @@ func TestImportingRepos(t *testing.T) { }, { Method: "GET", - Resource: "/api/2.0/permissions/repos/121232342", + Resource: "/api/2.0/permissions/repos/121232342?", Response: getJSONObject("test-data/get-repo-permissions.json"), }, }, @@ -1902,7 +1902,7 @@ func TestImportingSqlObjects(t *testing.T) { }, { Method: "GET", - Resource: "/api/2.0/permissions/directories/4451965692354143", + Resource: "/api/2.0/permissions/directories/4451965692354143?", Response: getJSONObject("test-data/get-directory-permissions.json"), }, { @@ -1933,7 +1933,7 @@ func TestImportingSqlObjects(t *testing.T) { }, { Method: "GET", - Resource: "/api/2.0/permissions/sql/warehouses/f562046bc1272886", + Resource: "/api/2.0/permissions/sql/warehouses/f562046bc1272886?", Response: getJSONObject("test-data/get-sql-endpoint-permissions.json"), }, { @@ -1962,12 +1962,12 @@ func TestImportingSqlObjects(t *testing.T) { }, { Method: "GET", - Resource: "/api/2.0/preview/sql/permissions/queries/16c4f969-eea0-4aad-8f82-03d79b078dcc", + Resource: "/api/2.0/permissions/sql/queries/16c4f969-eea0-4aad-8f82-03d79b078dcc?", Response: getJSONObject("test-data/get-sql-query-permissions.json"), }, { Method: "GET", - Resource: "/api/2.0/preview/sql/permissions/dashboards/9cb0c8f5-6262-4a1f-a741-2181de76028f", + Resource: "/api/2.0/permissions/dbsql-dashboards/9cb0c8f5-6262-4a1f-a741-2181de76028f?", Response: getJSONObject("test-data/get-sql-dashboard-permissions.json"), }, { @@ -1983,7 +1983,7 @@ func TestImportingSqlObjects(t *testing.T) { }, { Method: "GET", - Resource: "/api/2.0/preview/sql/permissions/alerts/3cf91a42-6217-4f3c-a6f0-345d489051b9", + Resource: "/api/2.0/permissions/sql/alerts/3cf91a42-6217-4f3c-a6f0-345d489051b9?", Response: getJSONObject("test-data/get-sql-alert-permissions.json"), }, }, @@ -2039,7 +2039,7 @@ func TestImportingDLTPipelines(t *testing.T) { }, { Method: "GET", - Resource: "/api/2.0/permissions/repos/123", + Resource: "/api/2.0/permissions/repos/123?", Response: getJSONObject("test-data/get-repo-permissions.json"), }, { @@ -2085,12 +2085,12 @@ func TestImportingDLTPipelines(t *testing.T) { }, { Method: "GET", - Resource: "/api/2.0/permissions/pipelines/123", + Resource: "/api/2.0/permissions/pipelines/123?", Response: getJSONObject("test-data/get-pipeline-permissions.json"), }, { Method: "GET", - Resource: "/api/2.0/permissions/notebooks/123", + Resource: "/api/2.0/permissions/notebooks/123?", Response: getJSONObject("test-data/get-notebook-permissions.json"), }, { @@ -2169,7 +2169,7 @@ func TestImportingDLTPipelines(t *testing.T) { }, { Method: "GET", - Resource: "/api/2.0/permissions/files/789", + Resource: "/api/2.0/permissions/files/789?", Response: getJSONObject("test-data/get-workspace-file-permissions.json"), }, }, @@ -2257,7 +2257,7 @@ func TestImportingDLTPipelinesMatchingOnly(t *testing.T) { }, { Method: "GET", - Resource: "/api/2.0/permissions/pipelines/123", + Resource: "/api/2.0/permissions/pipelines/123?", Response: getJSONObject("test-data/get-pipeline-permissions.json"), }, { diff --git a/exporter/importables.go b/exporter/importables.go index d2cb8d0f36..5ea235c335 100644 --- a/exporter/importables.go +++ b/exporter/importables.go @@ -32,7 +32,7 @@ import ( "github.com/databricks/terraform-provider-databricks/common" "github.com/databricks/terraform-provider-databricks/jobs" "github.com/databricks/terraform-provider-databricks/mws" - "github.com/databricks/terraform-provider-databricks/permissions" + "github.com/databricks/terraform-provider-databricks/permissions/entity" tfpipelines "github.com/databricks/terraform-provider-databricks/pipelines" "github.com/databricks/terraform-provider-databricks/repos" tfsettings "github.com/databricks/terraform-provider-databricks/settings" @@ -1184,7 +1184,7 @@ var resourcesMap map[string]importable = map[string]importable{ return (r.Data.Get("access_control.#").(int) == 0) }, Import: func(ic *importContext, r *resource) error { - var permissions permissions.PermissionsEntity + var permissions entity.PermissionsEntity s := ic.Resources["databricks_permissions"].Schema common.DataToStructPointer(r.Data, s, &permissions) for _, ac := range permissions.AccessControlList { diff --git a/exporter/importables_test.go b/exporter/importables_test.go index 544322a745..6bea1a8cf0 100644 --- a/exporter/importables_test.go +++ b/exporter/importables_test.go @@ -25,6 +25,7 @@ import ( "github.com/databricks/terraform-provider-databricks/common" "github.com/databricks/terraform-provider-databricks/jobs" "github.com/databricks/terraform-provider-databricks/permissions" + "github.com/databricks/terraform-provider-databricks/permissions/entity" "github.com/databricks/terraform-provider-databricks/internal/providers/sdkv2" dlt_pipelines "github.com/databricks/terraform-provider-databricks/pipelines" @@ -220,8 +221,8 @@ func TestPermissions(t *testing.T) { assert.Equal(t, "abc", name) d.MarkNewResource() - err := common.StructToData(permissions.PermissionsEntity{ - AccessControlList: []permissions.AccessControlChange{ + err := common.StructToData(entity.PermissionsEntity{ + AccessControlList: []iam.AccessControlRequest{ { UserName: "a", }, diff --git a/internal/acceptance/permissions_test.go b/internal/acceptance/permissions_test.go index 5d803bd451..1386ee9db4 100644 --- a/internal/acceptance/permissions_test.go +++ b/internal/acceptance/permissions_test.go @@ -3,222 +3,836 @@ package acceptance import ( "context" "fmt" + "regexp" + "strconv" "testing" - "github.com/databricks/databricks-sdk-go/client" - "github.com/databricks/databricks-sdk-go/config" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/service/iam" "github.com/databricks/terraform-provider-databricks/common" - "github.com/databricks/terraform-provider-databricks/permissions" - - "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestAccDatabricksPermissionsResourceFullLifecycle(t *testing.T) { - randomName := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) - WorkspaceLevel(t, Step{ - Template: fmt.Sprintf(` - resource "databricks_notebook" "this" { - content_base64 = base64encode("# Databricks notebook source\nprint(1)") - path = "/Beginning/%[1]s/Init" - language = "PYTHON" +// +// databricks_permissions testing support +// + +type permissionSettings struct { + // Name of the SP or group. Must be quoted for a literal string, or can be a reference to another object. + ref string + // If true, the resource will not be created + skipCreation bool + permissionLevel string +} + +type makePermissionsConfig struct { + servicePrincipal []permissionSettings + group []permissionSettings + user []permissionSettings +} + +// Not used today, so this fails linting, but we can uncomment it if needed in the future. +// func servicePrincipalPermissions(permissionLevel ...string) func(*makePermissionsConfig) { +// return func(config *makePermissionsConfig) { +// config.servicePrincipal = simpleSettings(permissionLevel...) +// } +// } + +func groupPermissions(permissionLevel ...string) func(*makePermissionsConfig) { + return func(config *makePermissionsConfig) { + config.group = simpleSettings(permissionLevel...) + } +} + +func userPermissions(permissionLevel ...string) func(*makePermissionsConfig) { + return func(config *makePermissionsConfig) { + config.user = simpleSettings(permissionLevel...) + } +} + +func allPrincipalPermissions(permissionLevel ...string) func(*makePermissionsConfig) { + return func(config *makePermissionsConfig) { + config.servicePrincipal = append(config.servicePrincipal, simpleSettings(permissionLevel...)...) + config.group = append(config.group, simpleSettings(permissionLevel...)...) + config.user = append(config.user, simpleSettings(permissionLevel...)...) + } +} + +func currentPrincipalPermission(t *testing.T, permissionLevel string) func(*makePermissionsConfig) { + settings := permissionSettings{ + permissionLevel: permissionLevel, + ref: "data.databricks_current_user.me.user_name", + skipCreation: true, + } + return func(config *makePermissionsConfig) { + if isGcp(t) { + config.user = append(config.user, settings) + } else { + config.servicePrincipal = append(config.servicePrincipal, settings) } - resource "databricks_group" "first" { - display_name = "First %[1]s" + } +} + +func currentPrincipalType(t *testing.T) string { + if isGcp(t) { + return "user" + } + return "service_principal" +} + +func customPermission(name string, permissionSettings permissionSettings) func(*makePermissionsConfig) { + return func(config *makePermissionsConfig) { + switch name { + case "service_principal": + config.servicePrincipal = append(config.servicePrincipal, permissionSettings) + case "group": + config.group = append(config.group, permissionSettings) + case "user": + config.user = append(config.user, permissionSettings) + default: + panic(fmt.Sprintf("unknown permission type: %s", name)) } - resource "databricks_permissions" "dummy" { - notebook_path = databricks_notebook.this.id + } +} + +func simpleSettings(permissionLevel ...string) []permissionSettings { + var settings []permissionSettings + for _, level := range permissionLevel { + settings = append(settings, permissionSettings{permissionLevel: level}) + } + return settings +} + +func makePermissionsTestStage(idAttribute, idValue string, permissionOptions ...func(*makePermissionsConfig)) string { + config := makePermissionsConfig{} + for _, option := range permissionOptions { + option(&config) + } + var resources string + var accessControlBlocks string + addPermissions := func(permissionSettings []permissionSettings, resourceType, resourceNameAttribute, idAttribute, accessControlAttribute string, getName func(int) string) { + for i, permission := range permissionSettings { + if !permission.skipCreation { + resources += fmt.Sprintf(` + resource "%s" "_%d" { + %s = "permissions-%s" + }`, resourceType, i, resourceNameAttribute, getName(i)) + } + var name string + if permission.ref == "" { + name = fmt.Sprintf("%s._%d.%s", resourceType, i, idAttribute) + } else { + name = permission.ref + } + accessControlBlocks += fmt.Sprintf(` access_control { - group_name = databricks_group.first.display_name - permission_level = "CAN_MANAGE" + %s = %s + permission_level = "%s" + }`, accessControlAttribute, name, permission.permissionLevel) + } + } + addPermissions(config.servicePrincipal, "databricks_service_principal", "display_name", "application_id", "service_principal_name", func(i int) string { + return fmt.Sprintf("{var.STICKY_RANDOM}-%d", i) + }) + addPermissions(config.group, "databricks_group", "display_name", "display_name", "group_name", func(i int) string { + return fmt.Sprintf("{var.STICKY_RANDOM}-%d", i) + }) + addPermissions(config.user, "databricks_user", "user_name", "user_name", "user_name", func(i int) string { + return fmt.Sprintf("{var.STICKY_RANDOM}-%d@databricks.com", i) + }) + return fmt.Sprintf(` + data databricks_current_user me {} + %s + resource "databricks_permissions" "this" { + %s = %s + %s + } + `, resources, idAttribute, idValue, accessControlBlocks) +} + +func assertContainsPermission(t *testing.T, permissions *iam.ObjectPermissions, principalType, name string, permissionLevel iam.PermissionLevel) { + for _, acl := range permissions.AccessControlList { + switch principalType { + case "user": + if acl.UserName == name { + assert.Equal(t, permissionLevel, acl.AllPermissions[0].PermissionLevel) + return + } + case "service_principal": + if acl.ServicePrincipalName == name { + assert.Equal(t, permissionLevel, acl.AllPermissions[0].PermissionLevel) + return + } + case "group": + if acl.GroupName == name { + assert.Equal(t, permissionLevel, acl.AllPermissions[0].PermissionLevel) + return } - }`, randomName), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("databricks_permissions.dummy", - "object_type", "notebook"), - resourceCheck("databricks_permissions.dummy", - func(ctx context.Context, client *common.DatabricksClient, id string) error { - permissions, err := permissions.NewPermissionsAPI(ctx, client).Read(id) - if err != nil { - return err - } - assert.GreaterOrEqual(t, len(permissions.AccessControlList), 1) - return nil - }), - ), - }, Step{ - Template: fmt.Sprintf(` - resource "databricks_notebook" "this" { - content_base64 = base64encode("# Databricks notebook source\nprint(1)") - path = "/Beginning/%[1]s/Init" - language = "PYTHON" } - resource "databricks_group" "first" { - display_name = "First %[1]s" + } + assert.Fail(t, fmt.Sprintf("permission not found for %s %s", principalType, name)) +} + +// +// databricks_permissions acceptance tests +// + +func TestAccPermissions_ClusterPolicy(t *testing.T) { + loadDebugEnvIfRunsFromIDE(t, "workspace") + policyTemplate := ` + resource "databricks_cluster_policy" "this" { + name = "{var.STICKY_RANDOM}" + definition = jsonencode({ + "spark_conf.spark.hadoop.javax.jdo.option.ConnectionURL": { + "type": "fixed", + "value": "jdbc:sqlserver://" + } + }) + }` + + WorkspaceLevel(t, Step{ + Template: policyTemplate + makePermissionsTestStage("cluster_policy_id", "databricks_cluster_policy.this.id", groupPermissions("CAN_USE")), + }, Step{ + Template: policyTemplate + makePermissionsTestStage("cluster_policy_id", "databricks_cluster_policy.this.id", currentPrincipalPermission(t, "CAN_USE"), allPrincipalPermissions("CAN_USE")), + }) +} + +func TestAccPermissions_InstancePool(t *testing.T) { + loadDebugEnvIfRunsFromIDE(t, "workspace") + policyTemplate := ` + data "databricks_node_type" "smallest" { + local_disk = true + } + + resource "databricks_instance_pool" "this" { + instance_pool_name = "{var.STICKY_RANDOM}" + min_idle_instances = 0 + max_capacity = 1 + node_type_id = data.databricks_node_type.smallest.id + idle_instance_autotermination_minutes = 10 + }` + + WorkspaceLevel(t, Step{ + Template: policyTemplate + makePermissionsTestStage("instance_pool_id", "databricks_instance_pool.this.id", groupPermissions("CAN_ATTACH_TO")), + }, Step{ + Template: policyTemplate + makePermissionsTestStage("instance_pool_id", "databricks_instance_pool.this.id", currentPrincipalPermission(t, "CAN_MANAGE"), allPrincipalPermissions("CAN_ATTACH_TO", "CAN_MANAGE")), + }, Step{ + Template: policyTemplate + makePermissionsTestStage("instance_pool_id", "databricks_instance_pool.this.id", currentPrincipalPermission(t, "CAN_ATTACH_TO")), + ExpectError: regexp.MustCompile("cannot remove management permissions for the current user for instance-pool, allowed levels: CAN_MANAGE"), + }) +} + +func TestAccPermissions_Cluster(t *testing.T) { + loadDebugEnvIfRunsFromIDE(t, "workspace") + policyTemplate := ` + +data "databricks_spark_version" "latest" { +} + + resource "databricks_cluster" "this" { + cluster_name = "singlenode-{var.RANDOM}" + spark_version = data.databricks_spark_version.latest.id + instance_pool_id = "{env.TEST_INSTANCE_POOL_ID}" + num_workers = 0 + autotermination_minutes = 10 + spark_conf = { + "spark.databricks.cluster.profile" = "singleNode" + "spark.master" = "local[*]" } - resource "databricks_group" "second" { - display_name = "Second %[1]s" + custom_tags = { + "ResourceClass" = "SingleNode" } - resource "databricks_permissions" "dummy" { - notebook_path = databricks_notebook.this.id - access_control { - group_name = databricks_group.first.display_name - permission_level = "CAN_MANAGE" - } - access_control { - group_name = databricks_group.second.display_name - permission_level = "CAN_RUN" + }` + + WorkspaceLevel(t, Step{ + Template: policyTemplate + makePermissionsTestStage("cluster_id", "databricks_cluster.this.id", groupPermissions("CAN_ATTACH_TO")), + }, Step{ + Template: policyTemplate + makePermissionsTestStage("cluster_id", "databricks_cluster.this.id", currentPrincipalPermission(t, "CAN_MANAGE"), allPrincipalPermissions("CAN_ATTACH_TO", "CAN_RESTART", "CAN_MANAGE")), + }, Step{ + Template: policyTemplate + makePermissionsTestStage("cluster_id", "databricks_cluster.this.id", currentPrincipalPermission(t, "CAN_ATTACH_TO")), + ExpectError: regexp.MustCompile("cannot remove management permissions for the current user for cluster, allowed levels: CAN_MANAGE"), + }) +} + +func TestAccPermissions_Job(t *testing.T) { + loadDebugEnvIfRunsFromIDE(t, "workspace") + template := ` + resource "databricks_job" "this" { + name = "{var.STICKY_RANDOM}" + }` + WorkspaceLevel(t, Step{ + Template: template + makePermissionsTestStage("job_id", "databricks_job.this.id", groupPermissions("CAN_VIEW")), + }, Step{ + Template: template + makePermissionsTestStage("job_id", "databricks_job.this.id", currentPrincipalPermission(t, "IS_OWNER"), allPrincipalPermissions("CAN_VIEW", "CAN_MANAGE_RUN", "CAN_MANAGE")), + }, Step{ + Template: template + makePermissionsTestStage("job_id", "databricks_job.this.id", currentPrincipalPermission(t, "CAN_MANAGE_RUN")), + ExpectError: regexp.MustCompile("cannot remove management permissions for the current user for job, allowed levels: CAN_MANAGE, IS_OWNER"), + }, Step{ + Template: template + makePermissionsTestStage("job_id", "databricks_job.this.id", currentPrincipalPermission(t, "CAN_MANAGE"), userPermissions("IS_OWNER")), + }, Step{ + Template: template, + Check: func(s *terraform.State) error { + w := databricks.Must(databricks.NewWorkspaceClient()) + jobId := s.RootModule().Resources["databricks_job.this"].Primary.ID + permissions, err := w.Permissions.GetByRequestObjectTypeAndRequestObjectId(context.Background(), "jobs", jobId) + assert.NoError(t, err) + idInt, err := strconv.Atoi(jobId) + assert.NoError(t, err) + job, err := w.Jobs.GetByJobId(context.Background(), int64(idInt)) + assert.NoError(t, err) + assertContainsPermission(t, permissions, currentPrincipalType(t), job.CreatorUserName, iam.PermissionLevelIsOwner) + return nil + }, + }) +} + +func TestAccPermissions_Pipeline(t *testing.T) { + loadDebugEnvIfRunsFromIDE(t, "workspace") + policyTemplate := ` + + locals { + name = "{var.STICKY_RANDOM}" + } + + resource "databricks_pipeline" "this" { + name = "${local.name}" + storage = "/test/${local.name}" + + library { + notebook { + path = databricks_notebook.this.path } - }`, randomName), - Check: resourceCheck("databricks_permissions.dummy", - func(ctx context.Context, client *common.DatabricksClient, id string) error { - permissions, err := permissions.NewPermissionsAPI(ctx, client).Read(id) - if err != nil { - return err - } - assert.GreaterOrEqual(t, len(permissions.AccessControlList), 2) - return nil - }), + } + continuous = false + }` + dltNotebookResource + + WorkspaceLevel(t, Step{ + Template: policyTemplate + makePermissionsTestStage("pipeline_id", "databricks_pipeline.this.id", groupPermissions("CAN_VIEW")), + }, Step{ + Template: policyTemplate + makePermissionsTestStage("pipeline_id", "databricks_pipeline.this.id", currentPrincipalPermission(t, "IS_OWNER"), allPrincipalPermissions("CAN_VIEW", "CAN_RUN", "CAN_MANAGE")), + }, Step{ + Template: policyTemplate + makePermissionsTestStage("pipeline_id", "databricks_pipeline.this.id", currentPrincipalPermission(t, "CAN_RUN")), + ExpectError: regexp.MustCompile("cannot remove management permissions for the current user for pipelines, allowed levels: CAN_MANAGE, IS_OWNER"), + }, Step{ + Template: policyTemplate + makePermissionsTestStage("pipeline_id", "databricks_pipeline.this.id", currentPrincipalPermission(t, "CAN_MANAGE"), userPermissions("IS_OWNER"), groupPermissions("CAN_VIEW", "CAN_RUN", "CAN_MANAGE")), + }, Step{ + Template: policyTemplate, + Check: resourceCheck("databricks_pipeline.this", func(ctx context.Context, c *common.DatabricksClient, id string) error { + w, err := c.WorkspaceClient() + assert.NoError(t, err) + pipeline, err := w.Pipelines.GetByPipelineId(context.Background(), id) + assert.NoError(t, err) + permissions, err := w.Permissions.GetByRequestObjectTypeAndRequestObjectId(context.Background(), "pipelines", id) + assert.NoError(t, err) + assertContainsPermission(t, permissions, currentPrincipalType(t), pipeline.CreatorUserName, iam.PermissionLevelIsOwner) + return nil + }), + }) +} + +func TestAccPermissions_Notebook_Path(t *testing.T) { + loadDebugEnvIfRunsFromIDE(t, "workspace") + notebookTemplate := ` + resource "databricks_directory" "this" { + path = "/permissions_test/{var.STICKY_RANDOM}" + } + resource "databricks_notebook" "this" { + source = "{var.CWD}/../../storage/testdata/tf-test-python.py" + path = "${databricks_directory.this.path}/test_notebook" + }` + WorkspaceLevel(t, Step{ + Template: notebookTemplate + makePermissionsTestStage("notebook_path", "databricks_notebook.this.id", groupPermissions("CAN_RUN")), + }, Step{ + Template: notebookTemplate + makePermissionsTestStage("notebook_path", "databricks_notebook.this.id", currentPrincipalPermission(t, "CAN_MANAGE"), allPrincipalPermissions("CAN_RUN", "CAN_READ", "CAN_EDIT", "CAN_MANAGE")), + }, Step{ + // The current user can be removed from permissions since they inherit permissions from the directory they created. + Template: notebookTemplate + makePermissionsTestStage("notebook_path", "databricks_notebook.this.id", allPrincipalPermissions("CAN_RUN", "CAN_READ", "CAN_EDIT", "CAN_MANAGE")), + }, Step{ + Template: notebookTemplate + makePermissionsTestStage("notebook_path", "databricks_notebook.this.id", currentPrincipalPermission(t, "CAN_READ")), + ExpectError: regexp.MustCompile("cannot remove management permissions for the current user for notebook, allowed levels: CAN_MANAGE"), + }) +} + +func TestAccPermissions_Notebook_Id(t *testing.T) { + loadDebugEnvIfRunsFromIDE(t, "workspace") + notebookTemplate := ` + resource "databricks_directory" "this" { + path = "/permissions_test/{var.STICKY_RANDOM}" + } + resource "databricks_notebook" "this" { + source = "{var.CWD}/../../storage/testdata/tf-test-python.py" + path = "${databricks_directory.this.path}/test_notebook" + }` + WorkspaceLevel(t, Step{ + Template: notebookTemplate + makePermissionsTestStage("notebook_id", "databricks_notebook.this.object_id", groupPermissions("CAN_RUN")), + }, Step{ + Template: notebookTemplate + makePermissionsTestStage("notebook_id", "databricks_notebook.this.object_id", currentPrincipalPermission(t, "CAN_MANAGE"), allPrincipalPermissions("CAN_RUN", "CAN_READ", "CAN_EDIT", "CAN_MANAGE")), + }, Step{ + // The current user can be removed from permissions since they inherit permissions from the directory they created. + Template: notebookTemplate + makePermissionsTestStage("notebook_id", "databricks_notebook.this.object_id", allPrincipalPermissions("CAN_RUN", "CAN_READ", "CAN_EDIT", "CAN_MANAGE")), + }, Step{ + Template: notebookTemplate + makePermissionsTestStage("notebook_id", "databricks_notebook.this.object_id", currentPrincipalPermission(t, "CAN_READ")), + ExpectError: regexp.MustCompile("cannot remove management permissions for the current user for notebook, allowed levels: CAN_MANAGE"), }) } -func TestAccDatabricksReposPermissionsResourceFullLifecycle(t *testing.T) { - randomName := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) +func TestAccPermissions_Directory_Path(t *testing.T) { + loadDebugEnvIfRunsFromIDE(t, "workspace") + directoryTemplate := ` + resource "databricks_directory" "this" { + path = "/permissions_test/{var.STICKY_RANDOM}" + }` WorkspaceLevel(t, Step{ - Template: fmt.Sprintf(` + Template: directoryTemplate + makePermissionsTestStage("directory_path", "databricks_directory.this.id", groupPermissions("CAN_RUN")), + }, Step{ + Template: directoryTemplate + makePermissionsTestStage("directory_path", "databricks_directory.this.id", currentPrincipalPermission(t, "CAN_MANAGE"), allPrincipalPermissions("CAN_RUN", "CAN_READ", "CAN_EDIT", "CAN_MANAGE")), + }, Step{ + // The current user can be removed from permissions since they inherit permissions from the directory they created. + Template: directoryTemplate + makePermissionsTestStage("directory_path", "databricks_directory.this.id", allPrincipalPermissions("CAN_RUN", "CAN_READ", "CAN_EDIT", "CAN_MANAGE")), + }, Step{ + Template: directoryTemplate + makePermissionsTestStage("directory_path", "databricks_directory.this.id", currentPrincipalPermission(t, "CAN_READ")), + ExpectError: regexp.MustCompile("cannot remove management permissions for the current user for directory, allowed levels: CAN_MANAGE"), + }) +} + +func TestAccPermissions_Directory_Id(t *testing.T) { + loadDebugEnvIfRunsFromIDE(t, "workspace") + directoryTemplate := ` + resource "databricks_directory" "this" { + path = "/permissions_test/{var.STICKY_RANDOM}" + }` + WorkspaceLevel(t, Step{ + Template: directoryTemplate + makePermissionsTestStage("directory_id", "databricks_directory.this.object_id", groupPermissions("CAN_RUN")), + }, Step{ + Template: directoryTemplate + makePermissionsTestStage("directory_id", "databricks_directory.this.object_id", currentPrincipalPermission(t, "CAN_MANAGE"), allPrincipalPermissions("CAN_RUN", "CAN_READ", "CAN_EDIT", "CAN_MANAGE")), + }, Step{ + // The current user can be removed from permissions since they inherit permissions from the directory they created. + Template: directoryTemplate + makePermissionsTestStage("directory_id", "databricks_directory.this.object_id", allPrincipalPermissions("CAN_RUN", "CAN_READ", "CAN_EDIT", "CAN_MANAGE")), + }, Step{ + Template: directoryTemplate + makePermissionsTestStage("directory_id", "databricks_directory.this.object_id", currentPrincipalPermission(t, "CAN_READ")), + ExpectError: regexp.MustCompile("cannot remove management permissions for the current user for directory, allowed levels: CAN_MANAGE"), + }) +} + +// This test exercises both by ID and by path permissions for the root directory. Testing them +// concurrently would result in a race condition. +func TestAccPermissions_Directory_RootDirectoryCorrectlyHandlesAdminUsers(t *testing.T) { + loadDebugEnvIfRunsFromIDE(t, "workspace") + expectedAclAfterDeletion := []iam.AccessControlResponse{ + { + GroupName: "admins", + AllPermissions: []iam.Permission{ + { + PermissionLevel: iam.PermissionLevelCanManage, + ForceSendFields: []string{"Inherited", "PermissionLevel"}, + }, + }, + ForceSendFields: []string{"GroupName"}, + }, + } + WorkspaceLevel(t, Step{ + Template: makePermissionsTestStage("directory_id", "\"0\"", groupPermissions("CAN_RUN")), + }, Step{ + Template: `data databricks_current_user me {}`, + Check: func(s *terraform.State) error { + w := databricks.Must(databricks.NewWorkspaceClient()) + permissions, err := w.Permissions.GetByRequestObjectTypeAndRequestObjectId(context.Background(), "directories", "0") + assert.NoError(t, err) + assert.Equal(t, expectedAclAfterDeletion, permissions.AccessControlList) + return nil + }, + }, Step{ + Template: makePermissionsTestStage("directory_path", "\"/\"", userPermissions("CAN_RUN")), + }, Step{ + Template: `data databricks_current_user me {}`, + Check: func(s *terraform.State) error { + w := databricks.Must(databricks.NewWorkspaceClient()) + permissions, err := w.Permissions.GetByRequestObjectTypeAndRequestObjectId(context.Background(), "directories", "0") + assert.NoError(t, err) + assert.Equal(t, expectedAclAfterDeletion, permissions.AccessControlList) + return nil + }, + }) +} + +func TestAccPermissions_WorkspaceFile_Path(t *testing.T) { + loadDebugEnvIfRunsFromIDE(t, "workspace") + workspaceFile := ` + resource "databricks_directory" "this" { + path = "/permissions_test/{var.STICKY_RANDOM}" + } + resource "databricks_workspace_file" "this" { + source = "{var.CWD}/../../storage/testdata/tf-test-python.py" + path = "${databricks_directory.this.path}/test_notebook" + }` + WorkspaceLevel(t, Step{ + Template: workspaceFile + makePermissionsTestStage("workspace_file_path", "databricks_workspace_file.this.id", groupPermissions("CAN_RUN")), + }, Step{ + Template: workspaceFile + makePermissionsTestStage("workspace_file_path", "databricks_workspace_file.this.id", currentPrincipalPermission(t, "CAN_MANAGE"), allPrincipalPermissions("CAN_RUN", "CAN_READ", "CAN_EDIT", "CAN_MANAGE")), + }, Step{ + // The current user can be removed from permissions since they inherit permissions from the directory they created. + Template: workspaceFile + makePermissionsTestStage("workspace_file_path", "databricks_workspace_file.this.id", allPrincipalPermissions("CAN_RUN", "CAN_READ", "CAN_EDIT", "CAN_MANAGE")), + }, Step{ + Template: workspaceFile + makePermissionsTestStage("workspace_file_path", "databricks_workspace_file.this.id", currentPrincipalPermission(t, "CAN_READ")), + ExpectError: regexp.MustCompile("cannot remove management permissions for the current user for file, allowed levels: CAN_MANAGE"), + }) +} + +func TestAccPermissions_WorkspaceFile_Id(t *testing.T) { + loadDebugEnvIfRunsFromIDE(t, "workspace") + workspaceFile := ` + resource "databricks_directory" "this" { + path = "/permissions_test/{var.STICKY_RANDOM}" + } + resource "databricks_workspace_file" "this" { + source = "{var.CWD}/../../storage/testdata/tf-test-python.py" + path = "${databricks_directory.this.path}/test_notebook" + }` + WorkspaceLevel(t, Step{ + Template: workspaceFile + makePermissionsTestStage("workspace_file_id", "databricks_workspace_file.this.object_id", groupPermissions("CAN_RUN")), + }, Step{ + Template: workspaceFile + makePermissionsTestStage("workspace_file_id", "databricks_workspace_file.this.object_id", currentPrincipalPermission(t, "CAN_MANAGE"), allPrincipalPermissions("CAN_RUN", "CAN_READ", "CAN_EDIT", "CAN_MANAGE")), + }, Step{ + // The current user can be removed from permissions since they inherit permissions from the directory they created. + Template: workspaceFile + makePermissionsTestStage("workspace_file_id", "databricks_workspace_file.this.object_id", allPrincipalPermissions("CAN_RUN", "CAN_READ", "CAN_EDIT", "CAN_MANAGE")), + }, Step{ + Template: workspaceFile + makePermissionsTestStage("workspace_file_id", "databricks_workspace_file.this.object_id", currentPrincipalPermission(t, "CAN_READ")), + ExpectError: regexp.MustCompile("cannot remove management permissions for the current user for file, allowed levels: CAN_MANAGE"), + }) +} + +func TestAccPermissions_Repo_Id(t *testing.T) { + loadDebugEnvIfRunsFromIDE(t, "workspace") + template := ` resource "databricks_repo" "this" { url = "https://github.com/databrickslabs/tempo.git" - path = "/Repos/terraform-tests/tempo-%[1]s" + path = "/Repos/terraform-tests/tempo-{var.STICKY_RANDOM}" } - resource "databricks_group" "first" { - display_name = "First %[1]s" - } - resource "databricks_group" "second" { - display_name = "Second %[1]s" + ` + WorkspaceLevel(t, Step{ + Template: template + makePermissionsTestStage("repo_id", "databricks_repo.this.id", groupPermissions("CAN_MANAGE", "CAN_READ")), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("databricks_permissions.this", "object_type", "repo"), + func(s *terraform.State) error { + w := databricks.Must(databricks.NewWorkspaceClient()) + repoId := s.RootModule().Resources["databricks_repo.this"].Primary.ID + permissions, err := w.Permissions.GetByRequestObjectTypeAndRequestObjectId(context.Background(), "repos", repoId) + assert.NoError(t, err) + group1Name := s.RootModule().Resources["databricks_group._0"].Primary.Attributes["display_name"] + assertContainsPermission(t, permissions, "group", group1Name, iam.PermissionLevelCanManage) + group2Name := s.RootModule().Resources["databricks_group._1"].Primary.Attributes["display_name"] + assertContainsPermission(t, permissions, "group", group2Name, iam.PermissionLevelCanRead) + return nil + }, + ), + }, Step{ + Template: template + makePermissionsTestStage("repo_id", "databricks_repo.this.id", currentPrincipalPermission(t, "CAN_MANAGE"), allPrincipalPermissions("CAN_READ", "CAN_MANAGE", "CAN_RUN", "CAN_EDIT")), + }, Step{ + Template: template + makePermissionsTestStage("repo_id", "databricks_repo.this.id", allPrincipalPermissions("CAN_READ", "CAN_MANAGE", "CAN_RUN", "CAN_EDIT")), + }, Step{ + Template: template + makePermissionsTestStage("repo_id", "databricks_repo.this.id", currentPrincipalPermission(t, "CAN_READ")), + ExpectError: regexp.MustCompile("cannot remove management permissions for the current user for repo, allowed levels: CAN_MANAGE"), + }) +} + +func TestAccPermissions_Repo_Path(t *testing.T) { + loadDebugEnvIfRunsFromIDE(t, "workspace") + template := ` + resource "databricks_repo" "this" { + url = "https://github.com/databrickslabs/tempo.git" + path = "/Repos/terraform-tests/tempo-{var.STICKY_RANDOM}" } - resource "databricks_permissions" "dummy" { - repo_path = databricks_repo.this.path - access_control { - group_name = databricks_group.first.display_name - permission_level = "CAN_MANAGE" - } - access_control { - group_name = databricks_group.second.display_name - permission_level = "CAN_RUN" - } - }`, randomName), + ` + WorkspaceLevel(t, Step{ + Template: template + makePermissionsTestStage("repo_path", "databricks_repo.this.path", groupPermissions("CAN_MANAGE", "CAN_RUN")), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("databricks_permissions.dummy", - "object_type", "repo"), - resourceCheck("databricks_permissions.dummy", - func(ctx context.Context, client *common.DatabricksClient, id string) error { - permissions, err := permissions.NewPermissionsAPI(ctx, client).Read(id) - if err != nil { - return err - } - assert.GreaterOrEqual(t, len(permissions.AccessControlList), 2) - return nil - }), + resource.TestCheckResourceAttr("databricks_permissions.this", "object_type", "repo"), + func(s *terraform.State) error { + w := databricks.Must(databricks.NewWorkspaceClient()) + repoId := s.RootModule().Resources["databricks_repo.this"].Primary.ID + permissions, err := w.Permissions.GetByRequestObjectTypeAndRequestObjectId(context.Background(), "repos", repoId) + assert.NoError(t, err) + group1Name := s.RootModule().Resources["databricks_group._0"].Primary.Attributes["display_name"] + assertContainsPermission(t, permissions, "group", group1Name, iam.PermissionLevelCanManage) + group2Name := s.RootModule().Resources["databricks_group._1"].Primary.Attributes["display_name"] + assertContainsPermission(t, permissions, "group", group2Name, iam.PermissionLevelCanRun) + return nil + }, ), + }, Step{ + Template: template + makePermissionsTestStage("repo_id", "databricks_repo.this.id", currentPrincipalPermission(t, "CAN_MANAGE"), allPrincipalPermissions("CAN_READ", "CAN_MANAGE", "CAN_RUN", "CAN_EDIT")), + }, Step{ + Template: template + makePermissionsTestStage("repo_id", "databricks_repo.this.id", allPrincipalPermissions("CAN_READ", "CAN_MANAGE", "CAN_RUN", "CAN_EDIT")), + }, Step{ + Template: template + makePermissionsTestStage("repo_id", "databricks_repo.this.id", currentPrincipalPermission(t, "CAN_READ")), + ExpectError: regexp.MustCompile("cannot remove management permissions for the current user for repo, allowed levels: CAN_MANAGE"), }) } -func TestAccDatabricksPermissionsForSqlWarehouses(t *testing.T) { - // Random string to annotate newly created groups - randomName := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) +func TestAccPermissions_Authorization_Passwords(t *testing.T) { + skipf(t)("ACLs for passwords are disabled on testing workspaces") + loadDebugEnvIfRunsFromIDE(t, "workspace") + WorkspaceLevel(t, Step{ + Template: makePermissionsTestStage("authorization", "\"passwords\"", groupPermissions("CAN_USE")), + }, Step{ + Template: makePermissionsTestStage("authorization", "\"passwords\"", customPermission("group", permissionSettings{ref: `"admins"`, skipCreation: true, permissionLevel: "CAN_USE"})), + }) +} - // Create a client to query the permissions API - c, err := client.New(&config.Config{}) - require.NoError(t, err) - permissionsClient := permissions.NewPermissionsAPI(context.Background(), &common.DatabricksClient{DatabricksClient: c}) +func TestAccPermissions_Authorization_Tokens(t *testing.T) { + loadDebugEnvIfRunsFromIDE(t, "workspace") + WorkspaceLevel(t, Step{ + Template: makePermissionsTestStage("authorization", "\"tokens\"", groupPermissions("CAN_USE")), + }, Step{ + Template: makePermissionsTestStage("authorization", "\"tokens\"", customPermission("group", permissionSettings{ref: `"users"`, skipCreation: true, permissionLevel: "CAN_USE"})), + }, Step{ + // Template needs to be non-empty + Template: "data databricks_current_user me {}", + Check: func(s *terraform.State) error { + w := databricks.Must(databricks.NewWorkspaceClient()) + permissions, err := w.Permissions.GetByRequestObjectTypeAndRequestObjectId(context.Background(), "authorization", "tokens") + assert.NoError(t, err) + assert.Len(t, permissions.AccessControlList, 1) + assert.Equal(t, iam.AccessControlResponse{ + GroupName: "admins", + AllPermissions: []iam.Permission{ + { + PermissionLevel: iam.PermissionLevelCanManage, + ForceSendFields: []string{"Inherited", "PermissionLevel"}, + }, + }, + ForceSendFields: []string{"GroupName"}, + }, permissions.AccessControlList[0]) + return nil + }, + }) +} + +func TestAccPermissions_SqlWarehouses(t *testing.T) { + loadDebugEnvIfRunsFromIDE(t, "workspace") + sqlWarehouseTemplate := ` + resource "databricks_sql_endpoint" "this" { + name = "{var.STICKY_RANDOM}" + cluster_size = "2X-Small" + }` + WorkspaceLevel(t, Step{ + Template: sqlWarehouseTemplate + makePermissionsTestStage("sql_endpoint_id", "databricks_sql_endpoint.this.id", groupPermissions("CAN_USE")), + }, Step{ + Template: sqlWarehouseTemplate + makePermissionsTestStage("sql_endpoint_id", "databricks_sql_endpoint.this.id", currentPrincipalPermission(t, "IS_OWNER"), allPrincipalPermissions("CAN_USE", "CAN_MANAGE", "CAN_MONITOR")), + // Note: ideally we could test making a new user/SP the owner of the warehouse, but the new user + // needs cluster creation permissions, and the SCIM API doesn't provide get-after-put consistency, + // so this would introduce flakiness. + // }, Step{ + // Template: sqlWarehouseTemplate + makePermissionsTestStage("sql_endpoint_id", "databricks_sql_endpoint.this.id", currentPrincipalPermission(t, "CAN_MANAGE"), servicePrincipalPermissions("IS_OWNER")) + ` + // resource databricks_entitlements "this" { + // application_id = databricks_service_principal._0.application_id + // allow_cluster_create = true + // } + // `, + }, Step{ + Template: sqlWarehouseTemplate + makePermissionsTestStage("sql_endpoint_id", "databricks_sql_endpoint.this.id", currentPrincipalPermission(t, "CAN_USE")), + ExpectError: regexp.MustCompile("cannot remove management permissions for the current user for warehouses, allowed levels: CAN_MANAGE, IS_OWNER"), + }, Step{ + Template: sqlWarehouseTemplate, + Check: func(s *terraform.State) error { + w := databricks.Must(databricks.NewWorkspaceClient()) + id := s.RootModule().Resources["databricks_sql_endpoint.this"].Primary.ID + warehouse, err := w.Warehouses.GetById(context.Background(), id) + assert.NoError(t, err) + permissions, err := w.Permissions.GetByRequestObjectTypeAndRequestObjectId(context.Background(), "warehouses", id) + assert.NoError(t, err) + assertContainsPermission(t, permissions, currentPrincipalType(t), warehouse.CreatorName, iam.PermissionLevelIsOwner) + return nil + }, + }) +} - // Validates export attribute "object_type" for the permissions resource - // is set to warehouses - checkObjectType := resource.TestCheckResourceAttr("databricks_permissions.this", - "object_type", "warehouses") +func TestAccPermissions_SqlDashboard(t *testing.T) { + loadDebugEnvIfRunsFromIDE(t, "workspace") + dashboardTemplate := ` + resource "databricks_sql_dashboard" "this" { + name = "{var.STICKY_RANDOM}" + }` + WorkspaceLevel(t, Step{ + Template: dashboardTemplate + makePermissionsTestStage("sql_dashboard_id", "databricks_sql_dashboard.this.id", groupPermissions("CAN_VIEW")), + }, Step{ + Template: dashboardTemplate + makePermissionsTestStage("sql_dashboard_id", "databricks_sql_dashboard.this.id", currentPrincipalPermission(t, "CAN_VIEW")), + ExpectError: regexp.MustCompile("cannot remove management permissions for the current user for dashboard, allowed levels: CAN_MANAGE"), + }, Step{ + Template: dashboardTemplate + makePermissionsTestStage("sql_dashboard_id", "databricks_sql_dashboard.this.id", currentPrincipalPermission(t, "CAN_MANAGE"), allPrincipalPermissions("CAN_VIEW", "CAN_READ", "CAN_EDIT", "CAN_RUN", "CAN_MANAGE")), + }) +} - // Asserts value of a permission level for a group - assertPermissionLevel := func(t *testing.T, permissionId, groupName, permissionLevel string) { - // Query permissions on warehouse - warehousePermissions, err := permissionsClient.Read(permissionId) - require.NoError(t, err) +func TestAccPermissions_SqlAlert(t *testing.T) { + loadDebugEnvIfRunsFromIDE(t, "workspace") + alertTemplate := ` + resource "databricks_sql_query" "this" { + name = "{var.STICKY_RANDOM}-query" + query = "SELECT 1 AS p1, 2 as p2" + data_source_id = "{env.TEST_DEFAULT_WAREHOUSE_DATASOURCE_ID}" + } + resource "databricks_sql_alert" "this" { + name = "{var.STICKY_RANDOM}-alert" + query_id = databricks_sql_query.this.id + options { + column = "p1" + op = ">=" + value = "3" + muted = false + } + }` + WorkspaceLevel(t, Step{ + Template: alertTemplate + makePermissionsTestStage("sql_alert_id", "databricks_sql_alert.this.id", groupPermissions("CAN_VIEW")), + }, Step{ + Template: alertTemplate + makePermissionsTestStage("sql_alert_id", "databricks_sql_alert.this.id", currentPrincipalPermission(t, "CAN_MANAGE"), groupPermissions("CAN_VIEW", "CAN_EDIT", "CAN_RUN", "CAN_MANAGE")), + }, Step{ + Template: alertTemplate + makePermissionsTestStage("sql_alert_id", "databricks_sql_alert.this.id", currentPrincipalPermission(t, "CAN_VIEW"), groupPermissions("CAN_VIEW", "CAN_EDIT", "CAN_RUN", "CAN_MANAGE")), + ExpectError: regexp.MustCompile("cannot remove management permissions for the current user for alert, allowed levels: CAN_MANAGE"), + }) +} - // Assert expected permission level is present - assert.Contains(t, warehousePermissions.AccessControlList, permissions.AccessControl{ - GroupName: groupName, - AllPermissions: []permissions.Permission{ - { - PermissionLevel: permissionLevel, - }, - }, - }) - } +func TestAccPermissions_SqlQuery(t *testing.T) { + loadDebugEnvIfRunsFromIDE(t, "workspace") + queryTemplate := ` + resource "databricks_sql_query" "this" { + name = "{var.STICKY_RANDOM}-query" + query = "SELECT 1 AS p1, 2 as p2" + data_source_id = "{env.TEST_DEFAULT_WAREHOUSE_DATASOURCE_ID}" + }` + WorkspaceLevel(t, Step{ + Template: queryTemplate + makePermissionsTestStage("sql_query_id", "databricks_sql_query.this.id", groupPermissions("CAN_VIEW")), + }, Step{ + Template: queryTemplate + makePermissionsTestStage("sql_query_id", "databricks_sql_query.this.id", currentPrincipalPermission(t, "CAN_MANAGE"), groupPermissions("CAN_VIEW", "CAN_EDIT", "CAN_RUN", "CAN_MANAGE")), + }, Step{ + Template: queryTemplate + makePermissionsTestStage("sql_query_id", "databricks_sql_query.this.id", currentPrincipalPermission(t, "CAN_VIEW"), groupPermissions("CAN_VIEW", "CAN_EDIT", "CAN_RUN", "CAN_MANAGE")), + ExpectError: regexp.MustCompile("cannot remove management permissions for the current user for query, allowed levels: CAN_MANAGE"), + }) +} - // Get permission ID from the terraform state - getPermissionId := func(s *terraform.State) string { - resourcePermission, ok := s.RootModule().Resources["databricks_permissions.this"] - require.True(t, ok, "could not find permissions resource: databricks_permissions.this") - return resourcePermission.Primary.ID - } +func TestAccPermissions_Dashboard(t *testing.T) { + loadDebugEnvIfRunsFromIDE(t, "workspace") + dashboardTemplate := ` + resource "databricks_directory" "this" { + path = "/permissions_test/{var.STICKY_RANDOM}" + } + resource "databricks_dashboard" "dashboard" { + display_name = "TF New Dashboard" + warehouse_id = "{env.TEST_DEFAULT_WAREHOUSE_ID}" + parent_path = databricks_directory.this.path + } + ` + WorkspaceLevel(t, Step{ + Template: dashboardTemplate + makePermissionsTestStage("dashboard_id", "databricks_dashboard.dashboard.id", groupPermissions("CAN_READ")), + }, Step{ + Template: dashboardTemplate + makePermissionsTestStage("dashboard_id", "databricks_dashboard.dashboard.id", currentPrincipalPermission(t, "CAN_MANAGE"), groupPermissions("CAN_READ", "CAN_EDIT", "CAN_RUN", "CAN_MANAGE")), + }, Step{ + Template: dashboardTemplate + makePermissionsTestStage("dashboard_id", "databricks_dashboard.dashboard.id", currentPrincipalPermission(t, "CAN_READ"), groupPermissions("CAN_READ", "CAN_EDIT", "CAN_RUN", "CAN_MANAGE")), + ExpectError: regexp.MustCompile("cannot remove management permissions for the current user for dashboard, allowed levels: CAN_MANAGE"), + }) +} - // Configuration for step 1 of the test. Create a databricks_permissions - // resources, assigning a group CAN_MANAGE permission to the warehouse. - config1 := fmt.Sprintf(` - resource "databricks_group" "one" { - display_name = "test-warehouse-permission-one-%s" - } - resource "databricks_permissions" "this" { - sql_endpoint_id = "{env.TEST_DEFAULT_WAREHOUSE_ID}" - access_control { - group_name = databricks_group.one.display_name - permission_level = "CAN_MANAGE" +func TestAccPermissions_Experiment(t *testing.T) { + loadDebugEnvIfRunsFromIDE(t, "workspace") + experimentTemplate := ` + resource "databricks_directory" "this" { + path = "/permissions_test/{var.STICKY_RANDOM}" } - }`, randomName) + resource "databricks_mlflow_experiment" "this" { + name = "${databricks_directory.this.path}/experiment" + }` + WorkspaceLevel(t, Step{ + Template: experimentTemplate + makePermissionsTestStage("experiment_id", "databricks_mlflow_experiment.this.id", groupPermissions("CAN_READ")), + }, Step{ + Template: experimentTemplate + makePermissionsTestStage("experiment_id", "databricks_mlflow_experiment.this.id", currentPrincipalPermission(t, "CAN_MANAGE"), groupPermissions("CAN_READ", "CAN_EDIT", "CAN_MANAGE")), + }, Step{ + Template: experimentTemplate + makePermissionsTestStage("experiment_id", "databricks_mlflow_experiment.this.id", currentPrincipalPermission(t, "CAN_READ"), groupPermissions("CAN_READ", "CAN_EDIT", "CAN_MANAGE")), + ExpectError: regexp.MustCompile("cannot remove management permissions for the current user for mlflowExperiment, allowed levels: CAN_MANAGE"), + }) +} - // Configuration for step 2 of the test. Create another group and update - // permissions to CAN_USE for the second group - config2 := fmt.Sprintf(` - resource "databricks_group" "one" { - display_name = "test-warehouse-permission-one-%[1]s" - } - resource "databricks_group" "two" { - display_name = "test-warehouse-permission-two-%[1]s" +func TestAccPermissions_RegisteredModel(t *testing.T) { + loadDebugEnvIfRunsFromIDE(t, "workspace") + modelTemplate := ` + resource "databricks_mlflow_model" "m1" { + name = "tf-{var.STICKY_RANDOM}" + description = "tf-{var.STICKY_RANDOM} description" } - resource "databricks_permissions" "this" { - sql_endpoint_id = "{env.TEST_DEFAULT_WAREHOUSE_ID}" - access_control { - group_name = databricks_group.one.display_name - permission_level = "CAN_MANAGE" - } - access_control { - group_name = databricks_group.two.display_name - permission_level = "CAN_USE" - } - }`, randomName) - - WorkspaceLevel(t, - Step{ - Template: config1, - Check: resource.ComposeTestCheckFunc( - checkObjectType, - func(s *terraform.State) error { - id := getPermissionId(s) - assertPermissionLevel(t, id, "test-warehouse-permission-one-"+randomName, "CAN_MANAGE") - return nil + ` + WorkspaceLevel(t, Step{ + Template: modelTemplate + makePermissionsTestStage("registered_model_id", "databricks_mlflow_model.m1.registered_model_id", groupPermissions("CAN_READ")), + }, Step{ + Template: modelTemplate + makePermissionsTestStage("registered_model_id", "databricks_mlflow_model.m1.registered_model_id", currentPrincipalPermission(t, "CAN_MANAGE"), groupPermissions("CAN_READ", "CAN_EDIT", "CAN_MANAGE_STAGING_VERSIONS", "CAN_MANAGE_PRODUCTION_VERSIONS", "CAN_MANAGE")), + }, Step{ + Template: modelTemplate + makePermissionsTestStage("registered_model_id", "databricks_mlflow_model.m1.registered_model_id", currentPrincipalPermission(t, "CAN_READ"), groupPermissions("CAN_READ", "CAN_EDIT", "CAN_MANAGE_STAGING_VERSIONS", "CAN_MANAGE_PRODUCTION_VERSIONS", "CAN_MANAGE")), + ExpectError: regexp.MustCompile("cannot remove management permissions for the current user for registered-model, allowed levels: CAN_MANAGE"), + }) +} + +func TestAccPermissions_RegisteredModel_Root(t *testing.T) { + loadDebugEnvIfRunsFromIDE(t, "workspace") + WorkspaceLevel(t, Step{ + Template: makePermissionsTestStage("registered_model_id", "\"root\"", groupPermissions("CAN_READ")), + }, Step{ + Template: makePermissionsTestStage("registered_model_id", "\"root\"", currentPrincipalPermission(t, "CAN_MANAGE"), groupPermissions("CAN_READ", "CAN_EDIT", "CAN_MANAGE_STAGING_VERSIONS", "CAN_MANAGE_PRODUCTION_VERSIONS", "CAN_MANAGE")), + }, Step{ + Template: makePermissionsTestStage("registered_model_id", "\"root\"", currentPrincipalPermission(t, "CAN_READ"), groupPermissions("CAN_READ", "CAN_EDIT", "CAN_MANAGE_STAGING_VERSIONS", "CAN_MANAGE_PRODUCTION_VERSIONS", "CAN_MANAGE")), + ExpectError: regexp.MustCompile("cannot remove management permissions for the current user for registered-model, allowed levels: CAN_MANAGE"), + }, Step{ + Template: "data databricks_current_user me {}", + Check: func(s *terraform.State) error { + w := databricks.Must(databricks.NewWorkspaceClient()) + permissions, err := w.Permissions.GetByRequestObjectTypeAndRequestObjectId(context.Background(), "registered-models", "root") + assert.NoError(t, err) + assert.Len(t, permissions.AccessControlList, 1) + assert.Equal(t, iam.AccessControlResponse{ + GroupName: "admins", + AllPermissions: []iam.Permission{ + { + PermissionLevel: iam.PermissionLevelCanManage, + ForceSendFields: []string{"Inherited", "PermissionLevel"}, + }, }, - ), + ForceSendFields: []string{"GroupName"}, + }, permissions.AccessControlList[0]) + return nil }, - Step{ - Template: config2, - Check: func(s *terraform.State) error { - id := getPermissionId(s) - assertPermissionLevel(t, id, "test-warehouse-permission-one-"+randomName, "CAN_MANAGE") - assertPermissionLevel(t, id, "test-warehouse-permission-two-"+randomName, "CAN_USE") - return nil - }, - }, - ) + }) +} + +func TestAccPermissions_ServingEndpoint(t *testing.T) { + loadDebugEnvIfRunsFromIDE(t, "workspace") + if isGcp(t) { + skipf(t)("Serving endpoints are not supported on GCP") + } + endpointTemplate := ` + resource "databricks_model_serving" "endpoint" { + name = "{var.STICKY_RANDOM}" + config { + served_models { + name = "prod_model" + model_name = "experiment-fixture-model" + model_version = "1" + workload_size = "Small" + scale_to_zero_enabled = true + } + traffic_config { + routes { + served_model_name = "prod_model" + traffic_percentage = 100 + } + } + } + }` + WorkspaceLevel(t, Step{ + Template: endpointTemplate + makePermissionsTestStage("serving_endpoint_id", "databricks_model_serving.endpoint.serving_endpoint_id", groupPermissions("CAN_VIEW")), + // Updating a serving endpoint seems to be flaky, so we'll only test that we can't remove management permissions for the current user. + // }, Step{ + // Template: endpointTemplate + makePermissionsTestStage("serving_endpoint_id", "databricks_model_serving.endpoint.id", currentPrincipalPermission(t, "CAN_MANAGE"), groupPermissions("CAN_VIEW", "CAN_QUERY", "CAN_MANAGE")), + }, Step{ + Template: endpointTemplate + makePermissionsTestStage("serving_endpoint_id", "databricks_model_serving.endpoint.serving_endpoint_id", currentPrincipalPermission(t, "CAN_VIEW"), groupPermissions("CAN_VIEW", "CAN_QUERY", "CAN_MANAGE")), + ExpectError: regexp.MustCompile("cannot remove management permissions for the current user for serving-endpoint, allowed levels: CAN_MANAGE"), + }) } diff --git a/permissions/entity/permissions_entity.go b/permissions/entity/permissions_entity.go new file mode 100644 index 0000000000..e8c1f4b067 --- /dev/null +++ b/permissions/entity/permissions_entity.go @@ -0,0 +1,18 @@ +package entity + +import "github.com/databricks/databricks-sdk-go/service/iam" + +// PermissionsEntity is the one used for resource metadata +type PermissionsEntity struct { + ObjectType string `json:"object_type,omitempty" tf:"computed"` + AccessControlList []iam.AccessControlRequest `json:"access_control" tf:"slice_set"` +} + +func (p PermissionsEntity) ContainsUserOrServicePrincipal(name string) bool { + for _, ac := range p.AccessControlList { + if ac.UserName == name || ac.ServicePrincipalName == name { + return true + } + } + return false +} diff --git a/permissions/permission_definitions.go b/permissions/permission_definitions.go new file mode 100644 index 0000000000..fbc9158517 --- /dev/null +++ b/permissions/permission_definitions.go @@ -0,0 +1,731 @@ +package permissions + +import ( + "context" + "fmt" + "sort" + "strconv" + "strings" + + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/service/iam" + "github.com/databricks/terraform-provider-databricks/common" + "github.com/databricks/terraform-provider-databricks/permissions/entity" + "github.com/databricks/terraform-provider-databricks/permissions/read" + "github.com/databricks/terraform-provider-databricks/permissions/update" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// resourcePermissions captures all the information needed to manage permissions for a given object type. +type resourcePermissions struct { + // Mandatory Fields + + // The attribute name that users configure with the ID of the object to manage + // e.g. "cluster_id" for a cluster + field string + // The object type to use in the Permissions API, e.g. "cluster" for a cluster. + objectType string + // The name of the object in the ID of the TF resource, e.g. "clusters" for a cluster, + // where the ID would be /clusters/. This should also match the prefix of the + // object ID in the API response, unless idMatcher is set. + requestObjectType string + // The allowed permission levels for this object type and its options. + allowedPermissionLevels map[string]permissionLevelOptions + + // ID Remapping Options + + // Returns the object ID for the given user-specified ID. This is necessary because permissions for + // some objects are done by path, whereas others are by ID. Those by path need to be converted to the + // internal object ID before being stored in the state. If not specified, the default ID is "//". + idRetriever func(ctx context.Context, w *databricks.WorkspaceClient, id string) (string, error) + // By default, a resourcePermissions can be retrieved based on the structure of the ID, as described above. + // If this function is set, it will be used to determine whether the ID matches this resource type. + idMatcher func(id string) bool + // A custom matcher to check whether a given ID matches this resource type. + // Most resources can be determined by looking at the attribute name used to configure the permission, but + // tokens & passwords are special cases where the resource type is determined by the value of this attribute. + stateMatcher func(id string) bool + + // Behavior Options and Customizations + + // The alternative name of the "path" attribute for this resource. E.g. "workspace_file_path" for a file. + // If not set, default is "_path". + pathVariant string + // If true, the provider will allow the user to configure the "admins" group for this resource type. Otherwise, + // validation will fail if the user tries to configure the "admins" group, and admin configurations in API + // responses will be ignored. This should only be set to true for the "authorization = passwords" resource. + allowConfiguringAdmins bool + // Customizers when handling permission resource creation and update. + // + // Most resources that have a CAN_MANAGE permission level should add update.AddCurrentUserAsManage to this list + // to ensure that the user applying the template always has management permissions on the underlying resource. + updateAclCustomizers []update.ACLCustomizer + // Customizers when handling permission resource deletion. + // + // Most resources that have a CAN_MANAGE permission level should add update.AddCurrentUserAsManage to this list + // to ensure that the user applying the template always has management permissions on the underlying resource. + deleteAclCustomizers []update.ACLCustomizer + // Customizers when handling permission resource read. + // + // Resources for which admins inherit permissions should add removeAdminPermissionsCustomizer to this list. This + // prevents the admin group from being included in the permissions when reading the state. + readAclCustomizers []read.ACLCustomizer + + // Returns the creator of the object. Used when deleting databricks_permissions resources, when the + // creator of the object is restored as the owner. + fetchObjectCreator func(ctx context.Context, w *databricks.WorkspaceClient, objectID string) (string, error) +} + +// getAllowedPermissionLevels returns the list of permission levels that are allowed for this resource type. +func (p resourcePermissions) getAllowedPermissionLevels(includeNonManagementPermissions bool) []string { + levels := make([]string, 0, len(p.allowedPermissionLevels)) + for level := range p.allowedPermissionLevels { + if includeNonManagementPermissions || p.allowedPermissionLevels[level].isManagementPermission { + levels = append(levels, level) + } + } + sort.Strings(levels) + return levels +} + +// resourceStatus captures the status of a resource with permissions. If the resource doesn't exist, +// the provider will not try to update its permissions. Otherwise, the creator will be returned if +// it can be determined for the given resource type. +type resourceStatus struct { + exists bool + creator string +} + +// getObjectStatus returns the creator of the object and whether the object exists. If the object creator cannot be determined for this +// resource type, an empty string is returned. Resources without fetchObjectCreator are assumed to exist and have an unknown creator. +func (p resourcePermissions) getObjectStatus(ctx context.Context, w *databricks.WorkspaceClient, objectID string) (resourceStatus, error) { + if p.fetchObjectCreator != nil { + creator, err := p.fetchObjectCreator(ctx, w, objectID) + if err != nil { + return resourceStatus{}, err + } + return resourceStatus{exists: creator != "", creator: creator}, nil + } + return resourceStatus{exists: true, creator: ""}, nil +} + +// getPathVariant returns the name of the path attribute for this resource type. +func (p resourcePermissions) getPathVariant() string { + if p.pathVariant != "" { + return p.pathVariant + } + return p.objectType + "_path" +} + +// validate checks that the user is not trying to set permissions for the admin group or remove their own management permissions. +func (p resourcePermissions) validate(ctx context.Context, entity entity.PermissionsEntity, currentUsername string) error { + for _, change := range entity.AccessControlList { + // Prevent users from setting permissions for admins. + if change.GroupName == "admins" && !p.allowConfiguringAdmins { + return fmt.Errorf("it is not possible to modify admin permissions for %s resources", p.objectType) + } + // Check that the user is preventing themselves from managing the object + level := p.allowedPermissionLevels[string(change.PermissionLevel)] + if (change.UserName == currentUsername || change.ServicePrincipalName == currentUsername) && !level.isManagementPermission { + allowedLevelsForCurrentUser := p.getAllowedPermissionLevels(false) + return fmt.Errorf("cannot remove management permissions for the current user for %s, allowed levels: %s", p.objectType, strings.Join(allowedLevelsForCurrentUser, ", ")) + } + + if level.deprecated != "" { + tflog.Debug(ctx, fmt.Sprintf("the permission level %s for %s is deprecated: %s", change.PermissionLevel, p.objectType, level.deprecated)) + } + } + return nil +} + +// getID returns the object ID for the given user-specified ID. +func (p resourcePermissions) getID(ctx context.Context, w *databricks.WorkspaceClient, id string) (string, error) { + var err error + if p.idRetriever != nil { + id, err = p.idRetriever(ctx, w, id) + if err != nil { + return "", err + } + } + return fmt.Sprintf("/%s/%s", p.requestObjectType, id), nil +} + +// prepareForUpdate prepares the access control list for an update request by calling all update customizers. +func (p resourcePermissions) prepareForUpdate(objectID string, e entity.PermissionsEntity, currentUser string) (entity.PermissionsEntity, error) { + cachedCurrentUser := func() (string, error) { return currentUser, nil } + ctx := update.ACLCustomizerContext{ + GetCurrentUser: cachedCurrentUser, + GetId: func() string { return objectID }, + } + var err error + for _, customizer := range p.updateAclCustomizers { + e.AccessControlList, err = customizer(ctx, e.AccessControlList) + if err != nil { + return entity.PermissionsEntity{}, err + } + } + return e, nil +} + +// prepareForDelete prepares the access control list for a delete request by calling all delete customizers. +func (p resourcePermissions) prepareForDelete(objectACL *iam.ObjectPermissions, getCurrentUser func() (string, error)) ([]iam.AccessControlRequest, error) { + accl := make([]iam.AccessControlRequest, 0, len(objectACL.AccessControlList)) + // By default, only admins have access to a resource when databricks_permissions for that resource are deleted. + for _, acl := range objectACL.AccessControlList { + if acl.GroupName != "admins" { + continue + } + for _, permission := range acl.AllPermissions { + if !permission.Inherited { + // keep everything direct for admin group + accl = append(accl, iam.AccessControlRequest{ + GroupName: acl.GroupName, + PermissionLevel: permission.PermissionLevel, + }) + break + } + } + } + ctx := update.ACLCustomizerContext{ + GetCurrentUser: getCurrentUser, + GetId: func() string { return objectACL.ObjectId }, + } + var err error + for _, customizer := range p.deleteAclCustomizers { + accl, err = customizer(ctx, accl) + if err != nil { + return nil, err + } + } + return accl, nil +} + +// prepareResponse prepares the access control list for a read response by calling all read customizers. +// +// If the user does not include an access_control block for themselves, it will not be included in the state. This +// prevents diffs when the applying user is not included in the access_control block for the resource but is +// added by update.AddCurrentUserAsManage. +// +// Read customizers are able to access the current state of the object in order to customize the response accordingly. +// For example, the SQL API previously used CAN_VIEW for read-only permission, but the GA API uses CAN_READ. Users may +// have CAN_VIEW in their resource configuration, so the read customizer will rewrite the response from CAN_READ to +// CAN_VIEW to match the user's configuration. +func (p resourcePermissions) prepareResponse(objectID string, objectACL *iam.ObjectPermissions, existing entity.PermissionsEntity, me string) (entity.PermissionsEntity, error) { + ctx := read.ACLCustomizerContext{ + GetId: func() string { return objectID }, + GetExistingPermissionsEntity: func() entity.PermissionsEntity { return existing }, + } + acl := *objectACL + for _, customizer := range p.readAclCustomizers { + acl = customizer(ctx, acl) + } + if acl.ObjectType != p.objectType { + return entity.PermissionsEntity{}, fmt.Errorf("expected object type %s, got %s", p.objectType, objectACL.ObjectType) + } + entity := entity.PermissionsEntity{} + for _, accessControl := range acl.AccessControlList { + // If the user doesn't include an access_control block for themselves, do not include it in the state. + // On create/update, the provider will automatically include the current user in the access_control block + // for appropriate resources. Otherwise, it must be included in state to prevent configuration drift. + if me == accessControl.UserName || me == accessControl.ServicePrincipalName { + if !existing.ContainsUserOrServicePrincipal(me) { + continue + } + } + // Skip admin permissions for resources where users are not allowed to explicitly configure them. + if accessControl.GroupName == "admins" && !p.allowConfiguringAdmins { + continue + } + for _, permission := range accessControl.AllPermissions { + // Inherited permissions can be ignored, as they are not set by the user. + if permission.Inherited { + continue + } + entity.AccessControlList = append(entity.AccessControlList, iam.AccessControlRequest{ + GroupName: accessControl.GroupName, + UserName: accessControl.UserName, + ServicePrincipalName: accessControl.ServicePrincipalName, + PermissionLevel: permission.PermissionLevel, + }) + } + } + return entity, nil +} + +// addOwnerPermissionIfNeeded adds the owner permission to the object ACL if the owner permission is allowed and not already set. +func (p resourcePermissions) addOwnerPermissionIfNeeded(objectACL []iam.AccessControlRequest, ownerOpt string) []iam.AccessControlRequest { + _, ok := p.allowedPermissionLevels["IS_OWNER"] + if !ok { + return objectACL + } + + for _, acl := range objectACL { + if acl.PermissionLevel == "IS_OWNER" { + return objectACL + } + } + + return append(objectACL, iam.AccessControlRequest{ + UserName: ownerOpt, + PermissionLevel: "IS_OWNER", + }) +} + +// permissionLevelOptions indicates the properties of a permissions level. Today, the only property +// is whether the current user can set the permission level for themselves. +type permissionLevelOptions struct { + // Whether users with this permission level are allowed to manage the resource. + // For some resources where ACLs don't define who can manage the resource, this might be unintuitive, + // e.g. all cluster policies permissions are considered management permissions because cluster policy + // ACLs don't define who can manage the cluster policy. + isManagementPermission bool + + // If non-empty, the permission level is deprecated. The string is a message to display to the user when + // this permission level is used. + deprecated string +} + +func getResourcePermissionsFromId(id string) (resourcePermissions, error) { + idParts := strings.Split(id, "/") + objectType := strings.Join(idParts[1:len(idParts)-1], "/") + for _, mapping := range allResourcePermissions() { + if mapping.idMatcher != nil { + if mapping.idMatcher(id) { + return mapping, nil + } + continue + } + if mapping.requestObjectType == objectType { + return mapping, nil + } + } + return resourcePermissions{}, fmt.Errorf("resource type for %s not found", id) +} + +// getResourcePermissionsFromState returns the resourcePermissions for the given state. +func getResourcePermissionsFromState(d interface{ GetOk(string) (any, bool) }) (resourcePermissions, string, error) { + allPermissions := allResourcePermissions() + for _, mapping := range allPermissions { + if v, ok := d.GetOk(mapping.field); ok { + id := v.(string) + if mapping.stateMatcher != nil && !mapping.stateMatcher(id) { + continue + } + return mapping, id, nil + } + } + allFields := make([]string, 0, len(allPermissions)) + seen := make(map[string]struct{}) + for _, mapping := range allPermissions { + if _, ok := seen[mapping.field]; ok { + continue + } + seen[mapping.field] = struct{}{} + allFields = append(allFields, mapping.field) + } + sort.Strings(allFields) + return resourcePermissions{}, "", fmt.Errorf("at least one type of resource identifier must be set; allowed fields: %s", strings.Join(allFields, ", ")) +} + +// getResourcePermissionsForObjectAcl returns the resourcePermissions for the given ObjectAclApiResponse. +// allResourcePermissions is the list of all resource types that can be managed by the databricks_permissions resource. +func allResourcePermissions() []resourcePermissions { + PATH := func(ctx context.Context, w *databricks.WorkspaceClient, path string) (string, error) { + info, err := w.Workspace.GetStatusByPath(ctx, path) + if err != nil { + return "", fmt.Errorf("cannot load path %s: %s", path, err) + } + return strconv.FormatInt(info.ObjectId, 10), nil + } + rewriteCanViewToCanRead := update.RewritePermissions(map[iam.PermissionLevel]iam.PermissionLevel{ + iam.PermissionLevelCanView: iam.PermissionLevelCanRead, + }) + rewriteCanReadToCanView := read.RewritePermissions(map[iam.PermissionLevel]iam.PermissionLevel{ + iam.PermissionLevelCanRead: iam.PermissionLevelCanView, + }) + return []resourcePermissions{ + { + field: "cluster_policy_id", + objectType: "cluster-policy", + requestObjectType: "cluster-policies", + allowedPermissionLevels: map[string]permissionLevelOptions{ + "CAN_USE": {isManagementPermission: true}, + }, + }, + { + field: "instance_pool_id", + objectType: "instance-pool", + requestObjectType: "instance-pools", + allowedPermissionLevels: map[string]permissionLevelOptions{ + "CAN_ATTACH_TO": {isManagementPermission: false}, + "CAN_MANAGE": {isManagementPermission: true}, + }, + updateAclCustomizers: []update.ACLCustomizer{update.AddCurrentUserAsManage}, + deleteAclCustomizers: []update.ACLCustomizer{update.AddCurrentUserAsManage}, + }, + { + field: "cluster_id", + objectType: "cluster", + requestObjectType: "clusters", + allowedPermissionLevels: map[string]permissionLevelOptions{ + "CAN_ATTACH_TO": {isManagementPermission: false}, + "CAN_RESTART": {isManagementPermission: false}, + "CAN_MANAGE": {isManagementPermission: true}, + }, + updateAclCustomizers: []update.ACLCustomizer{update.AddCurrentUserAsManage}, + deleteAclCustomizers: []update.ACLCustomizer{update.AddCurrentUserAsManage}, + }, + { + field: "pipeline_id", + objectType: "pipelines", + requestObjectType: "pipelines", + allowedPermissionLevels: map[string]permissionLevelOptions{ + "CAN_VIEW": {isManagementPermission: false}, + "CAN_RUN": {isManagementPermission: false}, + "CAN_MANAGE": {isManagementPermission: true}, + "IS_OWNER": {isManagementPermission: true}, + }, + fetchObjectCreator: func(ctx context.Context, w *databricks.WorkspaceClient, objectID string) (string, error) { + pipeline, err := w.Pipelines.GetByPipelineId(ctx, strings.ReplaceAll(objectID, "/pipelines/", "")) + if err != nil { + return "", common.IgnoreNotFoundError(err) + } + return pipeline.CreatorUserName, nil + }, + }, + { + field: "job_id", + objectType: "job", + requestObjectType: "jobs", + allowedPermissionLevels: map[string]permissionLevelOptions{ + "CAN_VIEW": {isManagementPermission: false}, + "CAN_MANAGE_RUN": {isManagementPermission: false}, + "IS_OWNER": {isManagementPermission: true}, + "CAN_MANAGE": {isManagementPermission: true}, + }, + fetchObjectCreator: func(ctx context.Context, w *databricks.WorkspaceClient, objectID string) (string, error) { + jobId, err := strconv.ParseInt(strings.ReplaceAll(objectID, "/jobs/", ""), 10, 64) + if err != nil { + return "", err + } + job, err := w.Jobs.GetByJobId(ctx, jobId) + if err != nil { + return "", common.IgnoreNotFoundError(err) + } + return job.CreatorUserName, nil + }, + }, + { + field: "notebook_id", + objectType: "notebook", + requestObjectType: "notebooks", + allowedPermissionLevels: map[string]permissionLevelOptions{ + "CAN_READ": {isManagementPermission: false}, + "CAN_RUN": {isManagementPermission: false}, + "CAN_EDIT": {isManagementPermission: false}, + "CAN_MANAGE": {isManagementPermission: true}, + }, + }, + { + field: "notebook_path", + objectType: "notebook", + requestObjectType: "notebooks", + allowedPermissionLevels: map[string]permissionLevelOptions{ + "CAN_READ": {isManagementPermission: false}, + "CAN_RUN": {isManagementPermission: false}, + "CAN_EDIT": {isManagementPermission: false}, + "CAN_MANAGE": {isManagementPermission: true}, + }, + idRetriever: PATH, + }, + { + field: "directory_id", + objectType: "directory", + requestObjectType: "directories", + allowedPermissionLevels: map[string]permissionLevelOptions{ + "CAN_READ": {isManagementPermission: false}, + "CAN_RUN": {isManagementPermission: false}, + "CAN_EDIT": {isManagementPermission: false}, + "CAN_MANAGE": {isManagementPermission: true}, + }, + updateAclCustomizers: []update.ACLCustomizer{ + update.If(update.ObjectIdMatches("/directories/0"), update.AddAdmin), + }, + deleteAclCustomizers: []update.ACLCustomizer{ + update.If(update.ObjectIdMatches("/directories/0"), update.AddAdmin), + }, + }, + { + field: "directory_path", + objectType: "directory", + requestObjectType: "directories", + allowedPermissionLevels: map[string]permissionLevelOptions{ + "CAN_READ": {isManagementPermission: false}, + "CAN_RUN": {isManagementPermission: false}, + "CAN_EDIT": {isManagementPermission: false}, + "CAN_MANAGE": {isManagementPermission: true}, + }, + idRetriever: PATH, + updateAclCustomizers: []update.ACLCustomizer{ + update.If(update.ObjectIdMatches("/directories/0"), update.AddAdmin), + }, + deleteAclCustomizers: []update.ACLCustomizer{ + update.If(update.ObjectIdMatches("/directories/0"), update.AddAdmin), + }, + }, + { + field: "workspace_file_id", + objectType: "file", + requestObjectType: "files", + allowedPermissionLevels: map[string]permissionLevelOptions{ + "CAN_READ": {isManagementPermission: false}, + "CAN_RUN": {isManagementPermission: false}, + "CAN_EDIT": {isManagementPermission: false}, + "CAN_MANAGE": {isManagementPermission: true}, + }, + pathVariant: "workspace_file_path", + }, + { + field: "workspace_file_path", + objectType: "file", + requestObjectType: "files", + allowedPermissionLevels: map[string]permissionLevelOptions{ + "CAN_READ": {isManagementPermission: false}, + "CAN_RUN": {isManagementPermission: false}, + "CAN_EDIT": {isManagementPermission: false}, + "CAN_MANAGE": {isManagementPermission: true}, + }, + idRetriever: PATH, + pathVariant: "workspace_file_path", + }, + { + field: "repo_id", + objectType: "repo", + requestObjectType: "repos", + allowedPermissionLevels: map[string]permissionLevelOptions{ + "CAN_READ": {isManagementPermission: false}, + "CAN_RUN": {isManagementPermission: false}, + "CAN_EDIT": {isManagementPermission: false}, + "CAN_MANAGE": {isManagementPermission: true}, + }, + }, + { + field: "repo_path", + objectType: "repo", + requestObjectType: "repos", + allowedPermissionLevels: map[string]permissionLevelOptions{ + "CAN_READ": {isManagementPermission: false}, + "CAN_RUN": {isManagementPermission: false}, + "CAN_EDIT": {isManagementPermission: false}, + "CAN_MANAGE": {isManagementPermission: true}, + }, + idRetriever: PATH, + }, + { + field: "authorization", + objectType: "tokens", + requestObjectType: "authorization", + stateMatcher: func(id string) bool { + return id == "tokens" + }, + idMatcher: func(id string) bool { + return id == "/authorization/tokens" + }, + allowedPermissionLevels: map[string]permissionLevelOptions{ + "CAN_USE": {isManagementPermission: true}, + "CAN_MANAGE": {isManagementPermission: true}, + }, + updateAclCustomizers: []update.ACLCustomizer{ + update.If(update.ObjectIdMatches("/authorization/tokens"), update.AddAdmin), + }, + }, + { + field: "authorization", + objectType: "passwords", + requestObjectType: "authorization", + stateMatcher: func(id string) bool { + return id == "passwords" + }, + idMatcher: func(id string) bool { + return id == "/authorization/passwords" + }, + allowedPermissionLevels: map[string]permissionLevelOptions{ + "CAN_USE": {isManagementPermission: true}, + }, + allowConfiguringAdmins: true, + }, + { + field: "sql_endpoint_id", + objectType: "warehouses", + requestObjectType: "sql/warehouses", + allowedPermissionLevels: map[string]permissionLevelOptions{ + "CAN_USE": {isManagementPermission: false}, + "CAN_MANAGE": {isManagementPermission: true}, + "CAN_MONITOR": {isManagementPermission: false}, + "IS_OWNER": {isManagementPermission: true}, + }, + updateAclCustomizers: []update.ACLCustomizer{update.AddCurrentUserAsManage}, + deleteAclCustomizers: []update.ACLCustomizer{update.AddCurrentUserAsManage}, + fetchObjectCreator: func(ctx context.Context, w *databricks.WorkspaceClient, objectID string) (string, error) { + warehouse, err := w.Warehouses.GetById(ctx, strings.ReplaceAll(objectID, "/sql/warehouses/", "")) + if err != nil { + return "", common.IgnoreNotFoundError(err) + } + return warehouse.CreatorName, nil + }, + }, + { + field: "sql_dashboard_id", + objectType: "dashboard", + requestObjectType: "dbsql-dashboards", + allowedPermissionLevels: map[string]permissionLevelOptions{ + "CAN_EDIT": {isManagementPermission: false}, + "CAN_RUN": {isManagementPermission: false}, + "CAN_MANAGE": {isManagementPermission: true}, + "CAN_READ": {isManagementPermission: false}, + // This was part of the original SQL permissions API but was replaced by CAN_READ in the GA API. + "CAN_VIEW": { + isManagementPermission: false, + deprecated: "use CAN_READ instead", + }, + }, + idMatcher: func(id string) bool { + return strings.HasPrefix(id, "/dbsql-dashboards/") || strings.HasPrefix(id, "/sql/dashboards/") + }, + updateAclCustomizers: []update.ACLCustomizer{ + update.AddCurrentUserAsManage, + rewriteCanViewToCanRead, + }, + deleteAclCustomizers: []update.ACLCustomizer{ + update.AddCurrentUserAsManage, + rewriteCanViewToCanRead, + }, + readAclCustomizers: []read.ACLCustomizer{ + rewriteCanReadToCanView, + func(ctx read.ACLCustomizerContext, objectAcls iam.ObjectPermissions) iam.ObjectPermissions { + // The object type in the new API is "dbsql-dashboard", but for compatibility this should + // be "dashboard" in the state. + objectAcls.ObjectType = "dashboard" + return objectAcls + }, + }, + }, + { + field: "sql_alert_id", + objectType: "alert", + requestObjectType: "sql/alerts", + allowedPermissionLevels: map[string]permissionLevelOptions{ + "CAN_EDIT": {isManagementPermission: false}, + "CAN_RUN": {isManagementPermission: false}, + "CAN_MANAGE": {isManagementPermission: true}, + "CAN_READ": {isManagementPermission: false}, + // This was part of the original SQL permissions API but was replaced by CAN_READ in the GA API. + // It should eventually be deprecated. + "CAN_VIEW": { + isManagementPermission: false, + deprecated: "use CAN_READ instead", + }, + }, + updateAclCustomizers: []update.ACLCustomizer{ + update.AddCurrentUserAsManage, + rewriteCanViewToCanRead, + }, + deleteAclCustomizers: []update.ACLCustomizer{ + update.AddCurrentUserAsManage, + rewriteCanViewToCanRead, + }, + readAclCustomizers: []read.ACLCustomizer{ + rewriteCanReadToCanView, + }, + }, + { + field: "sql_query_id", + objectType: "query", + requestObjectType: "sql/queries", + allowedPermissionLevels: map[string]permissionLevelOptions{ + "CAN_EDIT": {isManagementPermission: false}, + "CAN_RUN": {isManagementPermission: false}, + "CAN_MANAGE": {isManagementPermission: true}, + "CAN_READ": {isManagementPermission: false}, + // This was part of the original SQL permissions API but was replaced by CAN_READ in the GA API. + // It should eventually be deprecated. + "CAN_VIEW": { + isManagementPermission: false, + deprecated: "use CAN_READ instead", + }, + }, + updateAclCustomizers: []update.ACLCustomizer{ + update.AddCurrentUserAsManage, + rewriteCanViewToCanRead, + }, + deleteAclCustomizers: []update.ACLCustomizer{ + update.AddCurrentUserAsManage, + rewriteCanViewToCanRead, + }, + readAclCustomizers: []read.ACLCustomizer{ + rewriteCanReadToCanView, + }, + }, + { + field: "dashboard_id", + objectType: "dashboard", + requestObjectType: "dashboards", + allowedPermissionLevels: map[string]permissionLevelOptions{ + "CAN_EDIT": {isManagementPermission: false}, + "CAN_RUN": {isManagementPermission: false}, + "CAN_MANAGE": {isManagementPermission: true}, + "CAN_READ": {isManagementPermission: false}, + }, + readAclCustomizers: []read.ACLCustomizer{ + func(ctx read.ACLCustomizerContext, objectAcls iam.ObjectPermissions) iam.ObjectPermissions { + if strings.HasPrefix(objectAcls.ObjectId, "/dashboards/") { + // workaround for inconsistent API response returning object ID of file in the workspace + objectAcls.ObjectId = ctx.GetId() + } + return objectAcls + }, + }, + }, + { + field: "experiment_id", + objectType: "mlflowExperiment", + requestObjectType: "experiments", + allowedPermissionLevels: map[string]permissionLevelOptions{ + "CAN_READ": {isManagementPermission: false}, + "CAN_EDIT": {isManagementPermission: false}, + "CAN_MANAGE": {isManagementPermission: true}, + }, + }, + { + field: "registered_model_id", + objectType: "registered-model", + requestObjectType: "registered-models", + allowedPermissionLevels: map[string]permissionLevelOptions{ + "CAN_READ": {isManagementPermission: false}, + "CAN_EDIT": {isManagementPermission: false}, + "CAN_MANAGE_STAGING_VERSIONS": {isManagementPermission: false}, + "CAN_MANAGE_PRODUCTION_VERSIONS": {isManagementPermission: false}, + "CAN_MANAGE": {isManagementPermission: true}, + }, + updateAclCustomizers: []update.ACLCustomizer{ + update.AddCurrentUserAsManage, + update.If(update.ObjectIdMatches("/registered-models/root"), update.AddAdmin), + }, + deleteAclCustomizers: []update.ACLCustomizer{ + update.If(update.Not(update.ObjectIdMatches("/registered-models/root")), update.AddCurrentUserAsManage), + }, + }, + { + field: "serving_endpoint_id", + objectType: "serving-endpoint", + requestObjectType: "serving-endpoints", + allowedPermissionLevels: map[string]permissionLevelOptions{ + "CAN_VIEW": {isManagementPermission: false}, + "CAN_QUERY": {isManagementPermission: false}, + "CAN_MANAGE": {isManagementPermission: true}, + }, + updateAclCustomizers: []update.ACLCustomizer{update.AddCurrentUserAsManage}, + deleteAclCustomizers: []update.ACLCustomizer{update.AddCurrentUserAsManage}, + }, + } +} diff --git a/permissions/read/customizers.go b/permissions/read/customizers.go new file mode 100644 index 0000000000..3cee278fbb --- /dev/null +++ b/permissions/read/customizers.go @@ -0,0 +1,54 @@ +package read + +import ( + "github.com/databricks/databricks-sdk-go/service/iam" + "github.com/databricks/terraform-provider-databricks/permissions/entity" +) + +// Context that is available to aclReadCustomizer implementations. +type ACLCustomizerContext struct { + GetId func() string + GetExistingPermissionsEntity func() entity.PermissionsEntity +} + +// ACLCustomizer is a function that modifies the access control list of an object after it is read. +type ACLCustomizer func(ctx ACLCustomizerContext, objectAcls iam.ObjectPermissions) iam.ObjectPermissions + +// Rewrites the permission level of the access control list of an object after it is read. +// This is done only for resources in state where the permission level is equal to the replacement value +// in the mapping. For example, the permissons endpoint used to use the "CAN_VIEW" permission level for +// read-only access, but this was changed to "CAN_READ". Users who previously used "CAN_VIEW" should not +// be forced to change to "CAN_READ". This customizer will rewrite "CAN_READ" to "CAN_VIEW" when the +// user-specified value is CAN_VIEW and the API response is CAN_READ. +func RewritePermissions(mapping map[iam.PermissionLevel]iam.PermissionLevel) ACLCustomizer { + findOriginalAcl := func(new iam.AccessControlResponse, original entity.PermissionsEntity) (iam.AccessControlRequest, bool) { + for _, old := range original.AccessControlList { + if new.GroupName != "" && old.GroupName == new.GroupName { + return old, true + } + if new.UserName != "" && old.UserName == new.UserName { + return old, true + } + if new.ServicePrincipalName != "" && old.ServicePrincipalName == new.ServicePrincipalName { + return old, true + } + } + return iam.AccessControlRequest{}, false + } + return func(ctx ACLCustomizerContext, acl iam.ObjectPermissions) iam.ObjectPermissions { + original := ctx.GetExistingPermissionsEntity() + for i := range acl.AccessControlList { + inState, found := findOriginalAcl(acl.AccessControlList[i], original) + for j := range acl.AccessControlList[i].AllPermissions { + // If the original permission level is remapped to a replacement level, and the permission level + // in state is equal to the replacement level, we rewrite it to the replacement level. + original := acl.AccessControlList[i].AllPermissions[j].PermissionLevel + replacement, ok := mapping[original] + if ok && found && inState.PermissionLevel == replacement { + acl.AccessControlList[i].AllPermissions[j].PermissionLevel = replacement + } + } + } + return acl + } +} diff --git a/permissions/resource_permissions.go b/permissions/resource_permissions.go index fb0b24eebf..6eb138fb80 100644 --- a/permissions/resource_permissions.go +++ b/permissions/resource_permissions.go @@ -4,96 +4,17 @@ import ( "context" "errors" "fmt" - "log" "path" - "strconv" "strings" - "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/apierr" + "github.com/databricks/databricks-sdk-go/service/iam" "github.com/databricks/terraform-provider-databricks/common" + "github.com/databricks/terraform-provider-databricks/permissions/entity" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) -// ObjectACL is a structure to generically describe access control -type ObjectACL struct { - ObjectID string `json:"object_id,omitempty"` - ObjectType string `json:"object_type,omitempty"` - AccessControlList []AccessControl `json:"access_control_list"` -} - -// AccessControl is a structure to describe user/group permissions -type AccessControl struct { - UserName string `json:"user_name,omitempty"` - GroupName string `json:"group_name,omitempty"` - ServicePrincipalName string `json:"service_principal_name,omitempty"` - AllPermissions []Permission `json:"all_permissions,omitempty"` - - // SQLA entities don't use the `all_permissions` nesting, but rather a simple - // top level string with the permission level when retrieving permissions. - PermissionLevel string `json:"permission_level,omitempty"` -} - -func (ac AccessControl) toAccessControlChange() (AccessControlChange, bool) { - for _, permission := range ac.AllPermissions { - if permission.Inherited { - continue - } - return AccessControlChange{ - PermissionLevel: permission.PermissionLevel, - UserName: ac.UserName, - GroupName: ac.GroupName, - ServicePrincipalName: ac.ServicePrincipalName, - }, true - } - if ac.PermissionLevel != "" { - return AccessControlChange{ - PermissionLevel: ac.PermissionLevel, - UserName: ac.UserName, - GroupName: ac.GroupName, - ServicePrincipalName: ac.ServicePrincipalName, - }, true - } - return AccessControlChange{}, false -} - -func (ac AccessControl) String() string { - return fmt.Sprintf("%s%s%s%v", ac.GroupName, ac.UserName, ac.ServicePrincipalName, ac.AllPermissions) -} - -// Permission is a structure to describe permission level -type Permission struct { - PermissionLevel string `json:"permission_level"` - Inherited bool `json:"inherited,omitempty"` - InheritedFromObject []string `json:"inherited_from_object,omitempty"` -} - -func (p Permission) String() string { - if len(p.InheritedFromObject) > 0 { - return fmt.Sprintf("%s (from %s)", p.PermissionLevel, p.InheritedFromObject) - } - return p.PermissionLevel -} - -// AccessControlChangeList is wrapper around ACL changes for REST API -type AccessControlChangeList struct { - AccessControlList []AccessControlChange `json:"access_control_list"` -} - -// AccessControlChange is API wrapper for changing permissions -type AccessControlChange struct { - UserName string `json:"user_name,omitempty"` - GroupName string `json:"group_name,omitempty"` - ServicePrincipalName string `json:"service_principal_name,omitempty"` - PermissionLevel string `json:"permission_level"` -} - -func (acc AccessControlChange) String() string { - return fmt.Sprintf("%v%v%v %s", acc.UserName, acc.GroupName, acc.ServicePrincipalName, - acc.PermissionLevel) -} - // NewPermissionsAPI creates PermissionsAPI instance from provider meta func NewPermissionsAPI(ctx context.Context, m any) PermissionsAPI { return PermissionsAPI{ @@ -108,187 +29,103 @@ type PermissionsAPI struct { context context.Context } -func isDbsqlPermissionsWorkaroundNecessary(objectID string) bool { - return strings.HasPrefix(objectID, "/sql/") && !strings.HasPrefix(objectID, "/sql/warehouses") -} - -func urlPathForObjectID(objectID string) string { - if isDbsqlPermissionsWorkaroundNecessary(objectID) { - // Permissions for SQLA entities are routed differently from the others. - return "/preview/sql/permissions" + objectID[4:] - } - return "/permissions" + objectID -} - -// As described in https://github.com/databricks/terraform-provider-databricks/issues/1504, -// certain object types require that we explicitly grant the calling user CAN_MANAGE -// permissions when POSTing permissions changes through the REST API, to avoid accidentally -// revoking the calling user's ability to manage the current object. -func (a PermissionsAPI) shouldExplicitlyGrantCallingUserManagePermissions(objectID string) bool { - for _, prefix := range [...]string{"/registered-models/", "/clusters/", "/instance-pools/", "/serving-endpoints/", "/queries/", "/sql/warehouses"} { - if strings.HasPrefix(objectID, prefix) { - return true - } - } - return isDbsqlPermissionsWorkaroundNecessary(objectID) -} - -func isOwnershipWorkaroundNecessary(objectID string) bool { - return strings.HasPrefix(objectID, "/jobs") || strings.HasPrefix(objectID, "/pipelines") || strings.HasPrefix(objectID, "/sql/warehouses") -} - -func (a PermissionsAPI) getObjectCreator(objectID string) (string, error) { +// safePutWithOwner is a workaround for the limitation where warehouse without owners cannot have IS_OWNER set +func (a PermissionsAPI) safePutWithOwner(objectID string, objectACL []iam.AccessControlRequest, mapping resourcePermissions, ownerOpt string) error { w, err := a.client.WorkspaceClient() if err != nil { - return "", err + return err } - if strings.HasPrefix(objectID, "/jobs") { - jobId, err := strconv.ParseInt(strings.ReplaceAll(objectID, "/jobs/", ""), 10, 64) - if err != nil { - return "", err - } - job, err := w.Jobs.GetByJobId(a.context, jobId) - if err != nil { - return "", common.IgnoreNotFoundError(err) - } - return job.CreatorUserName, nil - } else if strings.HasPrefix(objectID, "/pipelines") { - pipeline, err := w.Pipelines.GetByPipelineId(a.context, strings.ReplaceAll(objectID, "/pipelines/", "")) - if err != nil { - return "", common.IgnoreNotFoundError(err) - } - return pipeline.CreatorUserName, nil - } else if strings.HasPrefix(objectID, "/sql/warehouses") { - warehouse, err := w.Warehouses.GetById(a.context, strings.ReplaceAll(objectID, "/sql/warehouses/", "")) - if err != nil { - return "", common.IgnoreNotFoundError(err) + idParts := strings.Split(objectID, "/") + id := idParts[len(idParts)-1] + withOwner := mapping.addOwnerPermissionIfNeeded(objectACL, ownerOpt) + _, err = w.Permissions.Set(a.context, iam.PermissionsRequest{ + RequestObjectId: id, + RequestObjectType: mapping.requestObjectType, + AccessControlList: withOwner, + }) + if err != nil { + if strings.Contains(err.Error(), "with no existing owner must provide a new owner") { + _, err = w.Permissions.Set(a.context, iam.PermissionsRequest{ + RequestObjectId: id, + RequestObjectType: mapping.requestObjectType, + AccessControlList: objectACL, + }) } - return warehouse.CreatorName, nil + return err } - return "", nil + return nil } -func (a PermissionsAPI) ensureCurrentUserCanManageObject(objectID string, objectACL AccessControlChangeList) (AccessControlChangeList, error) { - if !a.shouldExplicitlyGrantCallingUserManagePermissions(objectID) { - return objectACL, nil - } +func (a PermissionsAPI) getCurrentUser() (string, error) { w, err := a.client.WorkspaceClient() if err != nil { - return objectACL, err + return "", err } me, err := w.CurrentUser.Me(a.context) if err != nil { - return objectACL, err + return "", err } - objectACL.AccessControlList = append(objectACL.AccessControlList, AccessControlChange{ - UserName: me.UserName, - PermissionLevel: "CAN_MANAGE", - }) - return objectACL, nil + return me.UserName, nil } -// Helper function for applying permissions changes. Ensures that -// we select the correct HTTP method based on the object type and preserve the calling -// user's ability to manage the specified object when applying permissions changes. -func (a PermissionsAPI) put(objectID string, objectACL AccessControlChangeList) error { - objectACL, err := a.ensureCurrentUserCanManageObject(objectID, objectACL) +// Update updates object permissions. Technically, it's using method named SetOrDelete, but here we do more +func (a PermissionsAPI) Update(objectID string, entity entity.PermissionsEntity, mapping resourcePermissions) error { + currentUser, err := a.getCurrentUser() if err != nil { return err } - if isDbsqlPermissionsWorkaroundNecessary(objectID) { - // SQLA entities use POST for permission updates. - return a.client.Post(a.context, urlPathForObjectID(objectID), objectACL, nil) + // this logic was moved from CustomizeDiff because of undeterministic auth behavior + // in the corner-case scenarios. + // see https://github.com/databricks/terraform-provider-databricks/issues/2052 + err = mapping.validate(a.context, entity, currentUser) + if err != nil { + return err } - log.Printf("[DEBUG] PUT %s %v", objectID, objectACL) - return a.client.Put(a.context, urlPathForObjectID(objectID), objectACL) -} - -// safePutWithOwner is a workaround for the limitation where warehouse without owners cannot have IS_OWNER set -func (a PermissionsAPI) safePutWithOwner(objectID string, objectACL AccessControlChangeList, originalAcl []AccessControlChange) error { - err := a.put(objectID, objectACL) + prepared, err := mapping.prepareForUpdate(objectID, entity, currentUser) if err != nil { - if strings.Contains(err.Error(), "with no existing owner must provide a new owner") { - objectACL.AccessControlList = originalAcl - return a.put(objectID, objectACL) - } return err } - return nil + return a.safePutWithOwner(objectID, prepared.AccessControlList, mapping, currentUser) } -// Update updates object permissions. Technically, it's using method named SetOrDelete, but here we do more -func (a PermissionsAPI) Update(objectID string, objectACL AccessControlChangeList) error { - if objectID == "/authorization/tokens" || objectID == "/registered-models/root" || objectID == "/directories/0" { - // Prevent "Cannot change permissions for group 'admins' to None." - objectACL.AccessControlList = append(objectACL.AccessControlList, AccessControlChange{ - GroupName: "admins", - PermissionLevel: "CAN_MANAGE", - }) +// Delete gracefully removes permissions of non-admin users. After this operation, the object is managed +// by the current user and admin group. If the resource has IS_OWNER permissions, they are reset to the +// object creator, if it can be determined. +func (a PermissionsAPI) Delete(objectID string, mapping resourcePermissions) error { + objectACL, err := a.readRaw(objectID, mapping) + if err != nil { + return err } - originalAcl := make([]AccessControlChange, len(objectACL.AccessControlList)) - _ = copy(originalAcl, objectACL.AccessControlList) - if isOwnershipWorkaroundNecessary(objectID) { - owners := 0 - for _, acl := range objectACL.AccessControlList { - if acl.PermissionLevel == "IS_OWNER" { - owners++ - } - } - if owners == 0 { - w, err := a.client.WorkspaceClient() - if err != nil { - return err - } - me, err := w.CurrentUser.Me(a.context) - if err != nil { - return err - } - // add owner if it's missing, otherwise automated planning might be difficult - objectACL.AccessControlList = append(objectACL.AccessControlList, AccessControlChange{ - UserName: me.UserName, - PermissionLevel: "IS_OWNER", - }) - } + accl, err := mapping.prepareForDelete(objectACL, a.getCurrentUser) + if err != nil { + return err } - return a.safePutWithOwner(objectID, objectACL, originalAcl) -} - -// Delete gracefully removes permissions. Technically, it's using method named SetOrDelete, but here we do more -func (a PermissionsAPI) Delete(objectID string) error { - objectACL, err := a.Read(objectID) + w, err := a.client.WorkspaceClient() if err != nil { return err } - accl := AccessControlChangeList{} - for _, acl := range objectACL.AccessControlList { - if acl.GroupName == "admins" && objectID != "/authorization/passwords" { - if change, direct := acl.toAccessControlChange(); direct { - // keep everything direct for admin group - accl.AccessControlList = append(accl.AccessControlList, change) - } - } + resourceStatus, err := mapping.getObjectStatus(a.context, w, objectID) + if err != nil { + return err } - originalAcl := make([]AccessControlChange, len(accl.AccessControlList)) - _ = copy(originalAcl, accl.AccessControlList) - if isOwnershipWorkaroundNecessary(objectID) { - creator, err := a.getObjectCreator(objectID) - if err != nil { - return err - } - if creator == "" { - return nil - } - accl.AccessControlList = append(accl.AccessControlList, AccessControlChange{ - UserName: creator, - PermissionLevel: "IS_OWNER", - }) + // Do not bother resetting permissions for deleted resources + if !resourceStatus.exists { + return nil } - return a.safePutWithOwner(objectID, accl, originalAcl) + return a.safePutWithOwner(objectID, accl, mapping, resourceStatus.creator) } -// Read gets all relevant permissions for the object, including inherited ones -func (a PermissionsAPI) Read(objectID string) (objectACL ObjectACL, err error) { - err = a.client.Get(a.context, urlPathForObjectID(objectID), nil, &objectACL) +func (a PermissionsAPI) readRaw(objectID string, mapping resourcePermissions) (*iam.ObjectPermissions, error) { + w, err := a.client.WorkspaceClient() + if err != nil { + return nil, err + } + idParts := strings.Split(objectID, "/") + id := idParts[len(idParts)-1] + permissions, err := w.Permissions.Get(a.context, iam.GetPermissionRequest{ + RequestObjectId: id, + RequestObjectType: mapping.requestObjectType, + }) var apiErr *apierr.APIError // https://github.com/databricks/terraform-provider-databricks/issues/1227 // platform propagates INVALID_STATE error for auto-purged clusters in @@ -296,143 +133,34 @@ func (a PermissionsAPI) Read(objectID string) (objectACL ObjectACL, err error) { // cross-package dependency on "clusters". if errors.As(err, &apiErr) && strings.Contains(apiErr.Message, "Cannot access cluster") && apiErr.StatusCode == 400 { apiErr.StatusCode = 404 + apiErr.ErrorCode = "RESOURCE_DOES_NOT_EXIST" err = apiErr - return - } - if strings.HasPrefix(objectID, "/dashboards/") { - // workaround for inconsistent API response returning object ID of file in the workspace - objectACL.ObjectID = objectID - } - return -} - -// permissionsIDFieldMapping holds mapping -type permissionsIDFieldMapping struct { - field, objectType, resourceType string - - allowedPermissionLevels []string - - idRetriever func(ctx context.Context, w *databricks.WorkspaceClient, id string) (string, error) -} - -// PermissionsResourceIDFields shows mapping of id columns to resource types -func permissionsResourceIDFields() []permissionsIDFieldMapping { - SIMPLE := func(ctx context.Context, w *databricks.WorkspaceClient, id string) (string, error) { - return id, nil - } - PATH := func(ctx context.Context, w *databricks.WorkspaceClient, path string) (string, error) { - info, err := w.Workspace.GetStatusByPath(ctx, path) - if err != nil { - return "", fmt.Errorf("cannot load path %s: %s", path, err) - } - return strconv.FormatInt(info.ObjectId, 10), nil - } - return []permissionsIDFieldMapping{ - {"cluster_policy_id", "cluster-policy", "cluster-policies", []string{"CAN_USE"}, SIMPLE}, - {"instance_pool_id", "instance-pool", "instance-pools", []string{"CAN_ATTACH_TO", "CAN_MANAGE"}, SIMPLE}, - {"cluster_id", "cluster", "clusters", []string{"CAN_ATTACH_TO", "CAN_RESTART", "CAN_MANAGE"}, SIMPLE}, - {"pipeline_id", "pipelines", "pipelines", []string{"CAN_VIEW", "CAN_RUN", "CAN_MANAGE", "IS_OWNER"}, SIMPLE}, - {"job_id", "job", "jobs", []string{"CAN_VIEW", "CAN_MANAGE_RUN", "IS_OWNER", "CAN_MANAGE"}, SIMPLE}, - {"notebook_id", "notebook", "notebooks", []string{"CAN_READ", "CAN_RUN", "CAN_EDIT", "CAN_MANAGE"}, SIMPLE}, - {"notebook_path", "notebook", "notebooks", []string{"CAN_READ", "CAN_RUN", "CAN_EDIT", "CAN_MANAGE"}, PATH}, - {"directory_id", "directory", "directories", []string{"CAN_READ", "CAN_RUN", "CAN_EDIT", "CAN_MANAGE"}, SIMPLE}, - {"directory_path", "directory", "directories", []string{"CAN_READ", "CAN_RUN", "CAN_EDIT", "CAN_MANAGE"}, PATH}, - {"workspace_file_id", "file", "files", []string{"CAN_READ", "CAN_RUN", "CAN_EDIT", "CAN_MANAGE"}, SIMPLE}, - {"workspace_file_path", "file", "files", []string{"CAN_READ", "CAN_RUN", "CAN_EDIT", "CAN_MANAGE"}, PATH}, - {"repo_id", "repo", "repos", []string{"CAN_READ", "CAN_RUN", "CAN_EDIT", "CAN_MANAGE"}, SIMPLE}, - {"repo_path", "repo", "repos", []string{"CAN_READ", "CAN_RUN", "CAN_EDIT", "CAN_MANAGE"}, PATH}, - {"authorization", "tokens", "authorization", []string{"CAN_USE"}, SIMPLE}, - {"authorization", "passwords", "authorization", []string{"CAN_USE"}, SIMPLE}, - {"sql_endpoint_id", "warehouses", "sql/warehouses", []string{"CAN_USE", "CAN_MANAGE", "CAN_MONITOR", "IS_OWNER"}, SIMPLE}, - {"sql_dashboard_id", "dashboard", "sql/dashboards", []string{"CAN_EDIT", "CAN_RUN", "CAN_MANAGE", "CAN_VIEW"}, SIMPLE}, - {"sql_alert_id", "alert", "sql/alerts", []string{"CAN_EDIT", "CAN_RUN", "CAN_MANAGE", "CAN_VIEW"}, SIMPLE}, - {"sql_query_id", "query", "sql/queries", []string{"CAN_EDIT", "CAN_RUN", "CAN_MANAGE", "CAN_VIEW"}, SIMPLE}, - {"dashboard_id", "dashboard", "dashboards", []string{"CAN_EDIT", "CAN_RUN", "CAN_MANAGE", "CAN_READ"}, SIMPLE}, - {"experiment_id", "mlflowExperiment", "experiments", []string{"CAN_READ", "CAN_EDIT", "CAN_MANAGE"}, SIMPLE}, - {"registered_model_id", "registered-model", "registered-models", []string{ - "CAN_READ", "CAN_EDIT", "CAN_MANAGE_STAGING_VERSIONS", "CAN_MANAGE_PRODUCTION_VERSIONS", "CAN_MANAGE"}, SIMPLE}, - {"serving_endpoint_id", "serving-endpoint", "serving-endpoints", []string{"CAN_VIEW", "CAN_QUERY", "CAN_MANAGE"}, SIMPLE}, - } -} - -// PermissionsEntity is the one used for resource metadata -type PermissionsEntity struct { - ObjectType string `json:"object_type,omitempty" tf:"computed"` - AccessControlList []AccessControlChange `json:"access_control" tf:"slice_set"` -} - -func (oa *ObjectACL) isMatchingMapping(mapping permissionsIDFieldMapping) bool { - if mapping.objectType != oa.ObjectType { - return false - } - if oa.ObjectID != "" && oa.ObjectID[0] == '/' { - return strings.HasPrefix(oa.ObjectID[1:], mapping.resourceType) - } - if strings.HasPrefix(oa.ObjectID, "dashboards/") || strings.HasPrefix(oa.ObjectID, "alerts/") || strings.HasPrefix(oa.ObjectID, "queries/") { - idx := strings.Index(oa.ObjectID, "/") - if idx != -1 { - return mapping.resourceType == "sql/"+oa.ObjectID[:idx] - } - } - - return false -} - -func (oa *ObjectACL) ToPermissionsEntity(d *schema.ResourceData, me string) (PermissionsEntity, error) { - entity := PermissionsEntity{} - for _, accessControl := range oa.AccessControlList { - if accessControl.GroupName == "admins" && d.Id() != "/authorization/passwords" { - // not possible to lower admins permissions anywhere from CAN_MANAGE - continue - } - if me == accessControl.UserName || me == accessControl.ServicePrincipalName { - // not possible to lower one's permissions anywhere from CAN_MANAGE - continue - } - if change, direct := accessControl.toAccessControlChange(); direct { - entity.AccessControlList = append(entity.AccessControlList, change) - } } - for _, mapping := range permissionsResourceIDFields() { - if !oa.isMatchingMapping(mapping) { - continue - } - entity.ObjectType = mapping.objectType - var pathVariant any - if mapping.objectType == "file" { - pathVariant = d.Get("workspace_file_path") - } else { - pathVariant = d.Get(mapping.objectType + "_path") - } - if pathVariant != nil && pathVariant.(string) != "" { - // we're not importing and it's a path... it's set, so let's not re-set it - return entity, nil - } - identifier := path.Base(oa.ObjectID) - return entity, d.Set(mapping.field, identifier) + if err != nil { + return nil, err } - return entity, fmt.Errorf("unknown object type %s", oa.ObjectType) + return permissions, nil } -func stringInSlice(a string, list []string) bool { - for _, b := range list { - if b == a { - return true - } +// Read gets all relevant permissions for the object, including inherited ones +func (a PermissionsAPI) Read(objectID string, mapping resourcePermissions, existing entity.PermissionsEntity, me string) (entity.PermissionsEntity, error) { + permissions, err := a.readRaw(objectID, mapping) + if err != nil { + return entity.PermissionsEntity{}, err } - return false + return mapping.prepareResponse(objectID, permissions, existing, me) } // ResourcePermissions definition func ResourcePermissions() common.Resource { - s := common.StructToSchema(PermissionsEntity{}, func(s map[string]*schema.Schema) map[string]*schema.Schema { - for _, mapping := range permissionsResourceIDFields() { + s := common.StructToSchema(entity.PermissionsEntity{}, func(s map[string]*schema.Schema) map[string]*schema.Schema { + for _, mapping := range allResourcePermissions() { s[mapping.field] = &schema.Schema{ ForceNew: true, Type: schema.TypeString, Optional: true, } - for _, m := range permissionsResourceIDFields() { + for _, m := range allResourcePermissions() { if m.field == mapping.field { continue } @@ -445,38 +173,44 @@ func ResourcePermissions() common.Resource { return common.Resource{ Schema: s, CustomizeDiff: func(ctx context.Context, diff *schema.ResourceDiff) error { + mapping, _, err := getResourcePermissionsFromState(diff) + if err != nil { + // This preserves current behavior but is likely only exercised in tests where + // the original config is not specified. + return nil + } + planned := entity.PermissionsEntity{} + common.DiffToStructPointer(diff, s, &planned) // Plan time validation for object permission levels - for _, mapping := range permissionsResourceIDFields() { - if _, ok := diff.GetOk(mapping.field); !ok { + for _, accessControl := range planned.AccessControlList { + permissionLevel := accessControl.PermissionLevel + // No diff in permission level, so don't need to check. + if permissionLevel == "" { continue } - access_control_list := diff.Get("access_control").(*schema.Set).List() - for _, access_control := range access_control_list { - m := access_control.(map[string]any) - permission_level := m["permission_level"].(string) - if !stringInSlice(permission_level, mapping.allowedPermissionLevels) { - return fmt.Errorf(`permission_level %s is not supported with %s objects`, - permission_level, mapping.field) - } + // TODO: only warn on unknown permission levels, as new levels may be released that the TF provider + // is not aware of. + if _, ok := mapping.allowedPermissionLevels[string(permissionLevel)]; !ok { + return fmt.Errorf(`permission_level %s is not supported with %s objects; allowed levels: %s`, + permissionLevel, mapping.field, strings.Join(mapping.getAllowedPermissionLevels(true), ", ")) } } return nil }, Read: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { - id := d.Id() - w, err := c.WorkspaceClient() - if err != nil { - return err - } - objectACL, err := NewPermissionsAPI(ctx, c).Read(id) + a := NewPermissionsAPI(ctx, c) + mapping, err := getResourcePermissionsFromId(d.Id()) if err != nil { return err } - me, err := w.CurrentUser.Me(ctx) + var existing entity.PermissionsEntity + common.DataToStructPointer(d, s, &existing) + me, err := a.getCurrentUser() if err != nil { return err } - entity, err := objectACL.ToPermissionsEntity(d, me.UserName) + id := d.Id() + entity, err := a.Read(id, mapping, existing, me) if err != nil { return err } @@ -485,61 +219,53 @@ func ResourcePermissions() common.Resource { d.SetId("") return nil } + entity.ObjectType = mapping.objectType + pathVariant := d.Get(mapping.getPathVariant()) + if pathVariant == nil || pathVariant.(string) == "" { + identifier := path.Base(id) + if err = d.Set(mapping.field, identifier); err != nil { + return err + } + } return common.StructToData(entity, s, d) }, Create: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { - var entity PermissionsEntity + var entity entity.PermissionsEntity common.DataToStructPointer(d, s, &entity) w, err := c.WorkspaceClient() if err != nil { return err } - me, err := w.CurrentUser.Me(ctx) + mapping, configuredValue, err := getResourcePermissionsFromState(d) if err != nil { return err } - for _, mapping := range permissionsResourceIDFields() { - if v, ok := d.GetOk(mapping.field); ok { - id, err := mapping.idRetriever(ctx, w, v.(string)) - if err != nil { - return err - } - objectID := fmt.Sprintf("/%s/%s", mapping.resourceType, id) - // this logic was moved from CustomizeDiff because of undeterministic auth behavior - // in the corner-case scenarios. - // see https://github.com/databricks/terraform-provider-databricks/issues/2052 - for _, v := range entity.AccessControlList { - if v.UserName == me.UserName { - format := "it is not possible to decrease administrative permissions for the current user: %s" - return fmt.Errorf(format, me.UserName) - } - - if v.GroupName == "admins" && mapping.resourceType != "authorization" { - // should allow setting admins permissions for passwords and tokens usage - return fmt.Errorf("it is not possible to restrict any permissions from `admins`") - } - } - err = NewPermissionsAPI(ctx, c).Update(objectID, AccessControlChangeList{ - AccessControlList: entity.AccessControlList, - }) - if err != nil { - return err - } - d.SetId(objectID) - return nil - } + objectID, err := mapping.getID(ctx, w, configuredValue) + if err != nil { + return err + } + err = NewPermissionsAPI(ctx, c).Update(objectID, entity, mapping) + if err != nil { + return err } - return errors.New("at least one type of resource identifiers must be set") + d.SetId(objectID) + return nil }, Update: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { - var entity PermissionsEntity + var entity entity.PermissionsEntity common.DataToStructPointer(d, s, &entity) - return NewPermissionsAPI(ctx, c).Update(d.Id(), AccessControlChangeList{ - AccessControlList: entity.AccessControlList, - }) + mapping, err := getResourcePermissionsFromId(d.Id()) + if err != nil { + return err + } + return NewPermissionsAPI(ctx, c).Update(d.Id(), entity, mapping) }, Delete: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { - return NewPermissionsAPI(ctx, c).Delete(d.Id()) + mapping, err := getResourcePermissionsFromId(d.Id()) + if err != nil { + return err + } + return NewPermissionsAPI(ctx, c).Delete(d.Id(), mapping) }, } } diff --git a/permissions/resource_permissions_test.go b/permissions/resource_permissions_test.go index b01fddb1ca..7019ae5c56 100644 --- a/permissions/resource_permissions_test.go +++ b/permissions/resource_permissions_test.go @@ -2,17 +2,21 @@ package permissions import ( "context" - "net/http" + "fmt" "testing" + "github.com/stretchr/testify/mock" + "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/apierr" + "github.com/databricks/databricks-sdk-go/experimental/mocks" + "github.com/databricks/databricks-sdk-go/service/iam" "github.com/databricks/databricks-sdk-go/service/jobs" + "github.com/databricks/databricks-sdk-go/service/pipelines" + "github.com/databricks/databricks-sdk-go/service/workspace" "github.com/databricks/terraform-provider-databricks/common" - "github.com/databricks/terraform-provider-databricks/scim" - + "github.com/databricks/terraform-provider-databricks/permissions/entity" "github.com/databricks/terraform-provider-databricks/qa" - "github.com/databricks/terraform-provider-databricks/workspace" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -22,70 +26,39 @@ var ( TestingUser = "ben" TestingAdminUser = "admin" TestingOwner = "testOwner" - me = qa.HTTPFixture{ - ReuseRequest: true, - Method: "GET", - Resource: "/api/2.0/preview/scim/v2/Me", - Response: scim.User{ - UserName: TestingAdminUser, - }, - } ) -func TestEntityAccessControlChangeString(t *testing.T) { - assert.Equal(t, "me CAN_READ", AccessControlChange{ - UserName: "me", - PermissionLevel: "CAN_READ", - }.String()) -} - -func TestEntityAccessControlString(t *testing.T) { - assert.Equal(t, "me[CAN_READ (from [parent]) CAN_MANAGE]", AccessControl{ - UserName: "me", - AllPermissions: []Permission{ - { - InheritedFromObject: []string{"parent"}, - PermissionLevel: "CAN_READ", - }, - { - PermissionLevel: "CAN_MANAGE", - }, - }, - }.String()) -} - func TestResourcePermissionsRead(t *testing.T) { d, err := qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: http.MethodGet, - Resource: "/api/2.0/permissions/clusters/abc", - Response: ObjectACL{ - ObjectID: "/clusters/abc", - ObjectType: "cluster", - AccessControlList: []AccessControl{ - { - UserName: TestingUser, - AllPermissions: []Permission{ - { - PermissionLevel: "CAN_READ", - Inherited: false, - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: "admin"}, nil) + mwc.GetMockPermissionsAPI().EXPECT().Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "abc", + RequestObjectType: "clusters", + }).Return(&iam.ObjectPermissions{ + ObjectId: "/clusters/abc", + ObjectType: "cluster", + AccessControlList: []iam.AccessControlResponse{ + { + UserName: TestingUser, + AllPermissions: []iam.Permission{ + { + PermissionLevel: "CAN_READ", + Inherited: false, }, }, - { - UserName: TestingAdminUser, - AllPermissions: []Permission{ - { - PermissionLevel: "CAN_MANAGE", - Inherited: false, - }, + }, + { + UserName: TestingAdminUser, + AllPermissions: []iam.Permission{ + { + PermissionLevel: "CAN_MANAGE", + Inherited: false, }, }, }, }, - }, + }, nil) }, Resource: ResourcePermissions(), Read: true, @@ -104,17 +77,16 @@ func TestResourcePermissionsRead(t *testing.T) { // https://github.com/databricks/terraform-provider-databricks/issues/1227 func TestResourcePermissionsRead_RemovedCluster(t *testing.T) { qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: http.MethodGet, - Resource: "/api/2.0/permissions/clusters/abc", - Status: 400, - Response: apierr.APIError{ - ErrorCode: "INVALID_STATE", - Message: "Cannot access cluster X that was terminated or unpinned more than Y days ago.", - }, - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: "admin"}, nil) + mwc.GetMockPermissionsAPI().EXPECT().Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "abc", + RequestObjectType: "clusters", + }).Return(nil, &apierr.APIError{ + StatusCode: 400, + ErrorCode: "INVALID_STATE", + Message: "Cannot access cluster X that was terminated or unpinned more than Y days ago.", + }) }, Resource: ResourcePermissions(), Read: true, @@ -126,27 +98,25 @@ func TestResourcePermissionsRead_RemovedCluster(t *testing.T) { func TestResourcePermissionsRead_Mlflow_Model(t *testing.T) { d, err := qa.ResourceFixture{ - // Pass list of API request mocks - Fixtures: []qa.HTTPFixture{ - me, - { - Method: http.MethodGet, - Resource: "/api/2.0/permissions/registered-models/fakeuuid123", - Response: ObjectACL{ - ObjectID: "/registered-models/fakeuuid123", - ObjectType: "registered-model", - AccessControlList: []AccessControl{ - { - UserName: TestingUser, - PermissionLevel: "CAN_READ", - }, - { - UserName: TestingAdminUser, - PermissionLevel: "CAN_MANAGE", - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: "admin"}, nil) + mwc.GetMockPermissionsAPI().EXPECT().Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "fakeuuid123", + RequestObjectType: "registered-models", + }).Return(&iam.ObjectPermissions{ + ObjectId: "/registered-models/fakeuuid123", + ObjectType: "registered-model", + AccessControlList: []iam.AccessControlResponse{ + { + UserName: TestingUser, + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelCanRead}}, + }, + { + UserName: TestingAdminUser, + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelCanManage}}, }, }, - }, + }, nil) }, Resource: ResourcePermissions(), Read: true, @@ -164,42 +134,40 @@ func TestResourcePermissionsRead_Mlflow_Model(t *testing.T) { func TestResourcePermissionsCreate_Mlflow_Model(t *testing.T) { d, err := qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: http.MethodPut, - Resource: "/api/2.0/permissions/registered-models/fakeuuid123", - ExpectedRequest: AccessControlChangeList{ - AccessControlList: []AccessControlChange{ - { - UserName: TestingUser, - PermissionLevel: "CAN_READ", - }, - { - UserName: TestingAdminUser, - PermissionLevel: "CAN_MANAGE", - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: "admin"}, nil) + e := mwc.GetMockPermissionsAPI().EXPECT() + e.Set(mock.Anything, iam.PermissionsRequest{ + RequestObjectId: "fakeuuid123", + RequestObjectType: "registered-models", + AccessControlList: []iam.AccessControlRequest{ + { + UserName: TestingUser, + PermissionLevel: "CAN_READ", + }, + { + UserName: TestingAdminUser, + PermissionLevel: "CAN_MANAGE", }, }, - }, - { - Method: http.MethodGet, - Resource: "/api/2.0/permissions/registered-models/fakeuuid123", - Response: ObjectACL{ - ObjectID: "/registered-models/fakeuuid123", - ObjectType: "registered-model", - AccessControlList: []AccessControl{ - { - UserName: TestingUser, - PermissionLevel: "CAN_READ", - }, - { - UserName: TestingAdminUser, - PermissionLevel: "CAN_MANAGE", - }, + }).Return(nil, nil) + e.Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "fakeuuid123", + RequestObjectType: "registered-models", + }).Return(&iam.ObjectPermissions{ + ObjectId: "/registered-models/fakeuuid123", + ObjectType: "registered-model", + AccessControlList: []iam.AccessControlResponse{ + { + UserName: TestingUser, + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelCanRead}}, + }, + { + UserName: TestingAdminUser, + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelCanManage}}, }, }, - }, + }, nil) }, Resource: ResourcePermissions(), State: map[string]any{ @@ -223,42 +191,40 @@ func TestResourcePermissionsCreate_Mlflow_Model(t *testing.T) { func TestResourcePermissionsUpdate_Mlflow_Model(t *testing.T) { d, err := qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: http.MethodPut, - Resource: "/api/2.0/permissions/registered-models/fakeuuid123", - ExpectedRequest: AccessControlChangeList{ - AccessControlList: []AccessControlChange{ - { - UserName: TestingUser, - PermissionLevel: "CAN_READ", - }, - { - UserName: TestingAdminUser, - PermissionLevel: "CAN_MANAGE", - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: "admin"}, nil) + e := mwc.GetMockPermissionsAPI().EXPECT() + e.Set(mock.Anything, iam.PermissionsRequest{ + RequestObjectId: "fakeuuid123", + RequestObjectType: "registered-models", + AccessControlList: []iam.AccessControlRequest{ + { + UserName: TestingUser, + PermissionLevel: "CAN_READ", + }, + { + UserName: TestingAdminUser, + PermissionLevel: "CAN_MANAGE", }, }, - }, - { - Method: http.MethodGet, - Resource: "/api/2.0/permissions/registered-models/fakeuuid123", - Response: ObjectACL{ - ObjectID: "/registered-models/fakeuuid123", - ObjectType: "registered-model", - AccessControlList: []AccessControl{ - { - UserName: TestingUser, - PermissionLevel: "CAN_READ", - }, - { - UserName: TestingAdminUser, - PermissionLevel: "CAN_MANAGE", - }, + }).Return(nil, nil) + e.Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "fakeuuid123", + RequestObjectType: "registered-models", + }).Return(&iam.ObjectPermissions{ + ObjectId: "/registered-models/fakeuuid123", + ObjectType: "registered-model", + AccessControlList: []iam.AccessControlResponse{ + { + UserName: TestingUser, + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelCanRead}}, + }, + { + UserName: TestingAdminUser, + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelCanManage}}, }, }, - }, + }, nil) }, InstanceState: map[string]string{ "registered_model_id": "fakeuuid123", @@ -287,38 +253,36 @@ func TestResourcePermissionsUpdate_Mlflow_Model(t *testing.T) { func TestResourcePermissionsDelete_Mlflow_Model(t *testing.T) { d, err := qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: http.MethodGet, - Resource: "/api/2.0/permissions/registered-models/fakeuuid123", - Response: ObjectACL{ - ObjectID: "/registered-models/fakeuuid123", - ObjectType: "registered-model", - AccessControlList: []AccessControl{ - { - UserName: TestingUser, - PermissionLevel: "CAN_READ", - }, - { - UserName: TestingAdminUser, - PermissionLevel: "CAN_MANAGE", - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: "admin"}, nil) + e := mwc.GetMockPermissionsAPI().EXPECT() + e.Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "fakeuuid123", + RequestObjectType: "registered-models", + }).Return(&iam.ObjectPermissions{ + ObjectId: "/registered-models/fakeuuid123", + ObjectType: "registered-model", + AccessControlList: []iam.AccessControlResponse{ + { + UserName: TestingUser, + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelCanRead}}, + }, + { + UserName: TestingAdminUser, + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelCanManage}}, }, }, - }, - { - Method: http.MethodPut, - Resource: "/api/2.0/permissions/registered-models/fakeuuid123", - ExpectedRequest: AccessControlChangeList{ - AccessControlList: []AccessControlChange{ - { - UserName: TestingAdminUser, - PermissionLevel: "CAN_MANAGE", - }, + }, nil) + e.Set(mock.Anything, iam.PermissionsRequest{ + RequestObjectId: "fakeuuid123", + RequestObjectType: "registered-models", + AccessControlList: []iam.AccessControlRequest{ + { + UserName: TestingAdminUser, + PermissionLevel: "CAN_MANAGE", }, }, - }, + }).Return(nil, nil) }, Resource: ResourcePermissions(), Delete: true, @@ -330,31 +294,38 @@ func TestResourcePermissionsDelete_Mlflow_Model(t *testing.T) { func TestResourcePermissionsRead_SQLA_Asset(t *testing.T) { d, err := qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: http.MethodGet, - Resource: "/api/2.0/preview/sql/permissions/dashboards/abc", - Response: ObjectACL{ - ObjectID: "dashboards/abc", - ObjectType: "dashboard", - AccessControlList: []AccessControl{ - { - UserName: TestingUser, - PermissionLevel: "CAN_READ", - }, - { - UserName: TestingAdminUser, - PermissionLevel: "CAN_MANAGE", - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: "admin"}, nil) + e := mwc.GetMockPermissionsAPI().EXPECT() + e.Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "abc", + RequestObjectType: "dbsql-dashboards", + }).Return(&iam.ObjectPermissions{ + ObjectId: "dashboards/abc", + ObjectType: "dashboard", + AccessControlList: []iam.AccessControlResponse{ + { + UserName: TestingUser, + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelCanRead}}, + }, + { + UserName: TestingAdminUser, + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelCanManage}}, }, }, - }, + }, nil) }, Resource: ResourcePermissions(), Read: true, New: true, ID: "/sql/dashboards/abc", + HCL: ` + sql_dashboard_id = "abc" + access_control { + user_name = "ben" + permission_level = "CAN_VIEW" + } + `, }.Apply(t) assert.NoError(t, err) assert.Equal(t, "/sql/dashboards/abc", d.Id()) @@ -362,31 +333,31 @@ func TestResourcePermissionsRead_SQLA_Asset(t *testing.T) { require.Equal(t, 1, len(ac.List())) firstElem := ac.List()[0].(map[string]any) assert.Equal(t, TestingUser, firstElem["user_name"]) - assert.Equal(t, "CAN_READ", firstElem["permission_level"]) + assert.Equal(t, "CAN_VIEW", firstElem["permission_level"]) } func TestResourcePermissionsRead_Dashboard(t *testing.T) { d, err := qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: http.MethodGet, - Resource: "/api/2.0/permissions/dashboards/abc", - Response: ObjectACL{ - ObjectID: "dashboards/abc", - ObjectType: "dashboard", - AccessControlList: []AccessControl{ - { - UserName: TestingUser, - PermissionLevel: "CAN_READ", - }, - { - UserName: TestingAdminUser, - PermissionLevel: "CAN_MANAGE", - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: "admin"}, nil) + e := mwc.GetMockPermissionsAPI().EXPECT() + e.Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "abc", + RequestObjectType: "dashboards", + }).Return(&iam.ObjectPermissions{ + ObjectId: "dashboards/abc", + ObjectType: "dashboard", + AccessControlList: []iam.AccessControlResponse{ + { + UserName: TestingUser, + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelCanRead}}, + }, + { + UserName: TestingAdminUser, + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelCanManage}}, }, }, - }, + }, nil) }, Resource: ResourcePermissions(), Read: true, @@ -405,17 +376,16 @@ func TestResourcePermissionsRead_Dashboard(t *testing.T) { func TestResourcePermissionsRead_NotFound(t *testing.T) { qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: http.MethodGet, - Resource: "/api/2.0/permissions/clusters/abc", - Response: apierr.APIError{ - ErrorCode: "NOT_FOUND", - Message: "Cluster does not exist", - }, - Status: 404, - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: "admin"}, nil) + mwc.GetMockPermissionsAPI().EXPECT().Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "abc", + RequestObjectType: "clusters", + }).Return(nil, &apierr.APIError{ + StatusCode: 404, + ErrorCode: "NOT_FOUND", + Message: "Cluster does not exist", + }) }, Resource: ResourcePermissions(), Read: true, @@ -427,17 +397,16 @@ func TestResourcePermissionsRead_NotFound(t *testing.T) { func TestResourcePermissionsRead_some_error(t *testing.T) { _, err := qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: http.MethodGet, - Resource: "/api/2.0/permissions/clusters/abc", - Response: apierr.APIError{ - ErrorCode: "INVALID_REQUEST", - Message: "Internal error happened", - }, - Status: 400, - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: "admin"}, nil) + mwc.GetMockPermissionsAPI().EXPECT().Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "abc", + RequestObjectType: "clusters", + }).Return(nil, &apierr.APIError{ + StatusCode: 400, + ErrorCode: "INVALID_REQUEST", + Message: "Internal error happened", + }) }, Resource: ResourcePermissions(), Read: true, @@ -455,56 +424,17 @@ func TestResourcePermissionsCustomizeDiff_ErrorOnCreate(t *testing.T) { access_control { permission_level = "WHATEVER" }`, - }.ExpectError(t, "permission_level WHATEVER is not supported with cluster_id objects") -} - -func TestResourcePermissionsCustomizeDiff_ErrorOnPermissionsDecreate(t *testing.T) { - qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - }, - Resource: ResourcePermissions(), - Create: true, - HCL: ` - cluster_id = "abc" - access_control { - permission_level = "CAN_ATTACH_TO" - user_name = "admin" - }`, - }.ExpectError(t, "it is not possible to decrease administrative permissions for the current user: admin") + }.ExpectError(t, "permission_level WHATEVER is not supported with cluster_id objects; allowed levels: CAN_ATTACH_TO, CAN_MANAGE, CAN_RESTART") } func TestResourcePermissionsRead_ErrorOnScimMe(t *testing.T) { - qa.HTTPFixturesApply(t, []qa.HTTPFixture{ - { - Method: http.MethodGet, - Resource: "/api/2.0/permissions/clusters/abc", - Response: ObjectACL{ - ObjectID: "/clusters/abc", - ObjectType: "clusters", - AccessControlList: []AccessControl{ - { - UserName: TestingUser, - AllPermissions: []Permission{ - { - PermissionLevel: "CAN_READ", - Inherited: false, - }, - }, - }, - }, - }, - }, - { - Method: http.MethodGet, - Resource: "/api/2.0/preview/scim/v2/Me", - Response: apierr.APIError{ - ErrorCode: "INVALID_REQUEST", - Message: "Internal error happened", - }, - Status: 400, - }, - }, func(ctx context.Context, client *common.DatabricksClient) { + mock := func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(nil, &apierr.APIError{ + ErrorCode: "INVALID_REQUEST", + Message: "Internal error happened", + }) + } + qa.MockWorkspaceApply(t, mock, func(ctx context.Context, client *common.DatabricksClient) { r := ResourcePermissions().ToResource() d := r.TestResourceData() d.SetId("/clusters/abc") @@ -516,35 +446,33 @@ func TestResourcePermissionsRead_ErrorOnScimMe(t *testing.T) { func TestResourcePermissionsRead_ToPermissionsEntity_Error(t *testing.T) { qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: http.MethodGet, - Resource: "/api/2.0/permissions/clusters/abc", - Response: ObjectACL{ - ObjectType: "teapot", - }, - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: "admin"}, nil) + mwc.GetMockPermissionsAPI().EXPECT().Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "abc", + RequestObjectType: "clusters", + }).Return(&iam.ObjectPermissions{ + ObjectType: "teapot", + }, nil) }, Resource: ResourcePermissions(), Read: true, New: true, ID: "/clusters/abc", - }.ExpectError(t, "unknown object type teapot") + }.ExpectError(t, "expected object type cluster, got teapot") } func TestResourcePermissionsRead_EmptyListResultsInRemoval(t *testing.T) { qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: http.MethodGet, - Resource: "/api/2.0/permissions/clusters/abc", - Response: ObjectACL{ - ObjectID: "/clusters/abc", - ObjectType: "cluster", - }, - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: "admin"}, nil) + mwc.GetMockPermissionsAPI().EXPECT().Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "abc", + RequestObjectType: "clusters", + }).Return(&iam.ObjectPermissions{ + ObjectId: "/clusters/abc", + ObjectType: "cluster", + }, nil) }, Resource: ResourcePermissions(), Read: true, @@ -558,48 +486,46 @@ func TestResourcePermissionsRead_EmptyListResultsInRemoval(t *testing.T) { func TestResourcePermissionsDelete(t *testing.T) { d, err := qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: http.MethodGet, - Resource: "/api/2.0/permissions/clusters/abc", - Response: ObjectACL{ - ObjectID: "/clusters/abc", - ObjectType: "clusters", - AccessControlList: []AccessControl{ - { - UserName: TestingUser, - AllPermissions: []Permission{ - { - PermissionLevel: "CAN_READ", - Inherited: false, - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: "admin"}, nil) + e := mwc.GetMockPermissionsAPI().EXPECT() + e.Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "abc", + RequestObjectType: "clusters", + }).Return(&iam.ObjectPermissions{ + ObjectId: "/clusters/abc", + ObjectType: "cluster", + AccessControlList: []iam.AccessControlResponse{ + { + UserName: TestingUser, + AllPermissions: []iam.Permission{ + { + PermissionLevel: "CAN_READ", + Inherited: false, }, }, - { - UserName: TestingAdminUser, - AllPermissions: []Permission{ - { - PermissionLevel: "CAN_MANAGE", - Inherited: false, - }, + }, + { + UserName: TestingAdminUser, + AllPermissions: []iam.Permission{ + { + PermissionLevel: "CAN_MANAGE", + Inherited: false, }, }, }, }, - }, - { - Method: http.MethodPut, - Resource: "/api/2.0/permissions/clusters/abc", - ExpectedRequest: AccessControlChangeList{ - AccessControlList: []AccessControlChange{ - { - UserName: TestingAdminUser, - PermissionLevel: "CAN_MANAGE", - }, + }, nil) + e.Set(mock.Anything, iam.PermissionsRequest{ + RequestObjectId: "abc", + RequestObjectType: "clusters", + AccessControlList: []iam.AccessControlRequest{ + { + UserName: TestingAdminUser, + PermissionLevel: "CAN_MANAGE", }, }, - }, + }).Return(nil, nil) }, Resource: ResourcePermissions(), Delete: true, @@ -611,53 +537,50 @@ func TestResourcePermissionsDelete(t *testing.T) { func TestResourcePermissionsDelete_error(t *testing.T) { _, err := qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: http.MethodGet, - Resource: "/api/2.0/permissions/clusters/abc", - Response: ObjectACL{ - ObjectID: "/clusters/abc", - ObjectType: "clusters", - AccessControlList: []AccessControl{ - { - UserName: TestingUser, - AllPermissions: []Permission{ - { - PermissionLevel: "CAN_READ", - Inherited: false, - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: "admin"}, nil) + e := mwc.GetMockPermissionsAPI().EXPECT() + e.Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "abc", + RequestObjectType: "clusters", + }).Return(&iam.ObjectPermissions{ + ObjectId: "/clusters/abc", + ObjectType: "cluster", + AccessControlList: []iam.AccessControlResponse{ + { + UserName: TestingUser, + AllPermissions: []iam.Permission{ + { + PermissionLevel: "CAN_READ", + Inherited: false, }, }, - { - UserName: TestingAdminUser, - AllPermissions: []Permission{ - { - PermissionLevel: "CAN_MANAGE", - Inherited: false, - }, + }, + { + UserName: TestingAdminUser, + AllPermissions: []iam.Permission{ + { + PermissionLevel: "CAN_MANAGE", + Inherited: false, }, }, }, }, - }, - { - Method: http.MethodPut, - Resource: "/api/2.0/permissions/clusters/abc", - ExpectedRequest: AccessControlChangeList{ - AccessControlList: []AccessControlChange{ - { - UserName: TestingAdminUser, - PermissionLevel: "CAN_MANAGE", - }, + }, nil) + e.Set(mock.Anything, iam.PermissionsRequest{ + RequestObjectId: "abc", + RequestObjectType: "clusters", + AccessControlList: []iam.AccessControlRequest{ + { + UserName: TestingAdminUser, + PermissionLevel: "CAN_MANAGE", }, }, - Response: apierr.APIError{ - ErrorCode: "INVALID_REQUEST", - Message: "Internal error happened", - }, - Status: 400, - }, + }).Return(nil, &apierr.APIError{ + ErrorCode: "INVALID_REQUEST", + Message: "Internal error happened", + StatusCode: 400, + }) }, Resource: ResourcePermissions(), Delete: true, @@ -668,15 +591,13 @@ func TestResourcePermissionsDelete_error(t *testing.T) { func TestResourcePermissionsCreate_invalid(t *testing.T) { qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{me}, Resource: ResourcePermissions(), Create: true, - }.ExpectError(t, "at least one type of resource identifiers must be set") + }.ExpectError(t, "at least one type of resource identifier must be set; allowed fields: authorization, cluster_id, cluster_policy_id, dashboard_id, directory_id, directory_path, experiment_id, instance_pool_id, job_id, notebook_id, notebook_path, pipeline_id, registered_model_id, repo_id, repo_path, serving_endpoint_id, sql_alert_id, sql_dashboard_id, sql_endpoint_id, sql_query_id, workspace_file_id, workspace_file_path") } func TestResourcePermissionsCreate_no_access_control(t *testing.T) { qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{}, Resource: ResourcePermissions(), Create: true, State: map[string]any{ @@ -687,7 +608,6 @@ func TestResourcePermissionsCreate_no_access_control(t *testing.T) { func TestResourcePermissionsCreate_conflicting_fields(t *testing.T) { qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{}, Resource: ResourcePermissions(), Create: true, State: map[string]any{ @@ -705,7 +625,9 @@ func TestResourcePermissionsCreate_conflicting_fields(t *testing.T) { func TestResourcePermissionsCreate_AdminsThrowError(t *testing.T) { _, err := qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{me}, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: "admin"}, nil) + }, Resource: ResourcePermissions(), Create: true, HCL: ` @@ -716,57 +638,55 @@ func TestResourcePermissionsCreate_AdminsThrowError(t *testing.T) { } `, }.Apply(t) - assert.EqualError(t, err, "it is not possible to restrict any permissions from `admins`") + assert.EqualError(t, err, "it is not possible to modify admin permissions for cluster resources") } func TestResourcePermissionsCreate(t *testing.T) { d, err := qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: http.MethodPut, - Resource: "/api/2.0/permissions/clusters/abc", - ExpectedRequest: AccessControlChangeList{ - AccessControlList: []AccessControlChange{ - { - UserName: TestingUser, - PermissionLevel: "CAN_ATTACH_TO", - }, - { - UserName: TestingAdminUser, - PermissionLevel: "CAN_MANAGE", - }, - }, - }, - }, - { - Method: http.MethodGet, - Resource: "/api/2.0/permissions/clusters/abc", - Response: ObjectACL{ - ObjectID: "/clusters/abc", - ObjectType: "cluster", - AccessControlList: []AccessControl{ - { - UserName: TestingUser, - AllPermissions: []Permission{ - { - PermissionLevel: "CAN_ATTACH_TO", - Inherited: false, - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: "admin"}, nil) + e := mwc.GetMockPermissionsAPI().EXPECT() + e.Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "abc", + RequestObjectType: "clusters", + }).Return(&iam.ObjectPermissions{ + ObjectId: "/clusters/abc", + ObjectType: "cluster", + AccessControlList: []iam.AccessControlResponse{ + { + UserName: TestingUser, + AllPermissions: []iam.Permission{ + { + PermissionLevel: "CAN_ATTACH_TO", + Inherited: false, }, }, - { - UserName: TestingAdminUser, - AllPermissions: []Permission{ - { - PermissionLevel: "CAN_MANAGE", - Inherited: false, - }, + }, + { + UserName: TestingAdminUser, + AllPermissions: []iam.Permission{ + { + PermissionLevel: "CAN_MANAGE", + Inherited: false, }, }, }, }, - }, + }, nil) + e.Set(mock.Anything, iam.PermissionsRequest{ + RequestObjectId: "abc", + RequestObjectType: "clusters", + AccessControlList: []iam.AccessControlRequest{ + { + UserName: TestingUser, + PermissionLevel: "CAN_ATTACH_TO", + }, + { + UserName: TestingAdminUser, + PermissionLevel: "CAN_MANAGE", + }, + }, + }).Return(nil, nil) }, Resource: ResourcePermissions(), State: map[string]any{ @@ -790,42 +710,50 @@ func TestResourcePermissionsCreate(t *testing.T) { func TestResourcePermissionsCreate_SQLA_Asset(t *testing.T) { d, err := qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: http.MethodPost, - Resource: "/api/2.0/preview/sql/permissions/dashboards/abc", - ExpectedRequest: AccessControlChangeList{ - AccessControlList: []AccessControlChange{ - { - UserName: TestingUser, - PermissionLevel: "CAN_RUN", + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: "admin"}, nil) + e := mwc.GetMockPermissionsAPI().EXPECT() + e.Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "abc", + RequestObjectType: "dbsql-dashboards", + }).Return(&iam.ObjectPermissions{ + ObjectId: "/dashboards/abc", + ObjectType: "dashboard", + AccessControlList: []iam.AccessControlResponse{ + { + UserName: TestingUser, + AllPermissions: []iam.Permission{ + { + PermissionLevel: "CAN_RUN", + Inherited: false, + }, }, - { - UserName: TestingAdminUser, - PermissionLevel: "CAN_MANAGE", + }, + { + UserName: TestingAdminUser, + AllPermissions: []iam.Permission{ + { + PermissionLevel: "CAN_MANAGE", + Inherited: false, + }, }, }, }, - }, - { - Method: http.MethodGet, - Resource: "/api/2.0/preview/sql/permissions/dashboards/abc", - Response: ObjectACL{ - ObjectID: "dashboards/abc", - ObjectType: "dashboard", - AccessControlList: []AccessControl{ - { - UserName: TestingUser, - PermissionLevel: "CAN_RUN", - }, - { - UserName: TestingAdminUser, - PermissionLevel: "CAN_MANAGE", - }, + }, nil) + e.Set(mock.Anything, iam.PermissionsRequest{ + RequestObjectId: "abc", + RequestObjectType: "dbsql-dashboards", + AccessControlList: []iam.AccessControlRequest{ + { + UserName: TestingUser, + PermissionLevel: "CAN_RUN", + }, + { + UserName: TestingAdminUser, + PermissionLevel: "CAN_MANAGE", }, }, - }, + }).Return(nil, nil) }, Resource: ResourcePermissions(), State: map[string]any{ @@ -849,50 +777,48 @@ func TestResourcePermissionsCreate_SQLA_Asset(t *testing.T) { func TestResourcePermissionsCreate_SQLA_Endpoint(t *testing.T) { d, err := qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: "PUT", - Resource: "/api/2.0/permissions/sql/warehouses/abc", - ExpectedRequest: AccessControlChangeList{ - AccessControlList: []AccessControlChange{ - { - UserName: TestingUser, - PermissionLevel: "CAN_USE", - }, - { - UserName: TestingAdminUser, - PermissionLevel: "IS_OWNER", - }, - { - UserName: TestingAdminUser, - PermissionLevel: "CAN_MANAGE", - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: "admin"}, nil) + e := mwc.GetMockPermissionsAPI().EXPECT() + e.Set(mock.Anything, iam.PermissionsRequest{ + RequestObjectId: "abc", + RequestObjectType: "sql/warehouses", + AccessControlList: []iam.AccessControlRequest{ + { + UserName: TestingUser, + PermissionLevel: "CAN_USE", + }, + { + UserName: TestingAdminUser, + PermissionLevel: "CAN_MANAGE", + }, + { + UserName: TestingAdminUser, + PermissionLevel: "IS_OWNER", }, }, - }, - { - Method: http.MethodGet, - Resource: "/api/2.0/permissions/sql/warehouses/abc", - Response: ObjectACL{ - ObjectID: "dashboards/abc", - ObjectType: "dashboard", - AccessControlList: []AccessControl{ - { - UserName: TestingUser, - PermissionLevel: "CAN_USE", - }, - { - UserName: TestingAdminUser, - PermissionLevel: "IS_OWNER", - }, - { - UserName: TestingAdminUser, - PermissionLevel: "CAN_MANAGE", - }, + }).Return(nil, nil) + e.Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "abc", + RequestObjectType: "sql/warehouses", + }).Return(&iam.ObjectPermissions{ + ObjectId: "warehouses/abc", + ObjectType: "warehouses", + AccessControlList: []iam.AccessControlResponse{ + { + UserName: TestingUser, + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelCanUse}}, + }, + { + UserName: TestingAdminUser, + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelCanManage}}, + }, + { + UserName: TestingAdminUser, + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelIsOwner}}, }, }, - }, + }, nil) }, Resource: ResourcePermissions(), State: map[string]any{ @@ -916,71 +842,66 @@ func TestResourcePermissionsCreate_SQLA_Endpoint(t *testing.T) { func TestResourcePermissionsCreate_SQLA_Endpoint_WithOwnerError(t *testing.T) { d, err := qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: "PUT", - Resource: "/api/2.0/permissions/sql/warehouses/abc", - ExpectedRequest: AccessControlChangeList{ - AccessControlList: []AccessControlChange{ - { - UserName: TestingUser, - PermissionLevel: "CAN_USE", - }, - { - UserName: TestingAdminUser, - PermissionLevel: "IS_OWNER", - }, - { - UserName: TestingAdminUser, - PermissionLevel: "CAN_MANAGE", - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: "admin"}, nil) + e := mwc.GetMockPermissionsAPI().EXPECT() + e.Set(mock.Anything, iam.PermissionsRequest{ + RequestObjectId: "abc", + RequestObjectType: "sql/warehouses", + AccessControlList: []iam.AccessControlRequest{ + { + UserName: TestingUser, + PermissionLevel: "CAN_USE", + }, + { + UserName: TestingAdminUser, + PermissionLevel: "CAN_MANAGE", + }, + { + UserName: TestingAdminUser, + PermissionLevel: "IS_OWNER", }, }, - Response: apierr.APIError{ - ErrorCode: "INVALID_PARAMETER_VALUE", - Message: "PUT requests for warehouse *** with no existing owner must provide a new owner.", - }, - Status: 400, - }, - { - Method: "PUT", - Resource: "/api/2.0/permissions/sql/warehouses/abc", - ExpectedRequest: AccessControlChangeList{ - AccessControlList: []AccessControlChange{ - { - UserName: TestingUser, - PermissionLevel: "CAN_USE", - }, - { - UserName: TestingAdminUser, - PermissionLevel: "CAN_MANAGE", - }, + }).Return(nil, &apierr.APIError{ + ErrorCode: "INVALID_PARAMETER_VALUE", + Message: "PUT requests for warehouse *** with no existing owner must provide a new owner.", + StatusCode: 400, + }) + e.Set(mock.Anything, iam.PermissionsRequest{ + RequestObjectId: "abc", + RequestObjectType: "sql/warehouses", + AccessControlList: []iam.AccessControlRequest{ + { + UserName: TestingUser, + PermissionLevel: "CAN_USE", + }, + { + UserName: TestingAdminUser, + PermissionLevel: "CAN_MANAGE", }, }, - }, - { - Method: http.MethodGet, - Resource: "/api/2.0/permissions/sql/warehouses/abc", - Response: ObjectACL{ - ObjectID: "dashboards/abc", - ObjectType: "dashboard", - AccessControlList: []AccessControl{ - { - UserName: TestingUser, - PermissionLevel: "CAN_USE", - }, - { - UserName: TestingAdminUser, - PermissionLevel: "IS_OWNER", - }, - { - UserName: TestingAdminUser, - PermissionLevel: "CAN_MANAGE", - }, + }).Return(nil, nil) + e.Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "abc", + RequestObjectType: "sql/warehouses", + }).Return(&iam.ObjectPermissions{ + ObjectId: "warehouses/abc", + ObjectType: "warehouses", + AccessControlList: []iam.AccessControlResponse{ + { + UserName: TestingUser, + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelCanUse}}, + }, + { + UserName: TestingAdminUser, + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelCanManage}}, + }, + { + UserName: TestingAdminUser, + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelIsOwner}}, }, }, - }, + }, nil) }, Resource: ResourcePermissions(), State: map[string]any{ @@ -1004,50 +925,48 @@ func TestResourcePermissionsCreate_SQLA_Endpoint_WithOwnerError(t *testing.T) { func TestResourcePermissionsCreate_SQLA_Endpoint_WithOwner(t *testing.T) { d, err := qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: "PUT", - Resource: "/api/2.0/permissions/sql/warehouses/abc", - ExpectedRequest: AccessControlChangeList{ - AccessControlList: []AccessControlChange{ - { - UserName: TestingOwner, - PermissionLevel: "IS_OWNER", - }, - { - UserName: TestingUser, - PermissionLevel: "CAN_USE", - }, - { - UserName: TestingAdminUser, - PermissionLevel: "CAN_MANAGE", - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: "admin"}, nil) + e := mwc.GetMockPermissionsAPI().EXPECT() + e.Set(mock.Anything, iam.PermissionsRequest{ + RequestObjectId: "abc", + RequestObjectType: "sql/warehouses", + AccessControlList: []iam.AccessControlRequest{ + { + UserName: TestingOwner, + PermissionLevel: "IS_OWNER", + }, + { + UserName: TestingUser, + PermissionLevel: "CAN_USE", + }, + { + UserName: TestingAdminUser, + PermissionLevel: "CAN_MANAGE", }, }, - }, - { - Method: http.MethodGet, - Resource: "/api/2.0/permissions/sql/warehouses/abc", - Response: ObjectACL{ - ObjectID: "dashboards/abc", - ObjectType: "dashboard", - AccessControlList: []AccessControl{ - { - UserName: TestingUser, - PermissionLevel: "CAN_USE", - }, - { - UserName: TestingAdminUser, - PermissionLevel: "CAN_MANAGE", - }, - { - UserName: TestingOwner, - PermissionLevel: "IS_OWNER", - }, + }).Return(nil, nil) + e.Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "abc", + RequestObjectType: "sql/warehouses", + }).Return(&iam.ObjectPermissions{ + ObjectId: "warehouses/abc", + ObjectType: "warehouses", + AccessControlList: []iam.AccessControlResponse{ + { + UserName: TestingUser, + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelCanUse}}, + }, + { + UserName: TestingAdminUser, + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelCanManage}}, + }, + { + UserName: TestingOwner, + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelIsOwner}}, }, }, - }, + }, nil) }, Resource: ResourcePermissions(), State: map[string]any{ @@ -1094,17 +1013,12 @@ func TestResourcePermissionsCreate_SQLA_Endpoint_WithOwner(t *testing.T) { func TestResourcePermissionsCreate_NotebookPath_NotExists(t *testing.T) { _, err := qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: http.MethodGet, - Resource: "/api/2.0/workspace/get-status?path=%2FDevelopment%2FInit", - Response: apierr.APIError{ - ErrorCode: "INVALID_REQUEST", - Message: "Internal error happened", - }, - Status: 400, - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockWorkspaceAPI().EXPECT().GetStatusByPath(mock.Anything, "/Development/Init").Return(nil, &apierr.APIError{ + ErrorCode: "INVALID_REQUEST", + Message: "Internal error happened", + StatusCode: 400, + }) }, Resource: ResourcePermissions(), State: map[string]any{ @@ -1112,7 +1026,7 @@ func TestResourcePermissionsCreate_NotebookPath_NotExists(t *testing.T) { "access_control": []any{ map[string]any{ "user_name": TestingUser, - "permission_level": "CAN_USE", + "permission_level": "CAN_READ", }, }, }, @@ -1124,56 +1038,50 @@ func TestResourcePermissionsCreate_NotebookPath_NotExists(t *testing.T) { func TestResourcePermissionsCreate_NotebookPath(t *testing.T) { d, err := qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: http.MethodGet, - Resource: "/api/2.0/workspace/get-status?path=%2FDevelopment%2FInit", - Response: workspace.ObjectStatus{ - ObjectID: 988765, - ObjectType: "NOTEBOOK", - }, - }, - { - Method: http.MethodPut, - Resource: "/api/2.0/permissions/notebooks/988765", - ExpectedRequest: AccessControlChangeList{ - AccessControlList: []AccessControlChange{ - { - UserName: TestingUser, - PermissionLevel: "CAN_READ", - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: "admin"}, nil) + mwc.GetMockWorkspaceAPI().EXPECT().GetStatusByPath(mock.Anything, "/Development/Init").Return(&workspace.ObjectInfo{ + ObjectId: 988765, + ObjectType: workspace.ObjectTypeNotebook, + }, nil) + e := mwc.GetMockPermissionsAPI().EXPECT() + e.Set(mock.Anything, iam.PermissionsRequest{ + RequestObjectId: "988765", + RequestObjectType: "notebooks", + AccessControlList: []iam.AccessControlRequest{ + { + UserName: TestingUser, + PermissionLevel: "CAN_READ", }, }, - }, - { - Method: http.MethodGet, - Resource: "/api/2.0/permissions/notebooks/988765", - Response: ObjectACL{ - ObjectID: "/notebooks/988765", - ObjectType: "notebook", - AccessControlList: []AccessControl{ - { - UserName: TestingUser, - AllPermissions: []Permission{ - { - PermissionLevel: "CAN_READ", - Inherited: false, - }, + }).Return(nil, nil) + e.Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "988765", + RequestObjectType: "notebooks", + }).Return(&iam.ObjectPermissions{ + ObjectId: "/notebooks/988765", + ObjectType: "notebook", + AccessControlList: []iam.AccessControlResponse{ + { + UserName: TestingUser, + AllPermissions: []iam.Permission{ + { + PermissionLevel: "CAN_READ", + Inherited: false, }, }, - { - UserName: TestingAdminUser, - AllPermissions: []Permission{ - { - PermissionLevel: "CAN_MANAGE", - Inherited: false, - }, + }, + { + UserName: TestingAdminUser, + AllPermissions: []iam.Permission{ + { + PermissionLevel: "CAN_MANAGE", + Inherited: false, }, }, }, }, - }, + }, nil) }, Resource: ResourcePermissions(), State: map[string]any{ @@ -1198,56 +1106,50 @@ func TestResourcePermissionsCreate_NotebookPath(t *testing.T) { func TestResourcePermissionsCreate_WorkspaceFilePath(t *testing.T) { d, err := qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: http.MethodGet, - Resource: "/api/2.0/workspace/get-status?path=%2FDevelopment%2FInit", - Response: workspace.ObjectStatus{ - ObjectID: 988765, - ObjectType: workspace.File, - }, - }, - { - Method: http.MethodPut, - Resource: "/api/2.0/permissions/files/988765", - ExpectedRequest: AccessControlChangeList{ - AccessControlList: []AccessControlChange{ - { - UserName: TestingUser, - PermissionLevel: "CAN_READ", - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: "admin"}, nil) + mwc.GetMockWorkspaceAPI().EXPECT().GetStatusByPath(mock.Anything, "/Development/Init").Return(&workspace.ObjectInfo{ + ObjectId: 988765, + ObjectType: workspace.ObjectTypeFile, + }, nil) + e := mwc.GetMockPermissionsAPI().EXPECT() + e.Set(mock.Anything, iam.PermissionsRequest{ + RequestObjectId: "988765", + RequestObjectType: "files", + AccessControlList: []iam.AccessControlRequest{ + { + UserName: TestingUser, + PermissionLevel: "CAN_READ", }, }, - }, - { - Method: http.MethodGet, - Resource: "/api/2.0/permissions/files/988765", - Response: ObjectACL{ - ObjectID: "/files/988765", - ObjectType: "file", - AccessControlList: []AccessControl{ - { - UserName: TestingUser, - AllPermissions: []Permission{ - { - PermissionLevel: "CAN_READ", - Inherited: false, - }, + }).Return(nil, nil) + e.Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "988765", + RequestObjectType: "files", + }).Return(&iam.ObjectPermissions{ + ObjectId: "/files/988765", + ObjectType: "file", + AccessControlList: []iam.AccessControlResponse{ + { + UserName: TestingUser, + AllPermissions: []iam.Permission{ + { + PermissionLevel: "CAN_READ", + Inherited: false, }, }, - { - UserName: TestingAdminUser, - AllPermissions: []Permission{ - { - PermissionLevel: "CAN_MANAGE", - Inherited: false, - }, + }, + { + UserName: TestingAdminUser, + AllPermissions: []iam.Permission{ + { + PermissionLevel: "CAN_MANAGE", + Inherited: false, }, }, }, }, - }, + }, nil) }, Resource: ResourcePermissions(), State: map[string]any{ @@ -1272,18 +1174,6 @@ func TestResourcePermissionsCreate_WorkspaceFilePath(t *testing.T) { func TestResourcePermissionsCreate_error(t *testing.T) { qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: http.MethodPut, - Resource: "/api/2.0/permissions/clusters/abc", - Response: apierr.APIError{ - ErrorCode: "INVALID_REQUEST", - Message: "Internal error happened", - }, - Status: 400, - }, - }, Resource: ResourcePermissions(), State: map[string]any{ "cluster_id": "abc", @@ -1295,14 +1185,17 @@ func TestResourcePermissionsCreate_error(t *testing.T) { }, }, Create: true, - }.ExpectError(t, "permission_level CAN_USE is not supported with cluster_id objects") + }.ExpectError(t, "permission_level CAN_USE is not supported with cluster_id objects; allowed levels: CAN_ATTACH_TO, CAN_MANAGE, CAN_RESTART") } func TestResourcePermissionsCreate_PathIdRetriever_Error(t *testing.T) { qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - qa.HTTPFailures[0], + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockWorkspaceAPI().EXPECT().GetStatusByPath(mock.Anything, "/foo/bar").Return(nil, &apierr.APIError{ + ErrorCode: "INVALID_REQUEST", + Message: "i'm a teapot", + StatusCode: 418, + }) }, Resource: ResourcePermissions(), Create: true, @@ -1317,9 +1210,13 @@ func TestResourcePermissionsCreate_PathIdRetriever_Error(t *testing.T) { func TestResourcePermissionsCreate_ActualUpdate_Error(t *testing.T) { qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - qa.HTTPFailures[0], + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: "admin"}, nil) + mwc.GetMockPermissionsAPI().EXPECT().Set(mock.Anything, mock.Anything).Return(nil, &apierr.APIError{ + ErrorCode: "INVALID_REQUEST", + Message: "i'm a teapot", + StatusCode: 418, + }) }, Resource: ResourcePermissions(), Create: true, @@ -1334,52 +1231,50 @@ func TestResourcePermissionsCreate_ActualUpdate_Error(t *testing.T) { func TestResourcePermissionsUpdate(t *testing.T) { d, err := qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: http.MethodGet, - Resource: "/api/2.0/permissions/jobs/9", - Response: ObjectACL{ - ObjectID: "/jobs/9", - ObjectType: "job", - AccessControlList: []AccessControl{ - { - UserName: TestingUser, - AllPermissions: []Permission{ - { - PermissionLevel: "CAN_VIEW", - Inherited: false, - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: "admin"}, nil) + e := mwc.GetMockPermissionsAPI().EXPECT() + e.Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "9", + RequestObjectType: "jobs", + }).Return(&iam.ObjectPermissions{ + ObjectId: "/jobs/9", + ObjectType: "job", + AccessControlList: []iam.AccessControlResponse{ + { + UserName: TestingUser, + AllPermissions: []iam.Permission{ + { + PermissionLevel: "CAN_VIEW", + Inherited: false, }, }, - { - UserName: TestingAdminUser, - AllPermissions: []Permission{ - { - PermissionLevel: "CAN_MANAGE", - Inherited: false, - }, + }, + { + UserName: TestingAdminUser, + AllPermissions: []iam.Permission{ + { + PermissionLevel: "CAN_MANAGE", + Inherited: false, }, }, }, }, - }, - { - Method: http.MethodPut, - Resource: "/api/2.0/permissions/jobs/9", - ExpectedRequest: AccessControlChangeList{ - AccessControlList: []AccessControlChange{ - { - UserName: TestingUser, - PermissionLevel: "CAN_VIEW", - }, - { - UserName: TestingAdminUser, - PermissionLevel: "IS_OWNER", - }, + }, nil) + e.Set(mock.Anything, iam.PermissionsRequest{ + RequestObjectId: "9", + RequestObjectType: "jobs", + AccessControlList: []iam.AccessControlRequest{ + { + UserName: TestingUser, + PermissionLevel: "CAN_VIEW", + }, + { + UserName: TestingAdminUser, + PermissionLevel: "IS_OWNER", }, }, - }, + }).Return(nil, nil) }, InstanceState: map[string]string{ "job_id": "9", @@ -1405,235 +1300,202 @@ func TestResourcePermissionsUpdate(t *testing.T) { assert.Equal(t, "CAN_VIEW", firstElem["permission_level"]) } +func getResourcePermissions(field, objectType string) resourcePermissions { + for _, mapping := range allResourcePermissions() { + if mapping.field == field && mapping.objectType == objectType { + return mapping + } + } + panic(fmt.Sprintf("could not find resource permissions for field %s and object type %s", field, objectType)) +} + func TestResourcePermissionsUpdateTokensAlwaysThereForAdmins(t *testing.T) { - qa.HTTPFixturesApply(t, []qa.HTTPFixture{ - { - Method: "PUT", - Resource: "/api/2.0/permissions/authorization/tokens", - ExpectedRequest: AccessControlChangeList{ - AccessControlList: []AccessControlChange{ - { - UserName: "me", - PermissionLevel: "CAN_MANAGE", - }, - { - GroupName: "admins", - PermissionLevel: "CAN_MANAGE", - }, + qa.MockWorkspaceApply(t, func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: "me"}, nil) + mwc.GetMockPermissionsAPI().EXPECT().Set(mock.Anything, iam.PermissionsRequest{ + RequestObjectId: "tokens", + RequestObjectType: "authorization", + AccessControlList: []iam.AccessControlRequest{ + { + UserName: "me", + PermissionLevel: "CAN_MANAGE", + }, + { + GroupName: "admins", + PermissionLevel: "CAN_MANAGE", }, }, - }, + }).Return(nil, nil) }, func(ctx context.Context, client *common.DatabricksClient) { p := NewPermissionsAPI(ctx, client) - err := p.Update("/authorization/tokens", AccessControlChangeList{ - AccessControlList: []AccessControlChange{ + mapping := getResourcePermissions("authorization", "tokens") + err := p.Update("/authorization/tokens", entity.PermissionsEntity{ + AccessControlList: []iam.AccessControlRequest{ { UserName: "me", PermissionLevel: "CAN_MANAGE", }, }, - }) + }, mapping) assert.NoError(t, err) }) } func TestShouldKeepAdminsOnAnythingExceptPasswordsAndAssignsOwnerForJob(t *testing.T) { - qa.HTTPFixturesApply(t, []qa.HTTPFixture{ - { - Method: "GET", - Resource: "/api/2.0/permissions/jobs/123", - Response: ObjectACL{ - ObjectID: "/jobs/123", - ObjectType: "job", - AccessControlList: []AccessControl{ - { - GroupName: "admins", - AllPermissions: []Permission{ - { - PermissionLevel: "CAN_DO_EVERYTHING", - Inherited: true, - }, - { - PermissionLevel: "CAN_MANAGE", - Inherited: false, - }, + qa.MockWorkspaceApply(t, func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockJobsAPI().EXPECT().GetByJobId(mock.Anything, int64(123)).Return(&jobs.Job{ + CreatorUserName: "creator@example.com", + }, nil) + e := mwc.GetMockPermissionsAPI().EXPECT() + e.Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "123", + RequestObjectType: "jobs", + }).Return(&iam.ObjectPermissions{ + ObjectId: "/jobs/123", + ObjectType: "job", + AccessControlList: []iam.AccessControlResponse{ + { + GroupName: "admins", + AllPermissions: []iam.Permission{ + { + PermissionLevel: "CAN_DO_EVERYTHING", + Inherited: true, + }, + { + PermissionLevel: "CAN_MANAGE", + Inherited: false, }, }, }, }, - }, - { - Method: "GET", - Resource: "/api/2.1/jobs/get?job_id=123", - Response: jobs.Job{ - CreatorUserName: "creator@example.com", - }, - }, - { - Method: "PUT", - Resource: "/api/2.0/permissions/jobs/123", - ExpectedRequest: ObjectACL{ - AccessControlList: []AccessControl{ - { - GroupName: "admins", - PermissionLevel: "CAN_MANAGE", - }, - { - UserName: "creator@example.com", - PermissionLevel: "IS_OWNER", - }, + }, nil) + e.Set(mock.Anything, iam.PermissionsRequest{ + RequestObjectId: "123", + RequestObjectType: "jobs", + AccessControlList: []iam.AccessControlRequest{ + { + GroupName: "admins", + PermissionLevel: "CAN_MANAGE", + }, + { + UserName: "creator@example.com", + PermissionLevel: "IS_OWNER", }, }, - }, + }).Return(nil, nil) }, func(ctx context.Context, client *common.DatabricksClient) { p := NewPermissionsAPI(ctx, client) - err := p.Delete("/jobs/123") + mapping := getResourcePermissions("job_id", "job") + err := p.Delete("/jobs/123", mapping) assert.NoError(t, err) }) } func TestShouldDeleteNonExistentJob(t *testing.T) { - qa.HTTPFixturesApply(t, []qa.HTTPFixture{ - { - Method: "GET", - Resource: "/api/2.0/permissions/jobs/123", - Response: ObjectACL{ - ObjectID: "/jobs/123", - ObjectType: "job", - AccessControlList: []AccessControl{ - { - GroupName: "admins", - AllPermissions: []Permission{ - { - PermissionLevel: "CAN_DO_EVERYTHING", - Inherited: true, - }, - { - PermissionLevel: "CAN_MANAGE", - Inherited: false, - }, + qa.MockWorkspaceApply(t, func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockPermissionsAPI().EXPECT().Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "123", + RequestObjectType: "jobs", + }).Return(&iam.ObjectPermissions{ + ObjectId: "/jobs/123", + ObjectType: "job", + AccessControlList: []iam.AccessControlResponse{ + { + GroupName: "admins", + AllPermissions: []iam.Permission{ + { + PermissionLevel: "CAN_DO_EVERYTHING", + Inherited: true, + }, + { + PermissionLevel: "CAN_MANAGE", + Inherited: false, }, }, }, }, - }, - { - Method: "GET", - Resource: "/api/2.1/jobs/get?job_id=123", - Status: 400, - Response: apierr.APIError{ - StatusCode: 400, - Message: "Job 123 does not exist.", - ErrorCode: "INVALID_PARAMETER_VALUE", - }, - }, + }, nil) + mwc.GetMockJobsAPI().EXPECT().GetByJobId(mock.Anything, int64(123)).Return(nil, &apierr.APIError{ + StatusCode: 400, + Message: "Job 123 does not exist.", + ErrorCode: "INVALID_PARAMETER_VALUE", + }) }, func(ctx context.Context, client *common.DatabricksClient) { p := NewPermissionsAPI(ctx, client) - err := p.Delete("/jobs/123") + mapping := getResourcePermissions("job_id", "job") + err := p.Delete("/jobs/123", mapping) assert.NoError(t, err) }) } func TestShouldKeepAdminsOnAnythingExceptPasswordsAndAssignsOwnerForPipeline(t *testing.T) { - qa.HTTPFixturesApply(t, []qa.HTTPFixture{ - { - Method: "GET", - Resource: "/api/2.0/permissions/pipelines/123", - Response: ObjectACL{ - ObjectID: "/pipelines/123", - ObjectType: "pipeline", - AccessControlList: []AccessControl{ - { - GroupName: "admins", - AllPermissions: []Permission{ - { - PermissionLevel: "CAN_DO_EVERYTHING", - Inherited: true, - }, - { - PermissionLevel: "CAN_MANAGE", - Inherited: false, - }, + qa.MockWorkspaceApply(t, func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockPipelinesAPI().EXPECT().GetByPipelineId(mock.Anything, "123").Return(&pipelines.GetPipelineResponse{ + CreatorUserName: "creator@example.com", + }, nil) + e := mwc.GetMockPermissionsAPI().EXPECT() + e.Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "123", + RequestObjectType: "pipelines", + }).Return(&iam.ObjectPermissions{ + ObjectId: "/pipelines/123", + ObjectType: "pipeline", + AccessControlList: []iam.AccessControlResponse{ + { + GroupName: "admins", + AllPermissions: []iam.Permission{ + { + PermissionLevel: "CAN_DO_EVERYTHING", + Inherited: true, + }, + { + PermissionLevel: "CAN_MANAGE", + Inherited: false, }, }, }, }, - }, - { - Method: "GET", - Resource: "/api/2.0/pipelines/123?", - Response: jobs.Job{ - CreatorUserName: "creator@example.com", - }, - }, - { - Method: "PUT", - Resource: "/api/2.0/permissions/pipelines/123", - ExpectedRequest: ObjectACL{ - AccessControlList: []AccessControl{ - { - GroupName: "admins", - PermissionLevel: "CAN_MANAGE", - }, - { - UserName: "creator@example.com", - PermissionLevel: "IS_OWNER", - }, + }, nil) + e.Set(mock.Anything, iam.PermissionsRequest{ + RequestObjectId: "123", + RequestObjectType: "pipelines", + AccessControlList: []iam.AccessControlRequest{ + { + GroupName: "admins", + PermissionLevel: "CAN_MANAGE", + }, + { + UserName: "creator@example.com", + PermissionLevel: "IS_OWNER", }, }, - }, + }).Return(nil, nil) }, func(ctx context.Context, client *common.DatabricksClient) { p := NewPermissionsAPI(ctx, client) - err := p.Delete("/pipelines/123") + mapping := getResourcePermissions("pipeline_id", "pipelines") + err := p.Delete("/pipelines/123", mapping) assert.NoError(t, err) }) } func TestPathPermissionsResourceIDFields(t *testing.T) { - var m permissionsIDFieldMapping - for _, x := range permissionsResourceIDFields() { - if x.field == "notebook_path" { - m = x - } - } + m := getResourcePermissions("notebook_path", "notebook") w, err := databricks.NewWorkspaceClient(&databricks.Config{}) require.NoError(t, err) _, err = m.idRetriever(context.Background(), w, "x") assert.ErrorContains(t, err, "cannot load path x") } -func TestObjectACLToPermissionsEntityCornerCases(t *testing.T) { - _, err := (&ObjectACL{ - ObjectType: "bananas", - AccessControlList: []AccessControl{ - { - GroupName: "admins", - }, - }, - }).ToPermissionsEntity(ResourcePermissions().ToResource().TestResourceData(), "me") - assert.EqualError(t, err, "unknown object type bananas") -} - -func TestEntityAccessControlToAccessControlChange(t *testing.T) { - _, res := AccessControl{}.toAccessControlChange() - assert.False(t, res) -} - -func TestCornerCases(t *testing.T) { - qa.ResourceCornerCases(t, ResourcePermissions(), qa.CornerCaseSkipCRUD("create")) -} - func TestDeleteMissing(t *testing.T) { - qa.HTTPFixturesApply(t, []qa.HTTPFixture{ - { - MatchAny: true, - Status: 404, - Response: apierr.NotFound("missing"), - }, + qa.MockWorkspaceApply(t, func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockPermissionsAPI().EXPECT().Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "x", + RequestObjectType: "clusters", + }).Return(nil, apierr.ErrNotFound) }, func(ctx context.Context, client *common.DatabricksClient) { p := ResourcePermissions().ToResource() d := p.TestResourceData() - d.SetId("x") + d.SetId("/clusters/x") diags := p.DeleteContext(ctx, d, client) assert.Nil(t, diags) }) @@ -1641,65 +1503,59 @@ func TestDeleteMissing(t *testing.T) { func TestResourcePermissionsCreate_RepoPath(t *testing.T) { d, err := qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: http.MethodGet, - Resource: "/api/2.0/workspace/get-status?path=%2FRepos%2FDevelopment%2FInit", - Response: workspace.ObjectStatus{ - ObjectID: 988765, - ObjectType: "repo", - }, - }, - { - Method: http.MethodPut, - Resource: "/api/2.0/permissions/repos/988765", - ExpectedRequest: AccessControlChangeList{ - AccessControlList: []AccessControlChange{ - { - UserName: TestingUser, - PermissionLevel: "CAN_READ", - }, - }, - }, - }, - { - Method: http.MethodGet, - Resource: "/api/2.0/permissions/repos/988765", - Response: ObjectACL{ - ObjectID: "/repos/988765", - ObjectType: "repo", - AccessControlList: []AccessControl{ - { - UserName: TestingUser, - AllPermissions: []Permission{ - { - PermissionLevel: "CAN_READ", - Inherited: false, - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: TestingAdminUser}, nil) + mwc.GetMockWorkspaceAPI().EXPECT().GetStatusByPath(mock.Anything, "/Repos/Development/Init").Return(&workspace.ObjectInfo{ + ObjectId: 988765, + ObjectType: workspace.ObjectTypeRepo, + }, nil) + e := mwc.GetMockPermissionsAPI().EXPECT() + e.Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "988765", + RequestObjectType: "repos", + }).Return(&iam.ObjectPermissions{ + ObjectId: "/repos/988765", + ObjectType: "repo", + AccessControlList: []iam.AccessControlResponse{ + { + UserName: TestingUser, + AllPermissions: []iam.Permission{ + { + PermissionLevel: "CAN_READ", + Inherited: false, }, }, - { - UserName: TestingAdminUser, - AllPermissions: []Permission{ - { - PermissionLevel: "CAN_RUN", - Inherited: false, - }, + }, + { + UserName: TestingAdminUser, + AllPermissions: []iam.Permission{ + { + PermissionLevel: "CAN_RUN", + Inherited: false, }, }, - { - UserName: TestingAdminUser, - AllPermissions: []Permission{ - { - PermissionLevel: "CAN_MANAGE", - Inherited: false, - }, + }, + { + UserName: TestingAdminUser, + AllPermissions: []iam.Permission{ + { + PermissionLevel: "CAN_MANAGE", + Inherited: false, }, }, }, }, - }, + }, nil) + e.Set(mock.Anything, iam.PermissionsRequest{ + RequestObjectId: "988765", + RequestObjectType: "repos", + AccessControlList: []iam.AccessControlRequest{ + { + UserName: TestingUser, + PermissionLevel: "CAN_READ", + }, + }, + }).Return(nil, nil) }, Resource: ResourcePermissions(), State: map[string]any{ @@ -1725,42 +1581,40 @@ func TestResourcePermissionsCreate_RepoPath(t *testing.T) { // when caller does not specify CAN_MANAGE permission during create, it should be explictly added func TestResourcePermissionsCreate_Sql_Queries(t *testing.T) { d, err := qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: http.MethodPost, - Resource: "/api/2.0/preview/sql/permissions/queries/id111", - ExpectedRequest: AccessControlChangeList{ - AccessControlList: []AccessControlChange{ - { - UserName: TestingUser, - PermissionLevel: "CAN_RUN", - }, - { - UserName: TestingAdminUser, - PermissionLevel: "CAN_MANAGE", - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: TestingAdminUser}, nil) + e := mwc.GetMockPermissionsAPI().EXPECT() + e.Set(mock.Anything, iam.PermissionsRequest{ + RequestObjectId: "id111", + RequestObjectType: "sql/queries", + AccessControlList: []iam.AccessControlRequest{ + { + UserName: TestingUser, + PermissionLevel: "CAN_RUN", + }, + { + UserName: TestingAdminUser, + PermissionLevel: "CAN_MANAGE", }, }, - }, - { - Method: http.MethodGet, - Resource: "/api/2.0/preview/sql/permissions/queries/id111", - Response: ObjectACL{ - ObjectID: "queries/id111", - ObjectType: "query", - AccessControlList: []AccessControl{ - { - UserName: TestingUser, - PermissionLevel: "CAN_RUN", - }, - { - UserName: TestingAdminUser, - PermissionLevel: "CAN_MANAGE", - }, + }).Return(nil, nil) + e.Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "id111", + RequestObjectType: "sql/queries", + }).Return(&iam.ObjectPermissions{ + ObjectId: "queries/id111", + ObjectType: "query", + AccessControlList: []iam.AccessControlResponse{ + { + UserName: TestingUser, + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelCanRun}}, + }, + { + UserName: TestingAdminUser, + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelCanManage}}, }, }, - }, + }, nil) }, Resource: ResourcePermissions(), State: map[string]any{ @@ -1785,42 +1639,40 @@ func TestResourcePermissionsCreate_Sql_Queries(t *testing.T) { // when caller does not specify CAN_MANAGE permission during update, it should be explictly added func TestResourcePermissionsUpdate_Sql_Queries(t *testing.T) { d, err := qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: http.MethodPost, - Resource: "/api/2.0/preview/sql/permissions/queries/id111", - ExpectedRequest: AccessControlChangeList{ - AccessControlList: []AccessControlChange{ - { - UserName: TestingUser, - PermissionLevel: "CAN_RUN", - }, - { - UserName: TestingAdminUser, - PermissionLevel: "CAN_MANAGE", - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: TestingAdminUser}, nil) + e := mwc.GetMockPermissionsAPI().EXPECT() + e.Set(mock.Anything, iam.PermissionsRequest{ + RequestObjectId: "id111", + RequestObjectType: "sql/queries", + AccessControlList: []iam.AccessControlRequest{ + { + UserName: TestingUser, + PermissionLevel: "CAN_RUN", + }, + { + UserName: TestingAdminUser, + PermissionLevel: "CAN_MANAGE", }, }, - }, - { - Method: http.MethodGet, - Resource: "/api/2.0/preview/sql/permissions/queries/id111", - Response: ObjectACL{ - ObjectID: "queries/id111", - ObjectType: "query", - AccessControlList: []AccessControl{ - { - UserName: TestingUser, - PermissionLevel: "CAN_RUN", - }, - { - UserName: TestingAdminUser, - PermissionLevel: "CAN_MANAGE", - }, + }).Return(nil, nil) + e.Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "id111", + RequestObjectType: "sql/queries", + }).Return(&iam.ObjectPermissions{ + ObjectId: "queries/id111", + ObjectType: "query", + AccessControlList: []iam.AccessControlResponse{ + { + UserName: TestingUser, + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelCanRun}}, + }, + { + UserName: TestingAdminUser, + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelCanManage}}, }, }, - }, + }, nil) }, InstanceState: map[string]string{ "sql_query_id": "id111", @@ -1847,65 +1699,59 @@ func TestResourcePermissionsUpdate_Sql_Queries(t *testing.T) { func TestResourcePermissionsCreate_DirectoryPath(t *testing.T) { d, err := qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: http.MethodGet, - Resource: "/api/2.0/workspace/get-status?path=%2FFirst", - Response: workspace.ObjectStatus{ - ObjectID: 123456, - ObjectType: "directory", - }, - }, - { - Method: http.MethodPut, - Resource: "/api/2.0/permissions/directories/123456", - ExpectedRequest: AccessControlChangeList{ - AccessControlList: []AccessControlChange{ - { - UserName: TestingUser, - PermissionLevel: "CAN_READ", - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: TestingAdminUser}, nil) + mwc.GetMockWorkspaceAPI().EXPECT().GetStatusByPath(mock.Anything, "/First").Return(&workspace.ObjectInfo{ + ObjectId: 123456, + ObjectType: workspace.ObjectTypeDirectory, + }, nil) + e := mwc.GetMockPermissionsAPI().EXPECT() + e.Set(mock.Anything, iam.PermissionsRequest{ + RequestObjectId: "123456", + RequestObjectType: "directories", + AccessControlList: []iam.AccessControlRequest{ + { + UserName: TestingUser, + PermissionLevel: "CAN_READ", }, }, - }, - { - Method: http.MethodGet, - Resource: "/api/2.0/permissions/directories/123456", - Response: ObjectACL{ - ObjectID: "/directories/123456", - ObjectType: "directory", - AccessControlList: []AccessControl{ - { - UserName: TestingUser, - AllPermissions: []Permission{ - { - PermissionLevel: "CAN_READ", - Inherited: false, - }, + }).Return(nil, nil) + e.Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "123456", + RequestObjectType: "directories", + }).Return(&iam.ObjectPermissions{ + ObjectId: "/directories/123456", + ObjectType: "directory", + AccessControlList: []iam.AccessControlResponse{ + { + UserName: TestingUser, + AllPermissions: []iam.Permission{ + { + PermissionLevel: "CAN_READ", + Inherited: false, }, }, - { - UserName: TestingAdminUser, - AllPermissions: []Permission{ - { - PermissionLevel: "CAN_RUN", - Inherited: false, - }, + }, + { + UserName: TestingAdminUser, + AllPermissions: []iam.Permission{ + { + PermissionLevel: "CAN_RUN", + Inherited: false, }, }, - { - UserName: TestingAdminUser, - AllPermissions: []Permission{ - { - PermissionLevel: "CAN_MANAGE", - Inherited: false, - }, + }, + { + UserName: TestingAdminUser, + AllPermissions: []iam.Permission{ + { + PermissionLevel: "CAN_MANAGE", + Inherited: false, }, }, }, }, - }, + }, nil) }, Resource: ResourcePermissions(), State: map[string]any{ @@ -1930,34 +1776,32 @@ func TestResourcePermissionsCreate_DirectoryPath(t *testing.T) { func TestResourcePermissionsPasswordUsage(t *testing.T) { d, err := qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: http.MethodPut, - Resource: "/api/2.0/permissions/authorization/passwords", - ExpectedRequest: AccessControlChangeList{ - AccessControlList: []AccessControlChange{ - { - GroupName: "admins", - PermissionLevel: "CAN_USE", - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: TestingAdminUser}, nil) + e := mwc.GetMockPermissionsAPI().EXPECT() + e.Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "passwords", + RequestObjectType: "authorization", + }).Return(&iam.ObjectPermissions{ + ObjectId: "/authorization/passwords", + ObjectType: "passwords", + AccessControlList: []iam.AccessControlResponse{ + { + GroupName: "admins", + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelCanUse}}, }, }, - }, - { - Method: http.MethodGet, - Resource: "/api/2.0/permissions/authorization/passwords", - Response: ObjectACL{ - ObjectID: "/authorization/passwords", - ObjectType: "passwords", - AccessControlList: []AccessControl{ - { - GroupName: "admins", - PermissionLevel: "CAN_USE", - }, + }, nil) + e.Set(mock.Anything, iam.PermissionsRequest{ + RequestObjectId: "passwords", + RequestObjectType: "authorization", + AccessControlList: []iam.AccessControlRequest{ + { + GroupName: "admins", + PermissionLevel: "CAN_USE", }, }, - }, + }).Return(nil, nil) }, Resource: ResourcePermissions(), HCL: ` @@ -1979,42 +1823,40 @@ func TestResourcePermissionsPasswordUsage(t *testing.T) { func TestResourcePermissionsRootDirectory(t *testing.T) { d, err := qa.ResourceFixture{ - Fixtures: []qa.HTTPFixture{ - me, - { - Method: http.MethodPut, - Resource: "/api/2.0/permissions/directories/0", - ExpectedRequest: AccessControlChangeList{ - AccessControlList: []AccessControlChange{ - { - UserName: TestingUser, - PermissionLevel: "CAN_READ", - }, - { - GroupName: "admins", - PermissionLevel: "CAN_MANAGE", - }, + MockWorkspaceClientFunc: func(mwc *mocks.MockWorkspaceClient) { + mwc.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything).Return(&iam.User{UserName: TestingAdminUser}, nil) + e := mwc.GetMockPermissionsAPI().EXPECT() + e.Get(mock.Anything, iam.GetPermissionRequest{ + RequestObjectId: "0", + RequestObjectType: "directories", + }).Return(&iam.ObjectPermissions{ + ObjectId: "/directories/0", + ObjectType: "directory", + AccessControlList: []iam.AccessControlResponse{ + { + UserName: TestingUser, + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelCanRead}}, + }, + { + GroupName: "admins", + AllPermissions: []iam.Permission{{PermissionLevel: iam.PermissionLevelCanManage}}, }, }, - }, - { - Method: http.MethodGet, - Resource: "/api/2.0/permissions/directories/0", - Response: ObjectACL{ - ObjectID: "/directories/0", - ObjectType: "directory", - AccessControlList: []AccessControl{ - { - UserName: TestingUser, - PermissionLevel: "CAN_READ", - }, - { - GroupName: "admins", - PermissionLevel: "CAN_MANAGE", - }, + }, nil) + e.Set(mock.Anything, iam.PermissionsRequest{ + RequestObjectId: "0", + RequestObjectType: "directories", + AccessControlList: []iam.AccessControlRequest{ + { + UserName: TestingUser, + PermissionLevel: "CAN_READ", + }, + { + GroupName: "admins", + PermissionLevel: "CAN_MANAGE", }, }, - }, + }).Return(nil, nil) }, Resource: ResourcePermissions(), HCL: ` diff --git a/permissions/update/customizers.go b/permissions/update/customizers.go new file mode 100644 index 0000000000..ea2c5dd5db --- /dev/null +++ b/permissions/update/customizers.go @@ -0,0 +1,97 @@ +package update + +import ( + "github.com/databricks/databricks-sdk-go/service/iam" +) + +// Context that is available to aclUpdateCustomizer implementations. +type ACLCustomizerContext struct { + GetCurrentUser func() (string, error) + GetId func() string +} + +// ACLCustomizer is a function that modifies the access control list of an object before it is updated. +type ACLCustomizer func(ctx ACLCustomizerContext, objectAcls []iam.AccessControlRequest) ([]iam.AccessControlRequest, error) + +// If applies ths customizer if the condition is true. +func If(condition func(ACLCustomizerContext, []iam.AccessControlRequest) bool, customizer ACLCustomizer) ACLCustomizer { + return func(ctx ACLCustomizerContext, acl []iam.AccessControlRequest) ([]iam.AccessControlRequest, error) { + if condition(ctx, acl) { + return customizer(ctx, acl) + } + return acl, nil + } +} + +func Not(condition func(ACLCustomizerContext, []iam.AccessControlRequest) bool) func(ACLCustomizerContext, []iam.AccessControlRequest) bool { + return func(ctx ACLCustomizerContext, acl []iam.AccessControlRequest) bool { + return !condition(ctx, acl) + } +} + +// ObjectIdMatches returns a condition that checks if the object ID matches the expected value. +func ObjectIdMatches(expected string) func(ACLCustomizerContext, []iam.AccessControlRequest) bool { + return func(ctx ACLCustomizerContext, acl []iam.AccessControlRequest) bool { + return ctx.GetId() == expected + } +} + +// AddAdmin adds an explicit CAN_MANAGE permission for the 'admins' group if explicitAdminPermissionCheck returns true +// for the provided object ID. +func AddAdmin(ctx ACLCustomizerContext, acl []iam.AccessControlRequest) ([]iam.AccessControlRequest, error) { + found := false + for _, acl := range acl { + if acl.GroupName == "admins" { + found = true + break + } + } + if !found { + // Prevent "Cannot change permissions for group 'admins' to None." + acl = append(acl, iam.AccessControlRequest{ + GroupName: "admins", + PermissionLevel: "CAN_MANAGE", + }) + } + return acl, nil +} + +// Whether the object requires explicit manage permissions for the calling user if not set. +// As described in https://github.com/databricks/terraform-provider-databricks/issues/1504, +// certain object types require that we explicitly grant the calling user CAN_MANAGE +// permissions when POSTing permissions changes through the REST API, to avoid accidentally +// revoking the calling user's ability to manage the current object. +func AddCurrentUserAsManage(ctx ACLCustomizerContext, acl []iam.AccessControlRequest) ([]iam.AccessControlRequest, error) { + currentUser, err := ctx.GetCurrentUser() + if err != nil { + return nil, err + } + // The validate() method called in Update() ensures that the current user's permissions are either CAN_MANAGE + // or IS_OWNER if they are specified. If the current user is not specified in the access control list, we add + // them with CAN_MANAGE permissions. + found := false + for _, acl := range acl { + if acl.UserName == currentUser || acl.ServicePrincipalName == currentUser { + found = true + break + } + } + if !found { + acl = append(acl, iam.AccessControlRequest{ + UserName: currentUser, + PermissionLevel: "CAN_MANAGE", + }) + } + return acl, nil +} + +func RewritePermissions(mapping map[iam.PermissionLevel]iam.PermissionLevel) ACLCustomizer { + return func(ctx ACLCustomizerContext, acl []iam.AccessControlRequest) ([]iam.AccessControlRequest, error) { + for i := range acl { + if new, ok := mapping[acl[i].PermissionLevel]; ok { + acl[i].PermissionLevel = new + } + } + return acl, nil + } +}