diff --git a/acceptance/bundle/invariant/configs/cluster_policy.yml.tmpl b/acceptance/bundle/invariant/configs/cluster_policy.yml.tmpl index aa514b0e0d..35c1dd4a02 100644 --- a/acceptance/bundle/invariant/configs/cluster_policy.yml.tmpl +++ b/acceptance/bundle/invariant/configs/cluster_policy.yml.tmpl @@ -6,3 +6,6 @@ resources: foo: name: test-cluster-policy-$UNIQUE_NAME definition: '{"spark_version":{"type":"fixed","value":"13.3.x-scala2.12"}}' + permissions: + - level: CAN_USE + group_name: users diff --git a/acceptance/bundle/refschema/out.fields.txt b/acceptance/bundle/refschema/out.fields.txt index f04d141667..0add568eec 100644 --- a/acceptance/bundle/refschema/out.fields.txt +++ b/acceptance/bundle/refschema/out.fields.txt @@ -313,6 +313,12 @@ resources.cluster_policies.*.policy_family_definition_overrides string ALL resources.cluster_policies.*.policy_family_id string ALL resources.cluster_policies.*.policy_id string REMOTE resources.cluster_policies.*.url string INPUT +resources.cluster_policies.*.permissions.object_id string ALL +resources.cluster_policies.*.permissions[*] dresources.StatePermission ALL +resources.cluster_policies.*.permissions[*].group_name string ALL +resources.cluster_policies.*.permissions[*].level iam.PermissionLevel ALL +resources.cluster_policies.*.permissions[*].service_principal_name string ALL +resources.cluster_policies.*.permissions[*].user_name string ALL resources.clusters.*.apply_policy_default_values bool ALL resources.clusters.*.autoscale *compute.AutoScale ALL resources.clusters.*.autoscale.max_workers int ALL diff --git a/acceptance/bundle/resources/cluster_policies/permissions/basic/databricks.yml b/acceptance/bundle/resources/cluster_policies/permissions/basic/databricks.yml new file mode 100644 index 0000000000..7fd7091647 --- /dev/null +++ b/acceptance/bundle/resources/cluster_policies/permissions/basic/databricks.yml @@ -0,0 +1,13 @@ +bundle: + name: test_cluster_policy_permissions + +resources: + cluster_policies: + test_cluster_policy: + name: my_cluster_policy + definition: '{"spark_version":{"type":"fixed","value":"13.3.x-scala2.12"}}' + permissions: + - level: CAN_USE + group_name: users + - level: CAN_USE # TO_REMOVE + user_name: viewer@example.com # TO_REMOVE diff --git a/acceptance/bundle/resources/cluster_policies/permissions/basic/out.test.toml b/acceptance/bundle/resources/cluster_policies/permissions/basic/out.test.toml new file mode 100644 index 0000000000..0938e67898 --- /dev/null +++ b/acceptance/bundle/resources/cluster_policies/permissions/basic/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/cluster_policies/permissions/basic/output.txt b/acceptance/bundle/resources/cluster_policies/permissions/basic/output.txt new file mode 100644 index 0000000000..b84045576c --- /dev/null +++ b/acceptance/bundle/resources/cluster_policies/permissions/basic/output.txt @@ -0,0 +1,84 @@ + +=== Validate: permissions parse onto the cluster policy +>>> [CLI] bundle validate -o json +[ + { + "group_name": "users", + "level": "CAN_USE" + }, + { + "level": "CAN_USE", + "user_name": "viewer@example.com" + } +] + +=== Deploy: policy created and CAN_USE grants applied +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test_cluster_policy_permissions/default/files... +Created cluster_policies.test_cluster_policy +Created cluster_policies.test_cluster_policy.permissions +Files: 5 uploaded, 0 deleted +Resources: 2 created, 0 changed, 0 deleted, 0 unchanged + +>>> print_requests.py //permissions/cluster-policies +{ + "method": "PUT", + "path": "/api/2.0/permissions/cluster-policies/[UUID]", + "body": { + "access_control_list": [ + { + "group_name": "users", + "permission_level": "CAN_USE" + }, + { + "permission_level": "CAN_USE", + "user_name": "viewer@example.com" + } + ] + } +} + +>>> [CLI] bundle summary +Name: test_cluster_policy_permissions +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test_cluster_policy_permissions/default +Resources: + Cluster Policies: + test_cluster_policy: + Name: my_cluster_policy + URL: [DATABRICKS_URL]/compute/policies/[UUID]?w=[NUMID] + +=== Plan is a no-op immediately after deploy +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged + +=== Remove one grant and redeploy: the ACL is set again without it +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test_cluster_policy_permissions/default/files... +Updated cluster_policies.test_cluster_policy.permissions +Files: 3 uploaded, 0 deleted +Resources: 0 created, 1 changed, 0 deleted, 1 unchanged + +>>> print_requests.py //permissions/cluster-policies +{ + "method": "PUT", + "path": "/api/2.0/permissions/cluster-policies/[UUID]", + "body": { + "access_control_list": [ + { + "group_name": "users", + "permission_level": "CAN_USE" + } + ] + } +} + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.cluster_policies.test_cluster_policy + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test_cluster_policy_permissions/default + +Destroy: 1 deleted diff --git a/acceptance/bundle/resources/cluster_policies/permissions/basic/script b/acceptance/bundle/resources/cluster_policies/permissions/basic/script new file mode 100644 index 0000000000..6184d90781 --- /dev/null +++ b/acceptance/bundle/resources/cluster_policies/permissions/basic/script @@ -0,0 +1,22 @@ +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +title "Validate: permissions parse onto the cluster policy" +trace $CLI bundle validate -o json | jq ".resources.cluster_policies.test_cluster_policy.permissions" + +title "Deploy: policy created and CAN_USE grants applied" +trace $CLI bundle deploy +trace print_requests.py //permissions/cluster-policies + +trace $CLI bundle summary + +title "Plan is a no-op immediately after deploy" +trace $CLI bundle plan + +title "Remove one grant and redeploy: the ACL is set again without it" +grep -v TO_REMOVE databricks.yml > updated.yml && mv updated.yml databricks.yml +trace $CLI bundle deploy +trace print_requests.py //permissions/cluster-policies diff --git a/acceptance/bundle/resources/cluster_policies/permissions/basic/test.toml b/acceptance/bundle/resources/cluster_policies/permissions/basic/test.toml new file mode 100644 index 0000000000..cba9a272ef --- /dev/null +++ b/acceptance/bundle/resources/cluster_policies/permissions/basic/test.toml @@ -0,0 +1 @@ +Ignore = [".databricks", "databricks.yml", "updated.yml"] diff --git a/acceptance/bundle/resources/cluster_policies/permissions/levels/databricks.yml.tmpl b/acceptance/bundle/resources/cluster_policies/permissions/levels/databricks.yml.tmpl new file mode 100644 index 0000000000..8ebdcb8c36 --- /dev/null +++ b/acceptance/bundle/resources/cluster_policies/permissions/levels/databricks.yml.tmpl @@ -0,0 +1,11 @@ +bundle: + name: cluster-policy-permission-levels-$UNIQUE_NAME + +resources: + cluster_policies: + test_cluster_policy: + name: my_cluster_policy-$UNIQUE_NAME + definition: '{"spark_version":{"type":"fixed","value":"13.3.x-scala2.12"}}' + permissions: + - level: CAN_USE + group_name: users diff --git a/acceptance/bundle/resources/cluster_policies/permissions/levels/out.test.toml b/acceptance/bundle/resources/cluster_policies/permissions/levels/out.test.toml new file mode 100644 index 0000000000..c502b28221 --- /dev/null +++ b/acceptance/bundle/resources/cluster_policies/permissions/levels/out.test.toml @@ -0,0 +1,2 @@ +Cloud = true +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/cluster_policies/permissions/levels/output.txt b/acceptance/bundle/resources/cluster_policies/permissions/levels/output.txt new file mode 100644 index 0000000000..b6a65434bf --- /dev/null +++ b/acceptance/bundle/resources/cluster_policies/permissions/levels/output.txt @@ -0,0 +1,30 @@ + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/cluster-policy-permission-levels-[UNIQUE_NAME]/default/files... +Created cluster_policies.test_cluster_policy +Created cluster_policies.test_cluster_policy.permissions +Files: 6 uploaded, 0 deleted +Resources: 2 created, 0 changed, 0 deleted, 0 unchanged + +>>> print_requests.py //permissions/cluster-policies +{ + "method": "PUT", + "path": "/api/2.0/permissions/cluster-policies/[TEST_CLUSTER_POLICY_ID]", + "body": { + "access_control_list": [ + { + "group_name": "users", + "permission_level": "CAN_USE" + } + ] + } +} + +=== CAN_MANAGE is rejected +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.cluster_policies.test_cluster_policy + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/cluster-policy-permission-levels-[UNIQUE_NAME]/default + +Destroy: 1 deleted diff --git a/acceptance/bundle/resources/cluster_policies/permissions/levels/script b/acceptance/bundle/resources/cluster_policies/permissions/levels/script new file mode 100644 index 0000000000..f5f2b3e6a2 --- /dev/null +++ b/acceptance/bundle/resources/cluster_policies/permissions/levels/script @@ -0,0 +1,19 @@ +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +envsubst < databricks.yml.tmpl > databricks.yml + +# CAN_USE is the only level cluster policies accept: the deploy succeeds and the +# permissions PUT carries CAN_USE. +trace $CLI bundle deploy +policy_id="$(read_id.py test_cluster_policy)" +trace print_requests.py //permissions/cluster-policies + +# CAN_MANAGE is rejected by the permissions API. The backend error text differs +# between the fake server and a real workspace, so it is routed to a LOG file; +# musterr asserts only that the request fails. +title "CAN_MANAGE is rejected" +musterr $CLI cluster-policies set-permissions "$policy_id" --json '{"access_control_list":[{"group_name":"users","permission_level":"CAN_MANAGE"}]}' &> LOG.can_manage diff --git a/acceptance/bundle/resources/cluster_policies/permissions/levels/test.toml b/acceptance/bundle/resources/cluster_policies/permissions/levels/test.toml new file mode 100644 index 0000000000..3dd59d27d6 --- /dev/null +++ b/acceptance/bundle/resources/cluster_policies/permissions/levels/test.toml @@ -0,0 +1,2 @@ +Cloud = true +Ignore = [".databricks", "databricks.yml"] diff --git a/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_change/databricks.yml b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_change/databricks.yml new file mode 100644 index 0000000000..0c151e08e5 --- /dev/null +++ b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_change/databricks.yml @@ -0,0 +1,11 @@ +bundle: + name: test_cluster_policy_permissions_drift + +resources: + cluster_policies: + test_cluster_policy: + name: my_cluster_policy + definition: '{"spark_version":{"type":"fixed","value":"13.3.x-scala2.12"}}' + permissions: + - level: CAN_USE + group_name: users diff --git a/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_change/out.test.toml b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_change/out.test.toml new file mode 100644 index 0000000000..0938e67898 --- /dev/null +++ b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_change/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_change/output.txt b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_change/output.txt new file mode 100644 index 0000000000..3fd663498d --- /dev/null +++ b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_change/output.txt @@ -0,0 +1,78 @@ + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test_cluster_policy_permissions_drift/default/files... +Created cluster_policies.test_cluster_policy +Created cluster_policies.test_cluster_policy.permissions +Files: 5 uploaded, 0 deleted +Resources: 2 created, 0 changed, 0 deleted, 0 unchanged + +=== Plan is a no-op immediately after deploy +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged + +=== Grant an extra principal out of band +>>> [CLI] cluster-policies set-permissions [TEST_CLUSTER_POLICY_ID] --json {"access_control_list":[{"group_name":"users","permission_level":"CAN_USE"},{"user_name":"intruder@example.com","permission_level":"CAN_USE"}]} +{ + "access_control_list": [ + { + "all_permissions": [ + { + "inherited": false, + "permission_level": "CAN_USE" + } + ], + "group_name": "users" + }, + { + "all_permissions": [ + { + "inherited": false, + "permission_level": "CAN_USE" + } + ], + "display_name": "intruder@example.com", + "user_name": "intruder@example.com" + } + ], + "object_id": "/cluster-policies/[TEST_CLUSTER_POLICY_ID]", + "object_type": "cluster-policy" +} + +=== Plan detects the permission drift +>>> [CLI] bundle plan +update cluster_policies.test_cluster_policy.permissions + +Plan: 0 to add, 1 to change, 0 to delete, 1 unchanged + +=== Redeploy reconciles the ACL back to the configured grants +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test_cluster_policy_permissions_drift/default/files... +Updated cluster_policies.test_cluster_policy.permissions +Files: 2 uploaded, 0 deleted +Resources: 0 created, 1 changed, 0 deleted, 1 unchanged + +>>> print_requests.py //permissions/cluster-policies +{ + "method": "PUT", + "path": "/api/2.0/permissions/cluster-policies/[TEST_CLUSTER_POLICY_ID]", + "body": { + "access_control_list": [ + { + "group_name": "users", + "permission_level": "CAN_USE" + } + ] + } +} + +=== Plan is a no-op again +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.cluster_policies.test_cluster_policy + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test_cluster_policy_permissions_drift/default + +Destroy: 1 deleted diff --git a/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_change/script b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_change/script new file mode 100644 index 0000000000..fad8a31a13 --- /dev/null +++ b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_change/script @@ -0,0 +1,33 @@ +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +trace $CLI bundle deploy + +title "Plan is a no-op immediately after deploy" +trace $CLI bundle plan + +policy_id="$(read_id.py test_cluster_policy)" + +# Simulate an out-of-band ACL change the way an admin would in the UI: grant an +# extra principal directly through the permissions API without touching +# databricks.yml. The recorded bundle state is now stale, so the next plan must +# detect the drift. +title "Grant an extra principal out of band" +trace $CLI cluster-policies set-permissions "$policy_id" --json '{"access_control_list":[{"group_name":"users","permission_level":"CAN_USE"},{"user_name":"intruder@example.com","permission_level":"CAN_USE"}]}' + +# Discard the out-of-band request so the verification below captures only the +# reconciling Set issued by the redeploy. +rm -f out.requests.txt + +title "Plan detects the permission drift" +trace $CLI bundle plan + +title "Redeploy reconciles the ACL back to the configured grants" +trace $CLI bundle deploy +trace print_requests.py //permissions/cluster-policies + +title "Plan is a no-op again" +trace $CLI bundle plan diff --git a/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_change/test.toml b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_change/test.toml new file mode 100644 index 0000000000..7ccf95a8a6 --- /dev/null +++ b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_change/test.toml @@ -0,0 +1 @@ +Ignore = [".databricks", "databricks.yml"] diff --git a/bundle/config/mutator/resourcemutator/fix_permissions.go b/bundle/config/mutator/resourcemutator/fix_permissions.go index 6d8a44bd80..9e6c785b0d 100644 --- a/bundle/config/mutator/resourcemutator/fix_permissions.go +++ b/bundle/config/mutator/resourcemutator/fix_permissions.go @@ -25,6 +25,9 @@ var hasIsOwner = map[string]bool{ var ignoredResources = map[string]bool{ "secret_scopes": true, + // Cluster policies only support CAN_USE; injecting the current user as + // CAN_MANAGE/IS_OWNER would be rejected by the permissions API. + "cluster_policies": true, } // When processing permissions, we need to implement these constraints: diff --git a/bundle/config/resources/cluster_policy.go b/bundle/config/resources/cluster_policy.go index 5c447b0beb..b0b07ff5fb 100644 --- a/bundle/config/resources/cluster_policy.go +++ b/bundle/config/resources/cluster_policy.go @@ -23,6 +23,8 @@ type ClusterPolicy struct { // Shadows the embedded compute.CreatePolicy.PolicyFamilyDefinitionOverrides (a string), // same as Definition: also a policy document authorable as inline YAML. PolicyFamilyDefinitionOverrides any `json:"policy_family_definition_overrides,omitempty"` + + Permissions []ClusterPolicyPermission `json:"permissions,omitempty"` } func (s *ClusterPolicy) UnmarshalJSON(b []byte) error { diff --git a/bundle/config/resources/permission_types.go b/bundle/config/resources/permission_types.go index d067c5e3e3..31d41e0943 100644 --- a/bundle/config/resources/permission_types.go +++ b/bundle/config/resources/permission_types.go @@ -27,6 +27,7 @@ func (p Permission) String() string { type ( AppPermission PermissionT[apps.AppPermissionLevel] ClusterPermission PermissionT[compute.ClusterPermissionLevel] + ClusterPolicyPermission PermissionT[compute.ClusterPolicyPermissionLevel] InstancePoolPermission PermissionT[compute.InstancePoolPermissionLevel] JobPermission PermissionT[jobs.JobPermissionLevel] MlflowExperimentPermission PermissionT[ml.ExperimentPermissionLevel] diff --git a/bundle/direct/dresources/all.go b/bundle/direct/dresources/all.go index 2c82df2aab..391fb0684d 100644 --- a/bundle/direct/dresources/all.go +++ b/bundle/direct/dresources/all.go @@ -48,6 +48,7 @@ var SupportedResources = map[string]any{ "apps.permissions": (*ResourcePermissions)(nil), "alerts.permissions": (*ResourcePermissions)(nil), "clusters.permissions": (*ResourcePermissions)(nil), + "cluster_policies.permissions": (*ResourcePermissions)(nil), "database_instances.permissions": (*ResourcePermissions)(nil), "postgres_projects.permissions": (*ResourcePermissions)(nil), "experiments.permissions": (*ResourcePermissions)(nil), diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index 75a94ffc88..0c0d3d05ad 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -452,6 +452,16 @@ var testDeps = map[string]prepareWorkspace{ }, nil }, + "cluster_policies.permissions": func(ctx context.Context, client *databricks.WorkspaceClient) (any, error) { + return &PermissionsState{ + ObjectID: "/cluster-policies/cluster-policy-permissions", + EmbeddedSlice: []StatePermission{{ + Level: "CAN_USE", + UserName: "user@example.com", + }}, + }, nil + }, + "instance_pools.permissions": func(ctx context.Context, client *databricks.WorkspaceClient) (any, error) { return &PermissionsState{ ObjectID: "/instance-pools/pool-permissions", diff --git a/bundle/direct/dresources/permissions.go b/bundle/direct/dresources/permissions.go index e99311757a..b61b9a60a7 100644 --- a/bundle/direct/dresources/permissions.go +++ b/bundle/direct/dresources/permissions.go @@ -17,6 +17,7 @@ var permissionResourceToObjectType = map[string]string{ "alerts": "/alertsv2/", "apps": "/apps/", "clusters": "/clusters/", + "cluster_policies": "/cluster-policies/", "instance_pools": "/instance-pools/", "dashboards": "/dashboards/", "genie_spaces": "/genie/", diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index bd1c02857a..f594c78c27 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -550,6 +550,26 @@ resources: "lifecycle": "description": |- PLACEHOLDER + "permissions": + "description": |- + The permissions to apply to this resource. + "markdown_description": |- + A Sequence of permissions to apply to this resource, where each item grants a permission `level` to a single `user_name`, `group_name`, or `service_principal_name`. A principal cannot be set in both a resource's `permissions` and the top-level `permissions` mapping. + + See [\_](/dev-tools/bundles/settings.md#permissions) and [\_](/dev-tools/bundles/permissions.md). + "$fields": + "group_name": + "description": |- + The name of the group granted the permission level. + "level": + "description": |- + The permission level to apply. Cluster policies only support `CAN_USE`. + "service_principal_name": + "description": |- + The name of the service principal granted the permission level. + "user_name": + "description": |- + The name of the user granted the permission level. "clusters": "description": |- The cluster definitions for the bundle, where each key is the name of a cluster. diff --git a/bundle/internal/validation/generated/enum_fields.go b/bundle/internal/validation/generated/enum_fields.go index 5dadaece24..195d51bdfc 100644 --- a/bundle/internal/validation/generated/enum_fields.go +++ b/bundle/internal/validation/generated/enum_fields.go @@ -41,6 +41,8 @@ var EnumFields = map[string][]string{ "resources.catalogs.*.grants[*].privileges[*]": {"ACCESS", "ALL_PRIVILEGES", "APPLY_TAG", "BROWSE", "CREATE", "CREATE_CATALOG", "CREATE_CLEAN_ROOM", "CREATE_CONNECTION", "CREATE_EXTERNAL_LOCATION", "CREATE_EXTERNAL_TABLE", "CREATE_EXTERNAL_VOLUME", "CREATE_FOREIGN_CATALOG", "CREATE_FOREIGN_SECURABLE", "CREATE_FUNCTION", "CREATE_MANAGED_STORAGE", "CREATE_MATERIALIZED_VIEW", "CREATE_MODEL", "CREATE_PROVIDER", "CREATE_RECIPIENT", "CREATE_SCHEMA", "CREATE_SERVICE_CREDENTIAL", "CREATE_SHARE", "CREATE_STORAGE_CREDENTIAL", "CREATE_TABLE", "CREATE_VIEW", "CREATE_VOLUME", "EXECUTE", "EXECUTE_CLEAN_ROOM_TASK", "EXTERNAL_USE_SCHEMA", "MANAGE", "MANAGE_ALLOWLIST", "MODIFY", "MODIFY_CLEAN_ROOM", "READ_FILES", "READ_METADATA", "READ_PRIVATE_FILES", "READ_VOLUME", "REFRESH", "SELECT", "SET_SHARE_PERMISSION", "USAGE", "USE_CATALOG", "USE_CONNECTION", "USE_MARKETPLACE_ASSETS", "USE_PROVIDER", "USE_RECIPIENT", "USE_SCHEMA", "USE_SHARE", "WRITE_FILES", "WRITE_PRIVATE_FILES", "WRITE_VOLUME"}, + "resources.cluster_policies.*.permissions[*].level": {"CAN_USE"}, + "resources.clusters.*.aws_attributes.availability": {"ON_DEMAND", "SPOT", "SPOT_WITH_FALLBACK"}, "resources.clusters.*.aws_attributes.ebs_volume_type": {"GENERAL_PURPOSE_SSD", "THROUGHPUT_OPTIMIZED_HDD"}, "resources.clusters.*.azure_attributes.availability": {"ON_DEMAND_AZURE", "SPOT_AZURE", "SPOT_WITH_FALLBACK_AZURE"}, diff --git a/bundle/internal/validation/generated/required_fields.go b/bundle/internal/validation/generated/required_fields.go index 97fa418097..b5d9d68cc1 100644 --- a/bundle/internal/validation/generated/required_fields.go +++ b/bundle/internal/validation/generated/required_fields.go @@ -43,6 +43,7 @@ var RequiredFields = map[string][]string{ "resources.cluster_policies.*.libraries[*].cran": {"package"}, "resources.cluster_policies.*.libraries[*].maven": {"coordinates"}, "resources.cluster_policies.*.libraries[*].pypi": {"package"}, + "resources.cluster_policies.*.permissions[*]": {"level"}, "resources.clusters.*.cluster_log_conf.dbfs": {"destination"}, "resources.clusters.*.cluster_log_conf.s3": {"destination"}, diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index 311426837f..e0407dcdf7 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -617,6 +617,11 @@ "description": "Cluster Policy name requested by the user. This has to be unique. Length must be between 1 and 100\ncharacters.", "$ref": "#/$defs/string" }, + "permissions": { + "description": "The permissions to apply to this resource.", + "$ref": "#/$defs/slice/github.com/databricks/cli/bundle/config/resources.ClusterPolicyPermission", + "markdownDescription": "A Sequence of permissions to apply to this resource, where each item grants a permission `level` to a single `user_name`, `group_name`, or `service_principal_name`. A principal cannot be set in both a resource's `permissions` and the top-level `permissions` mapping.\n\nSee [permissions](https://docs.databricks.com/dev-tools/bundles/settings.html#permissions) and [link](https://docs.databricks.com/dev-tools/bundles/permissions.html)." + }, "policy_family_definition_overrides": { "description": "Policy definition JSON document expressed in [Databricks Policy Definition Language](https://docs.databricks.com/administration-guide/clusters/policy-definition.html).\nThe JSON document must be passed as a string and cannot be embedded in the requests.\n\nYou can use this to customize the policy definition inherited from the policy family.\nPolicy rules specified here are merged into the inherited policy definition.", "$ref": "#/$defs/interface" @@ -634,6 +639,39 @@ } ] }, + "resources.ClusterPolicyPermission": { + "oneOf": [ + { + "type": "object", + "properties": { + "group_name": { + "description": "The name of the group granted the permission level.", + "$ref": "#/$defs/string" + }, + "level": { + "description": "The permission level to apply. Cluster policies only support `CAN_USE`.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/compute.ClusterPolicyPermissionLevel" + }, + "service_principal_name": { + "description": "The name of the service principal granted the permission level.", + "$ref": "#/$defs/string" + }, + "user_name": { + "description": "The name of the user granted the permission level.", + "$ref": "#/$defs/string" + } + }, + "additionalProperties": false, + "required": [ + "level" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.Dashboard": { "oneOf": [ { @@ -5454,6 +5492,21 @@ } ] }, + "compute.ClusterPolicyPermissionLevel": { + "oneOf": [ + { + "type": "string", + "description": "Permission level", + "enum": [ + "CAN_USE" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "compute.ClusterSpec": { "oneOf": [ { @@ -15193,6 +15246,20 @@ } ] }, + "resources.ClusterPolicyPermission": { + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.ClusterPolicyPermission" + } + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.InstancePoolPermission": { "oneOf": [ { diff --git a/libs/testserver/permissions.go b/libs/testserver/permissions.go index 61f9907ae2..24ae7a8a4b 100644 --- a/libs/testserver/permissions.go +++ b/libs/testserver/permissions.go @@ -238,6 +238,23 @@ func (s *FakeWorkspace) SetPermissions(req Request) any { } } + // Cluster policies only support CAN_USE; the real permissions API rejects any + // other level. Model that so a deploy or a set-permissions call requesting + // CAN_MANAGE fails the same way locally and on cloud. + if requestObjectType == "cluster-policies" { + for _, acl := range updateRequest.AccessControlList { + if acl.PermissionLevel != "" && acl.PermissionLevel != "CAN_USE" { + return Response{ + StatusCode: 400, + Body: map[string]string{ + "error_code": "INVALID_PARAMETER_VALUE", + "message": fmt.Sprintf("Cluster policy permissions only support CAN_USE, got %s", acl.PermissionLevel), + }, + } + } + } + } + responseObjectID := fmt.Sprintf("/%s/%s", requestObjectType, objectId) // Get existing permissions or create new ones