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 ee09e0e9ba..de252dc416 100644 --- a/acceptance/bundle/refschema/out.fields.txt +++ b/acceptance/bundle/refschema/out.fields.txt @@ -333,6 +333,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/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..40fccf6448 --- /dev/null +++ b/acceptance/bundle/resources/cluster_policies/permissions/levels/output.txt @@ -0,0 +1,34 @@ + +>>> [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 +Warning: invalid value "CAN_MANAGE" for enum field. Valid values are [CAN_USE] + at resources.cluster_policies.test_cluster_policy.permissions[0].level + in databricks.yml:10:18 + +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..bb5762483b --- /dev/null +++ b/acceptance/bundle/resources/cluster_policies/permissions/levels/script @@ -0,0 +1,20 @@ +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +envsubst < databricks.yml.tmpl > databricks.yml + +# CAN_USE is accepted: deploy succeeds and the PUT carries CAN_USE. +trace $CLI bundle deploy +read_id.py test_cluster_policy > /dev/null +trace print_requests.py //permissions/cluster-policies + +# CAN_MANAGE is rejected, so the redeploy fails with the backend's error. +# Output goes to LOG (upload counts differ between the fake and cloud); the +# error message is deterministic and asserted here. +title "CAN_MANAGE is rejected" +update_file.py databricks.yml CAN_USE CAN_MANAGE +musterr $CLI bundle deploy &> LOG.deploy +cat LOG.deploy | contains.py "Unknown Cluster Policy Permission Level: CAN_MANAGE" > /dev/null 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_deletion/databricks.yml b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_deletion/databricks.yml new file mode 100644 index 0000000000..9615515a7c --- /dev/null +++ b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_deletion/databricks.yml @@ -0,0 +1,11 @@ +bundle: + name: test_cluster_policy_permissions_recreate + +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_deletion/out.test.toml b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_deletion/out.test.toml new file mode 100644 index 0000000000..0938e67898 --- /dev/null +++ b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_deletion/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_deletion/output.txt b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_deletion/output.txt new file mode 100644 index 0000000000..d1b2cc33b6 --- /dev/null +++ b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_deletion/output.txt @@ -0,0 +1,58 @@ + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test_cluster_policy_permissions_recreate/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 + +=== Permissions are set on the live policy +>>> [CLI] api get /api/2.0/permissions/cluster-policies/[TEST_CLUSTER_POLICY_ID] +{ + "object_type": "cluster-policy", + "access_control_list": [ + { + "all_permissions": [ + { + "inherited": false, + "permission_level": "CAN_USE" + } + ], + "group_name": "users" + } + ] +} + +=== Delete the policy out of band +>>> [CLI] cluster-policies delete [TEST_CLUSTER_POLICY_ID] + +=== Permissions API still returns the ACLs for the deleted policy (no 404) +>>> [CLI] api get /api/2.0/permissions/cluster-policies/[TEST_CLUSTER_POLICY_ID] +{ + "object_type": "cluster-policy", + "access_control_list": [ + { + "all_permissions": [ + { + "inherited": false, + "permission_level": "CAN_USE" + } + ], + "group_name": "users" + } + ] +} + +=== Plan recreates the gone policy +>>> [CLI] bundle plan +create cluster_policies.test_cluster_policy +update cluster_policies.test_cluster_policy.permissions + +Plan: 1 to add, 1 to change, 0 to delete, 0 unchanged + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test_cluster_policy_permissions_recreate/default + +Destroy: 1 deleted diff --git a/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_deletion/script b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_deletion/script new file mode 100644 index 0000000000..fa6e94b873 --- /dev/null +++ b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_deletion/script @@ -0,0 +1,25 @@ +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +trace $CLI bundle deploy +# Fetch the id via summary + add_repl (a shell function) rather than read_id.py: +# MSYS_NO_PATHCONV mangles the PATH-resolved python script location on Windows. +policy_id=$($CLI bundle summary --output json | jq -r '.resources.cluster_policies.test_cluster_policy.id') +add_repl "$policy_id" TEST_CLUSTER_POLICY_ID + +title "Permissions are set on the live policy" +trace $CLI api get /api/2.0/permissions/cluster-policies/$policy_id | jq '{object_type, access_control_list}' + +title "Delete the policy out of band" +trace $CLI cluster-policies delete "$policy_id" + +# v1 quirk: the permissions endpoint keeps returning the ACLs for a deleted +# cluster policy; it does not 404 the way a v2 cascade-deleted resource would. +title "Permissions API still returns the ACLs for the deleted policy (no 404)" +trace $CLI api get /api/2.0/permissions/cluster-policies/$policy_id | jq '{object_type, access_control_list}' + +title "Plan recreates the gone policy" +trace $CLI bundle plan diff --git a/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_deletion/test.toml b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_deletion/test.toml new file mode 100644 index 0000000000..7bec79e9c6 --- /dev/null +++ b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_deletion/test.toml @@ -0,0 +1,9 @@ +# Local-only: fixed policy name is not safe for concurrent cloud runs. Cloud +# coverage of the permission behavior lives in the levels test. +Cloud = false + +# `api get` is passed a leading-slash path; without this, Git Bash on Windows +# rewrites /api/... to C:\Program Files\Git\api\... before the CLI sees it. +Env.MSYS_NO_PATHCONV = "1" + +Ignore = [".databricks", "databricks.yml"] diff --git a/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_grant/databricks.yml b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_grant/databricks.yml new file mode 100644 index 0000000000..0c151e08e5 --- /dev/null +++ b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_grant/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_grant/out.test.toml b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_grant/out.test.toml new file mode 100644 index 0000000000..0938e67898 --- /dev/null +++ b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_grant/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_grant/output.txt b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_grant/output.txt new file mode 100644 index 0000000000..3fd663498d --- /dev/null +++ b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_grant/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_grant/script b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_grant/script new file mode 100644 index 0000000000..fad8a31a13 --- /dev/null +++ b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_grant/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_grant/test.toml b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_grant/test.toml new file mode 100644 index 0000000000..4a3471ddc4 --- /dev/null +++ b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_grant/test.toml @@ -0,0 +1,4 @@ +# Local-only: fixed policy name is not safe for concurrent cloud runs. Cloud +# coverage of the permission behavior lives in the levels test. +Cloud = false +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 aa838acfee..2ab265d29f 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 96620ba9a8..e47f89c505 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 1abda54bfa..5e62e92168 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 26de25e74d..617f5a65ec 100644 --- a/bundle/internal/validation/generated/required_fields.go +++ b/bundle/internal/validation/generated/required_fields.go @@ -44,6 +44,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 8fdb1bfad8..425956ab06 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -624,6 +624,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" @@ -641,6 +646,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": [ { @@ -5513,6 +5551,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": [ { @@ -15633,6 +15686,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..a3584c1d49 100644 --- a/libs/testserver/permissions.go +++ b/libs/testserver/permissions.go @@ -238,6 +238,22 @@ func (s *FakeWorkspace) SetPermissions(req Request) any { } } + // Cluster policies only support CAN_USE; the real API rejects other levels + // with this exact message. Model it so deploys fail the same way 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("Unknown Cluster Policy Permission Level: %s", acl.PermissionLevel), + }, + } + } + } + } + responseObjectID := fmt.Sprintf("/%s/%s", requestObjectType, objectId) // Get existing permissions or create new ones