From 4ae853d750dbe32d7e6d09c204b4544ed9f4fae7 Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Tue, 18 Aug 2026 09:58:50 +0000 Subject: [PATCH 1/5] Add per-resource permissions support to cluster_policies Cluster policies support a single ACL level, CAN_USE (SDK compute.ClusterPolicyPermissionLevel). Wire up the per-resource `permissions` field so a policy can grant CAN_USE to users, groups, and service principals via the direct engine's generic permissions applier: - Declare the ClusterPolicyPermission type and add the Permissions field. - Register cluster_policies.permissions in the direct engine and map the permissions-API object type to /cluster-policies/. - Regenerate the bundle JSON schema and fill in the field descriptions. cluster_policies is intentionally left out of the top-level levelsMap (it stays in unsupportedResources): the bundle-wide permissions block is expressed in CAN_MANAGE/CAN_VIEW/CAN_RUN, none of which a cluster policy can accept, so only the explicit per-resource form is supported. Co-authored-by: Isaac --- bundle/config/resources/cluster_policy.go | 2 + bundle/config/resources/permission_types.go | 1 + bundle/direct/dresources/all.go | 1 + bundle/direct/dresources/all_test.go | 10 +++ bundle/direct/dresources/permissions.go | 1 + bundle/internal/schema/annotations.yml | 20 ++++++ bundle/schema/jsonschema.json | 67 +++++++++++++++++++++ 7 files changed, 102 insertions(+) diff --git a/bundle/config/resources/cluster_policy.go b/bundle/config/resources/cluster_policy.go index 5c447b0beb9..b0b07ff5fb9 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 d067c5e3e37..31d41e09439 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 2c82df2aab9..391fb0684d2 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 75a94ffc88c..0c0d3d05ada 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 e99311757a2..b61b9a60a7c 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 bd1c02857ad..f594c78c278 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/schema/jsonschema.json b/bundle/schema/jsonschema.json index 311426837f3..e0407dcdf73 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": [ { From 1a4cc911160d17869192e73e605734f228d088e5 Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Tue, 18 Aug 2026 10:42:31 +0000 Subject: [PATCH 2/5] Cover cluster_policies permissions with acceptance tests Add basic and out_of_band_change acceptance tests for the cluster_policies `permissions` field, filtering permission requests with print_requests.py. The basic test surfaced that FixPermissions injects the current user as CAN_MANAGE onto every resource with a permissions field. Cluster policies only accept CAN_USE, so the real backend would reject that. Add cluster_policies to fix_permissions.go's ignoredResources (like secret_scopes) so only the user-specified CAN_USE grants are sent. Co-authored-by: Isaac --- .../permissions/basic/databricks.yml | 13 +++ .../permissions/basic/out.test.toml | 2 + .../permissions/basic/output.txt | 84 +++++++++++++++++++ .../cluster_policies/permissions/basic/script | 22 +++++ .../permissions/basic/test.toml | 1 + .../out_of_band_change/databricks.yml | 11 +++ .../out_of_band_change/out.test.toml | 2 + .../permissions/out_of_band_change/output.txt | 78 +++++++++++++++++ .../permissions/out_of_band_change/script | 33 ++++++++ .../permissions/out_of_band_change/test.toml | 1 + .../resourcemutator/fix_permissions.go | 3 + 11 files changed, 250 insertions(+) create mode 100644 acceptance/bundle/resources/cluster_policies/permissions/basic/databricks.yml create mode 100644 acceptance/bundle/resources/cluster_policies/permissions/basic/out.test.toml create mode 100644 acceptance/bundle/resources/cluster_policies/permissions/basic/output.txt create mode 100644 acceptance/bundle/resources/cluster_policies/permissions/basic/script create mode 100644 acceptance/bundle/resources/cluster_policies/permissions/basic/test.toml create mode 100644 acceptance/bundle/resources/cluster_policies/permissions/out_of_band_change/databricks.yml create mode 100644 acceptance/bundle/resources/cluster_policies/permissions/out_of_band_change/out.test.toml create mode 100644 acceptance/bundle/resources/cluster_policies/permissions/out_of_band_change/output.txt create mode 100644 acceptance/bundle/resources/cluster_policies/permissions/out_of_band_change/script create mode 100644 acceptance/bundle/resources/cluster_policies/permissions/out_of_band_change/test.toml 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 00000000000..7fd70916477 --- /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 00000000000..0938e678987 --- /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 00000000000..b84045576cf --- /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 00000000000..6184d90781e --- /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 00000000000..cba9a272efc --- /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/out_of_band_change/databricks.yml b/acceptance/bundle/resources/cluster_policies/permissions/out_of_band_change/databricks.yml new file mode 100644 index 00000000000..0c151e08e51 --- /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 00000000000..0938e678987 --- /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 00000000000..3fd663498dc --- /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 00000000000..fad8a31a138 --- /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 00000000000..7ccf95a8a6a --- /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 6d8a44bd800..9e6c785b0d9 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: From b6fd833047875d771c24e57b8b951ded3f4d41bb Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Tue, 18 Aug 2026 10:49:47 +0000 Subject: [PATCH 3/5] Assert cluster_policies permission levels on cloud Add a Cloud=true test that deploys a policy with CAN_USE (accepted) and then asserts a direct CAN_MANAGE set-permissions call is rejected. Model the rejection in the testserver: the permissions API only accepts CAN_USE for cluster policies, so a non-CAN_USE level returns 400. This keeps the local run and the cloud run in agreement. The backend error text differs between the fake and a real workspace, so it is routed to a LOG file and musterr asserts only that the request fails. Co-authored-by: Isaac --- .../permissions/levels/databricks.yml.tmpl | 11 +++++++ .../permissions/levels/out.test.toml | 2 ++ .../permissions/levels/output.txt | 30 +++++++++++++++++++ .../permissions/levels/script | 19 ++++++++++++ .../permissions/levels/test.toml | 2 ++ libs/testserver/permissions.go | 17 +++++++++++ 6 files changed, 81 insertions(+) create mode 100644 acceptance/bundle/resources/cluster_policies/permissions/levels/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/cluster_policies/permissions/levels/out.test.toml create mode 100644 acceptance/bundle/resources/cluster_policies/permissions/levels/output.txt create mode 100644 acceptance/bundle/resources/cluster_policies/permissions/levels/script create mode 100644 acceptance/bundle/resources/cluster_policies/permissions/levels/test.toml 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 00000000000..8ebdcb8c366 --- /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 00000000000..c502b28221b --- /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 00000000000..b6a65434bff --- /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 00000000000..f5f2b3e6a27 --- /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 00000000000..3dd59d27d6c --- /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/libs/testserver/permissions.go b/libs/testserver/permissions.go index 61f9907ae2e..24ae7a8a4b2 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 From 7e89437984f9cff1957311f2418f5737e7b8b9db Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Tue, 18 Aug 2026 10:52:48 +0000 Subject: [PATCH 4/5] Regenerate validation and refschema for cluster_policies permissions Runs generate-check output: the refschema field list, the level enum (CAN_USE only), and the required-field entry for the new cluster_policies permissions block. Co-authored-by: Isaac --- acceptance/bundle/refschema/out.fields.txt | 6 ++++++ bundle/internal/validation/generated/enum_fields.go | 2 ++ bundle/internal/validation/generated/required_fields.go | 1 + 3 files changed, 9 insertions(+) diff --git a/acceptance/bundle/refschema/out.fields.txt b/acceptance/bundle/refschema/out.fields.txt index f04d1416677..0add568eec3 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/bundle/internal/validation/generated/enum_fields.go b/bundle/internal/validation/generated/enum_fields.go index 5dadaece243..195d51bdfcd 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 97fa418097c..b5d9d68cc12 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"}, From 504c16d0ebd6ad0c265a7638b39ae76ed0940b25 Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Tue, 18 Aug 2026 11:34:14 +0000 Subject: [PATCH 5/5] Cover cluster_policies permissions in invariant configs TestInvariantConfigsCoverage requires every resource with a permissions field to have an invariant config exercising it. Attach a CAN_USE grant to the cluster_policies invariant config. Co-authored-by: Isaac --- acceptance/bundle/invariant/configs/cluster_policy.yml.tmpl | 3 +++ 1 file changed, 3 insertions(+) diff --git a/acceptance/bundle/invariant/configs/cluster_policy.yml.tmpl b/acceptance/bundle/invariant/configs/cluster_policy.yml.tmpl index aa514b0e0db..35c1dd4a02f 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