From d1349076f778011d72a8f559d7e6cfea677a4ef8 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Thu, 4 Jun 2026 13:50:47 +0200 Subject: [PATCH] bundle: record auto-migration compatibility telemetry on deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ApplyWorkspaceRootPermissions already calls SetPermissions on each workspace path prefix during deploy; the response carries the folder's resulting ACL. Reusing it (no extra API call), we record whether this deploy is compatible with an automatic DMS migration — i.e. whether, after migration, the deployment state folder can be managed by only the declared permissions plus the deployer (allowed without being declared only when the state is under the deployer's home). Exactly one verdict is recorded per deploy: - is_definitely_auto_migration_compatible: folder ACL observed and compatible. - is_not_auto_migration_compatible: folder ACL observed (or known statically for /Workspace/Shared) and not compatible. - is_maybe_auto_migration_compatible: no permissions section, so the folder was not synced and the ACL is unobserved; resolving it needs a GetPermissions call. Aggregate by deployment ID: a deployment (bundle target) is auto-migratable only if all of its deploys are compatible. The fake test server now models home-folder ownership (a directory under /Workspace/Users/ returns that owner's inherited CAN_MANAGE), so the acceptance test exercises the full matrix via bundle paths and declared permissions alone. Co-authored-by: Shreyas Goenka --- .../job_tasks/out.telemetry.direct.txt | 2 + .../job_tasks/out.telemetry.terraform.txt | 2 + .../resource_deps/resources_var/output.txt | 2 + .../out.telemetry.terraform.txt | 2 + .../deploy-app-lifecycle-started/output.txt | 8 + .../telemetry/deploy-compute-type/output.txt | 16 ++ .../telemetry/deploy-experimental/output.txt | 8 + .../deploy-name-prefix/custom/output.txt | 8 + .../mode-development/output.txt | 8 + .../telemetry/deploy-whl-artifacts/output.txt | 16 ++ .../databricks.yml | 112 +++++++++++ .../out.test.toml | 3 + .../output.txt | 165 +++++++++++++++ .../script | 36 ++++ .../bundle/telemetry/deploy/out.telemetry.txt | 8 + bundle/metrics/metrics.go | 23 +++ .../permissions/workspace_path_permissions.go | 7 + bundle/permissions/workspace_root.go | 188 ++++++++++++++++-- bundle/permissions/workspace_root_test.go | 12 +- libs/testserver/permissions.go | 101 +++++++++- 20 files changed, 707 insertions(+), 20 deletions(-) create mode 100644 acceptance/bundle/telemetry/deploy-workspace-folder-permissions/databricks.yml create mode 100644 acceptance/bundle/telemetry/deploy-workspace-folder-permissions/out.test.toml create mode 100644 acceptance/bundle/telemetry/deploy-workspace-folder-permissions/output.txt create mode 100644 acceptance/bundle/telemetry/deploy-workspace-folder-permissions/script diff --git a/acceptance/bundle/resource_deps/job_tasks/out.telemetry.direct.txt b/acceptance/bundle/resource_deps/job_tasks/out.telemetry.direct.txt index 3e6a825eb5e..bbaf2e4e023 100644 --- a/acceptance/bundle/resource_deps/job_tasks/out.telemetry.direct.txt +++ b/acceptance/bundle/resource_deps/job_tasks/out.telemetry.direct.txt @@ -2,9 +2,11 @@ experimental.use_legacy_run_as false has_classic_interactive_compute false has_classic_job_compute false has_serverless_compute true +is_maybe_auto_migration_compatible true local.cache.attempt true local.cache.miss true presets_name_prefix_is_set false python_wheel_wrapper_is_set false run_as_set false skip_artifact_cleanup false +state_path_is_shared false diff --git a/acceptance/bundle/resource_deps/job_tasks/out.telemetry.terraform.txt b/acceptance/bundle/resource_deps/job_tasks/out.telemetry.terraform.txt index 3e6a825eb5e..bbaf2e4e023 100644 --- a/acceptance/bundle/resource_deps/job_tasks/out.telemetry.terraform.txt +++ b/acceptance/bundle/resource_deps/job_tasks/out.telemetry.terraform.txt @@ -2,9 +2,11 @@ experimental.use_legacy_run_as false has_classic_interactive_compute false has_classic_job_compute false has_serverless_compute true +is_maybe_auto_migration_compatible true local.cache.attempt true local.cache.miss true presets_name_prefix_is_set false python_wheel_wrapper_is_set false run_as_set false skip_artifact_cleanup false +state_path_is_shared false diff --git a/acceptance/bundle/resource_deps/resources_var/output.txt b/acceptance/bundle/resource_deps/resources_var/output.txt index 59ee3bc4616..12a084ea74a 100644 --- a/acceptance/bundle/resource_deps/resources_var/output.txt +++ b/acceptance/bundle/resource_deps/resources_var/output.txt @@ -40,9 +40,11 @@ experimental.use_legacy_run_as false has_classic_interactive_compute false has_classic_job_compute false has_serverless_compute false +is_maybe_auto_migration_compatible true local.cache.attempt true local.cache.hit true presets_name_prefix_is_set true python_wheel_wrapper_is_set false run_as_set false skip_artifact_cleanup false +state_path_is_shared false diff --git a/acceptance/bundle/resource_deps/tf_path_only_error/out.telemetry.terraform.txt b/acceptance/bundle/resource_deps/tf_path_only_error/out.telemetry.terraform.txt index fbb1d343107..56c2ebbd8f1 100644 --- a/acceptance/bundle/resource_deps/tf_path_only_error/out.telemetry.terraform.txt +++ b/acceptance/bundle/resource_deps/tf_path_only_error/out.telemetry.terraform.txt @@ -3,9 +3,11 @@ has_classic_interactive_compute false has_classic_job_compute false has_serverless_compute false has_tf_only_references true +is_maybe_auto_migration_compatible true local.cache.attempt true local.cache.hit true presets_name_prefix_is_set false python_wheel_wrapper_is_set false run_as_set false skip_artifact_cleanup false +state_path_is_shared false diff --git a/acceptance/bundle/telemetry/deploy-app-lifecycle-started/output.txt b/acceptance/bundle/telemetry/deploy-app-lifecycle-started/output.txt index 3c47531c6fa..f6ba2b2508f 100644 --- a/acceptance/bundle/telemetry/deploy-app-lifecycle-started/output.txt +++ b/acceptance/bundle/telemetry/deploy-app-lifecycle-started/output.txt @@ -37,6 +37,14 @@ Deployment complete! "key": "skip_artifact_cleanup", "value": false }, + { + "key": "state_path_is_shared", + "value": false + }, + { + "key": "is_maybe_auto_migration_compatible", + "value": true + }, { "key": "has_serverless_compute", "value": false diff --git a/acceptance/bundle/telemetry/deploy-compute-type/output.txt b/acceptance/bundle/telemetry/deploy-compute-type/output.txt index a424df8ce1d..f97e4e9422b 100644 --- a/acceptance/bundle/telemetry/deploy-compute-type/output.txt +++ b/acceptance/bundle/telemetry/deploy-compute-type/output.txt @@ -41,6 +41,14 @@ Deployment complete! "key": "skip_artifact_cleanup", "value": false }, + { + "key": "state_path_is_shared", + "value": false + }, + { + "key": "is_maybe_auto_migration_compatible", + "value": true + }, { "key": "has_serverless_compute", "value": true @@ -83,6 +91,14 @@ Deployment complete! "key": "skip_artifact_cleanup", "value": false }, + { + "key": "state_path_is_shared", + "value": false + }, + { + "key": "is_maybe_auto_migration_compatible", + "value": true + }, { "key": "has_serverless_compute", "value": true diff --git a/acceptance/bundle/telemetry/deploy-experimental/output.txt b/acceptance/bundle/telemetry/deploy-experimental/output.txt index cf7a2358da7..0a1edbc42ab 100644 --- a/acceptance/bundle/telemetry/deploy-experimental/output.txt +++ b/acceptance/bundle/telemetry/deploy-experimental/output.txt @@ -40,6 +40,14 @@ Deployment complete! "key": "skip_artifact_cleanup", "value": false }, + { + "key": "state_path_is_shared", + "value": false + }, + { + "key": "is_maybe_auto_migration_compatible", + "value": true + }, { "key": "has_serverless_compute", "value": false diff --git a/acceptance/bundle/telemetry/deploy-name-prefix/custom/output.txt b/acceptance/bundle/telemetry/deploy-name-prefix/custom/output.txt index 31ff8e9cf7e..6bb4815a3a7 100644 --- a/acceptance/bundle/telemetry/deploy-name-prefix/custom/output.txt +++ b/acceptance/bundle/telemetry/deploy-name-prefix/custom/output.txt @@ -36,6 +36,14 @@ Deployment complete! "key": "skip_artifact_cleanup", "value": false }, + { + "key": "state_path_is_shared", + "value": false + }, + { + "key": "is_maybe_auto_migration_compatible", + "value": true + }, { "key": "has_serverless_compute", "value": false diff --git a/acceptance/bundle/telemetry/deploy-name-prefix/mode-development/output.txt b/acceptance/bundle/telemetry/deploy-name-prefix/mode-development/output.txt index 39b671bec32..7fdbba43da9 100644 --- a/acceptance/bundle/telemetry/deploy-name-prefix/mode-development/output.txt +++ b/acceptance/bundle/telemetry/deploy-name-prefix/mode-development/output.txt @@ -36,6 +36,14 @@ Deployment complete! "key": "skip_artifact_cleanup", "value": false }, + { + "key": "state_path_is_shared", + "value": false + }, + { + "key": "is_maybe_auto_migration_compatible", + "value": true + }, { "key": "has_serverless_compute", "value": false diff --git a/acceptance/bundle/telemetry/deploy-whl-artifacts/output.txt b/acceptance/bundle/telemetry/deploy-whl-artifacts/output.txt index a9b8ce4ae6e..a25e395eae9 100644 --- a/acceptance/bundle/telemetry/deploy-whl-artifacts/output.txt +++ b/acceptance/bundle/telemetry/deploy-whl-artifacts/output.txt @@ -40,6 +40,14 @@ Deployment complete! "key": "skip_artifact_cleanup", "value": false }, + { + "key": "state_path_is_shared", + "value": false + }, + { + "key": "is_maybe_auto_migration_compatible", + "value": true + }, { "key": "has_serverless_compute", "value": false @@ -84,6 +92,14 @@ Deployment complete! "key": "skip_artifact_cleanup", "value": true }, + { + "key": "state_path_is_shared", + "value": false + }, + { + "key": "is_maybe_auto_migration_compatible", + "value": true + }, { "key": "has_serverless_compute", "value": false diff --git a/acceptance/bundle/telemetry/deploy-workspace-folder-permissions/databricks.yml b/acceptance/bundle/telemetry/deploy-workspace-folder-permissions/databricks.yml new file mode 100644 index 00000000000..3698dfb68c1 --- /dev/null +++ b/acceptance/bundle/telemetry/deploy-workspace-folder-permissions/databricks.yml @@ -0,0 +1,112 @@ +bundle: + name: test-bundle + +# The deploying user is tester@databricks.com. A workspace folder under +# /Workspace/Users/ grants CAN_MANAGE, so the deploy observes that +# inherited access in the SetPermissions response. Each target below places the +# deployment state in a different location with different declared permissions to +# exercise the permission-scope telemetry. +targets: + # --- state in /Workspace/Shared (not synced; verdict is static) --- + + # group_name: users has CAN_MANAGE, so the all-users access is declared. + shared_users_can_manage: + default: true + permissions: + - group_name: users + level: CAN_MANAGE + workspace: + state_path: /Workspace/Shared/test-bundle-state + + # users group not granted: the all-users access is not declared. + shared_not_declared: + permissions: + - user_name: ${workspace.current_user.userName} + level: CAN_MANAGE + workspace: + state_path: /Workspace/Shared/test-bundle-state + + shared_no_permissions: + workspace: + state_path: /Workspace/Shared/test-bundle-state + + # --- state under the deploying user's home (the default layout) --- + + # The deploying user is declared, so the inherited owner access is declared too. + user_declared: + permissions: + - user_name: ${workspace.current_user.userName} + level: CAN_MANAGE + + # The deploying user is not declared, but as the folder creator they retain access. + # That is allowed for migration, even though it exceeds the declared permissions. + user_not_declared: + permissions: + - group_name: team + level: CAN_MANAGE + + # No permissions: not synced, so the verdict is static. The state is under the + # deploying user's home, so it is migratable. (For no-permissions bundles the live + # ACL is never observed, so "match" and "not match" coincide.) + user_no_permissions: {} + + # --- state under another user's home --- + + # The other user (the home owner) is declared, so their inherited access is declared. + another_user_declared: + permissions: + - user_name: ${workspace.current_user.userName} + level: CAN_MANAGE + - user_name: other@example.com + level: CAN_MANAGE + workspace: + state_path: /Workspace/Users/other@example.com/test-bundle-state + + # The other user is not declared, so their inherited access exceeds the permissions + # and is not the deploying user, so it is not migratable. + another_user_not_declared: + permissions: + - user_name: ${workspace.current_user.userName} + level: CAN_MANAGE + workspace: + state_path: /Workspace/Users/other@example.com/test-bundle-state + + # No permissions, but the state is under another user's home: that user owns it + # (undeclared access), which we know statically, so it is not compatible. + another_user_no_permissions: + workspace: + state_path: /Workspace/Users/other@example.com/test-bundle-state + + # --- state under a non-home, non-shared folder (/Workspace/) --- + # The script grants permissions on the parent folder before deploying, so the deploy + # observes them as inherited access (real ACLs, not synthesized home ownership). + + # No permissions and not under a user home: unknown without a GetPermissions call. + workspace_other_no_permissions: + workspace: + state_path: /Workspace/teams/test-bundle-state + + # Parent grants group_name: team, which the bundle also declares. + workspace_other_declared: + permissions: + - group_name: team + level: CAN_MANAGE + workspace: + state_path: /Workspace/teams-declared/test-bundle-state + + # Parent grants group_name: team, which the bundle does not declare. + workspace_other_not_declared: + permissions: + - user_name: ${workspace.current_user.userName} + level: CAN_MANAGE + workspace: + state_path: /Workspace/teams-undeclared/test-bundle-state + + # Parent grants the deploying user, which the bundle does not declare. The state is + # not under the deployer's home, so the deployer exemption does not apply. + workspace_other_extra_current_user: + permissions: + - group_name: team + level: CAN_MANAGE + workspace: + state_path: /Workspace/teams-extra/test-bundle-state diff --git a/acceptance/bundle/telemetry/deploy-workspace-folder-permissions/out.test.toml b/acceptance/bundle/telemetry/deploy-workspace-folder-permissions/out.test.toml new file mode 100644 index 00000000000..f784a183258 --- /dev/null +++ b/acceptance/bundle/telemetry/deploy-workspace-folder-permissions/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/bundle/telemetry/deploy-workspace-folder-permissions/output.txt b/acceptance/bundle/telemetry/deploy-workspace-folder-permissions/output.txt new file mode 100644 index 00000000000..3473d5b6967 --- /dev/null +++ b/acceptance/bundle/telemetry/deploy-workspace-folder-permissions/output.txt @@ -0,0 +1,165 @@ + +>>> [CLI] bundle deploy -t shared_users_can_manage +Recommendation: permissions section should explicitly include the current deployment identity '[USERNAME]' or one of its groups +If it is not included, CAN_MANAGE permissions are only applied if the present identity is used to deploy. + +Consider using a adding a top-level permissions section such as the following: + + permissions: + - user_name: [USERNAME] + level: CAN_MANAGE + +See https://docs.databricks.com/dev-tools/bundles/permissions.html to learn more about permission configuration. + in databricks.yml:16:7 + +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/shared_users_can_manage/files... +Deploying resources... +Deployment complete! + +>>> print_telemetry_bool_values +is_definitely_auto_migration_compatible true +state_path_is_shared true + +>>> [CLI] bundle deploy -t shared_not_declared +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/shared_not_declared/files... +Deploying resources... +Deployment complete! + +>>> print_telemetry_bool_values +is_not_auto_migration_compatible true +state_path_is_shared true + +>>> [CLI] bundle deploy -t shared_no_permissions +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/shared_no_permissions/files... +Deploying resources... +Deployment complete! + +>>> print_telemetry_bool_values +is_not_auto_migration_compatible true +state_path_is_shared true + +>>> [CLI] bundle deploy -t user_declared +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/user_declared/files... +Deploying resources... +Deployment complete! + +>>> print_telemetry_bool_values +is_definitely_auto_migration_compatible true +state_path_is_shared false + +>>> [CLI] bundle deploy -t user_not_declared +Recommendation: permissions section should explicitly include the current deployment identity '[USERNAME]' or one of its groups +If it is not included, CAN_MANAGE permissions are only applied if the present identity is used to deploy. + +Consider using a adding a top-level permissions section such as the following: + + permissions: + - user_name: [USERNAME] + level: CAN_MANAGE + +See https://docs.databricks.com/dev-tools/bundles/permissions.html to learn more about permission configuration. + in databricks.yml:45:7 + +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/user_not_declared/files... +Deploying resources... +Deployment complete! + +>>> print_telemetry_bool_values +is_definitely_auto_migration_compatible true +state_path_is_shared false + +>>> [CLI] bundle deploy -t user_no_permissions +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/user_no_permissions/files... +Deploying resources... +Deployment complete! + +>>> print_telemetry_bool_values +is_maybe_auto_migration_compatible true +state_path_is_shared false + +>>> [CLI] bundle deploy -t another_user_declared +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/another_user_declared/files... +Deploying resources... +Deployment complete! + +>>> print_telemetry_bool_values +is_definitely_auto_migration_compatible true +state_path_is_shared false + +>>> [CLI] bundle deploy -t another_user_not_declared +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/another_user_not_declared/files... +Deploying resources... +Deployment complete! + +>>> print_telemetry_bool_values +is_not_auto_migration_compatible true +state_path_is_shared false + +>>> [CLI] bundle deploy -t another_user_no_permissions +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/another_user_no_permissions/files... +Deploying resources... +Deployment complete! + +>>> print_telemetry_bool_values +is_not_auto_migration_compatible true +state_path_is_shared false + +>>> [CLI] bundle deploy -t workspace_other_no_permissions +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/workspace_other_no_permissions/files... +Deploying resources... +Deployment complete! + +>>> print_telemetry_bool_values +is_maybe_auto_migration_compatible true +state_path_is_shared false + +>>> [CLI] bundle deploy -t workspace_other_declared +Recommendation: permissions section should explicitly include the current deployment identity '[USERNAME]' or one of its groups +If it is not included, CAN_MANAGE permissions are only applied if the present identity is used to deploy. + +Consider using a adding a top-level permissions section such as the following: + + permissions: + - user_name: [USERNAME] + level: CAN_MANAGE + +See https://docs.databricks.com/dev-tools/bundles/permissions.html to learn more about permission configuration. + in databricks.yml:92:7 + +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/workspace_other_declared/files... +Deploying resources... +Deployment complete! + +>>> print_telemetry_bool_values +is_definitely_auto_migration_compatible true +state_path_is_shared false + +>>> [CLI] bundle deploy -t workspace_other_not_declared +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/workspace_other_not_declared/files... +Deploying resources... +Deployment complete! + +>>> print_telemetry_bool_values +is_not_auto_migration_compatible true +state_path_is_shared false + +>>> [CLI] bundle deploy -t workspace_other_extra_current_user +Recommendation: permissions section should explicitly include the current deployment identity '[USERNAME]' or one of its groups +If it is not included, CAN_MANAGE permissions are only applied if the present identity is used to deploy. + +Consider using a adding a top-level permissions section such as the following: + + permissions: + - user_name: [USERNAME] + level: CAN_MANAGE + +See https://docs.databricks.com/dev-tools/bundles/permissions.html to learn more about permission configuration. + in databricks.yml:109:7 + +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/workspace_other_extra_current_user/files... +Deploying resources... +Deployment complete! + +>>> print_telemetry_bool_values +is_not_auto_migration_compatible true +state_path_is_shared false diff --git a/acceptance/bundle/telemetry/deploy-workspace-folder-permissions/script b/acceptance/bundle/telemetry/deploy-workspace-folder-permissions/script new file mode 100644 index 00000000000..2b90e2f9402 --- /dev/null +++ b/acceptance/bundle/telemetry/deploy-workspace-folder-permissions/script @@ -0,0 +1,36 @@ +verdict() { + trace $CLI bundle deploy -t "$1" + trace print_telemetry_bool_values | grep -E "state_path|auto_migration" + rm out.requests.txt +} + +# grant_parent sets permissions on a workspace folder so that folders +# created under it during deploy observe the permissions as inherited access. +grant_parent() { + $CLI workspace mkdirs "$1" >>LOG.setup 2>&1 + oid=$($CLI workspace get-status "$1" -o json 2>>LOG.setup | jq -r .object_id) + $CLI workspace set-permissions directories "$oid" --json "$2" >>LOG.setup 2>&1 +} + +for target in \ + shared_users_can_manage \ + shared_not_declared \ + shared_no_permissions \ + user_declared \ + user_not_declared \ + user_no_permissions \ + another_user_declared \ + another_user_not_declared \ + another_user_no_permissions \ + workspace_other_no_permissions; do + verdict "$target" +done + +grant_parent /Workspace/teams-declared '{"access_control_list": [{"group_name": "team", "permission_level": "CAN_MANAGE"}]}' +verdict workspace_other_declared + +grant_parent /Workspace/teams-undeclared '{"access_control_list": [{"group_name": "team", "permission_level": "CAN_MANAGE"}]}' +verdict workspace_other_not_declared + +grant_parent /Workspace/teams-extra "{\"access_control_list\": [{\"user_name\": \"$($CLI current-user me -o json | jq -r .userName)\", \"permission_level\": \"CAN_MANAGE\"}]}" +verdict workspace_other_extra_current_user diff --git a/acceptance/bundle/telemetry/deploy/out.telemetry.txt b/acceptance/bundle/telemetry/deploy/out.telemetry.txt index f945233dd16..e6a7ce40259 100644 --- a/acceptance/bundle/telemetry/deploy/out.telemetry.txt +++ b/acceptance/bundle/telemetry/deploy/out.telemetry.txt @@ -70,6 +70,14 @@ "key": "skip_artifact_cleanup", "value": false }, + { + "key": "state_path_is_shared", + "value": false + }, + { + "key": "is_maybe_auto_migration_compatible", + "value": true + }, { "key": "has_serverless_compute", "value": false diff --git a/bundle/metrics/metrics.go b/bundle/metrics/metrics.go index f61ca18f1e2..27f84c9b1a4 100644 --- a/bundle/metrics/metrics.go +++ b/bundle/metrics/metrics.go @@ -10,4 +10,27 @@ const ( ClusterLifecycleStarted = "cluster_lifecycle_started" SqlWarehouseLifecycleStarted = "sql_warehouse_lifecycle_started" SelectUsed = "select_used" + + // Whether workspace.state_path is under /Workspace/Shared. + StatePathIsShared = "state_path_is_shared" + + // Whether this deploy is compatible with an automatic DMS migration. A deploy is + // compatible when, after migration, the deployment state folder can be managed by + // only the declared permissions — plus the deployer, but only when the state is + // under the deployer's own home directory (/Workspace/Users/), where the + // deployer owns and retains access. So undeclared access by the deployer is fine + // only there; undeclared access by anyone else is never fine. A state folder in + // /Workspace/Shared is compatible only if group_name: users has CAN_MANAGE. + // + // Exactly one of the three keys below is recorded per deploy: + // - definitely: we observed the folder's permissions and they are compatible. + // - not: we observed the folder's permissions and they are not compatible. + // - maybe: no permissions section is set, so we did not sync the folder and + // cannot tell without an additional GetPermissions call. + // + // A deployment (a bundle target, identified by deployment ID) is auto-migratable + // only if all of its deploys are compatible, so aggregate these by deployment ID. + IsDefinitelyAutoMigrationCompatible = "is_definitely_auto_migration_compatible" + IsMaybeAutoMigrationCompatible = "is_maybe_auto_migration_compatible" + IsNotAutoMigrationCompatible = "is_not_auto_migration_compatible" ) diff --git a/bundle/permissions/workspace_path_permissions.go b/bundle/permissions/workspace_path_permissions.go index 6ff1729196b..e7038a968a2 100644 --- a/bundle/permissions/workspace_path_permissions.go +++ b/bundle/permissions/workspace_path_permissions.go @@ -66,6 +66,13 @@ func (p WorkspacePathPermissions) Compare(perms []resources.Permission) diag.Dia return diags } +// Exceeds reports whether the workspace folder grants any principal more access than +// the declared permissions allow. Same condition as Compare, as a boolean. +func (p WorkspacePathPermissions) Exceeds(perms []resources.Permission) bool { + ok, _ := containsAll(p.Permissions, perms) + return !ok +} + // samePrincipal checks if two permissions refer to the same user/group/service principal. func samePrincipal(a, b resources.Permission) bool { return a.UserName == b.UserName && diff --git a/bundle/permissions/workspace_root.go b/bundle/permissions/workspace_root.go index 78b9bfd704a..9e6b196cf06 100644 --- a/bundle/permissions/workspace_root.go +++ b/bundle/permissions/workspace_root.go @@ -3,12 +3,17 @@ package permissions import ( "context" "fmt" + "slices" "strconv" + "strings" "github.com/databricks/cli/bundle" + "github.com/databricks/cli/bundle/config/resources" "github.com/databricks/cli/bundle/libraries" + "github.com/databricks/cli/bundle/metrics" "github.com/databricks/cli/bundle/paths" "github.com/databricks/cli/libs/diag" + "github.com/databricks/cli/libs/iamutil" "github.com/databricks/databricks-sdk-go/service/workspace" "golang.org/x/sync/errgroup" ) @@ -25,21 +30,25 @@ func (*workspaceRootPermissions) Name() string { // Apply implements bundle.Mutator. func (*workspaceRootPermissions) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics { - err := giveAccessForWorkspaceRoot(ctx, b) + stateFolderPermissions, err := giveAccessForWorkspaceRoot(ctx, b) if err != nil { return diag.FromErr(err) } + recordPermissionMetrics(b, stateFolderPermissions) return nil } -func giveAccessForWorkspaceRoot(ctx context.Context, b *bundle.Bundle) error { +// giveAccessForWorkspaceRoot applies the bundle's top-level permissions to the +// workspace folders and returns the resulting permissions of the folder that holds +// the deployment state, or nil when no permissions are declared or that folder is in +// /Workspace/Shared (which is not synced). +func giveAccessForWorkspaceRoot(ctx context.Context, b *bundle.Bundle) (*WorkspacePathPermissions, error) { var permissions []workspace.WorkspaceObjectAccessControlRequest - for _, p := range b.Config.Permissions { level, err := GetWorkspaceObjectPermissionLevel(string(p.Level)) if err != nil { - return err + return nil, err } permissions = append(permissions, workspace.WorkspaceObjectAccessControlRequest{ @@ -51,40 +60,85 @@ func giveAccessForWorkspaceRoot(ctx context.Context, b *bundle.Bundle) error { } if len(permissions) == 0 { - return nil + return nil, nil } w := b.WorkspaceClient(ctx).Workspace bundlePaths := paths.CollectUniqueWorkspacePathPrefixes(b.Config.Workspace) + // Each goroutine writes the folder's resulting permissions into its own slot, + // so they are inspected after Wait rather than concurrently. + folderPermissions := make([]*WorkspacePathPermissions, len(bundlePaths)) g, ctx := errgroup.WithContext(ctx) - for _, p := range bundlePaths { + for i, p := range bundlePaths { g.Go(func() error { - return setPermissions(ctx, w, p, permissions) + wp, err := setPermissions(ctx, w, p, permissions) + folderPermissions[i] = wp + return err }) } - return g.Wait() + if err := g.Wait(); err != nil { + return nil, err + } + + // The deployment state lives under root_path by default, or in its own folder when + // state_path is configured outside root_path. Return that folder's permissions. + var stateFolder string + if pathContains(b.Config.Workspace.RootPath, b.Config.Workspace.StatePath) { + stateFolder = b.Config.Workspace.RootPath + } else { + stateFolder = b.Config.Workspace.StatePath + } + + i := slices.Index(bundlePaths, stateFolder) + if i < 0 { + return nil, nil + } + return folderPermissions[i], nil } -func setPermissions(ctx context.Context, w workspace.WorkspaceInterface, path string, permissions []workspace.WorkspaceObjectAccessControlRequest) error { +// pathContains reports whether the workspace folder at parent is, or is an ancestor +// of, child. Empty paths are treated as a match because workspace paths are fully +// defaulted before deploy. Both paths are /Workspace-normalized by PrependWorkspacePrefix. +func pathContains(parent, child string) bool { + if parent == "" || child == "" { + return true + } + if child == parent { + return true + } + if !strings.HasSuffix(parent, "/") { + parent += "/" + } + return strings.HasPrefix(child, parent) +} + +func setPermissions(ctx context.Context, w workspace.WorkspaceInterface, path string, permissions []workspace.WorkspaceObjectAccessControlRequest) (*WorkspacePathPermissions, error) { // If the folder is shared, then we don't need to set permissions since it's always set for all users and it's checked in mutators before. if libraries.IsWorkspaceSharedPath(path) { - return nil + return nil, nil } obj, err := w.GetStatusByPath(ctx, path) //nolint:staticcheck // Deprecated in SDK v0.127.0. Migration to WorkspaceHierarchyService tracked separately. if err != nil { - return err + return nil, err } - _, err = w.SetPermissions(ctx, workspace.WorkspaceObjectPermissionsRequest{ + // Reusing the SetPermissions response (the folder's resulting ACL) lets us compare + // it against the declaration without an extra API call. The Set replaces the direct + // ACL with the declared permissions, so any principal still showing higher access is + // inherited from a parent folder. + resp, err := w.SetPermissions(ctx, workspace.WorkspaceObjectPermissionsRequest{ WorkspaceObjectId: strconv.FormatInt(obj.ObjectId, 10), WorkspaceObjectType: "directories", AccessControlList: permissions, }) + if err != nil { + return nil, err + } - return err + return ObjectAclToResourcePermissions(path, resp.AccessControlList), nil } func GetWorkspaceObjectPermissionLevel(bundlePermission string) (workspace.WorkspaceObjectPermissionLevel, error) { @@ -99,3 +153,111 @@ func GetWorkspaceObjectPermissionLevel(bundlePermission string) (workspace.Works return "", fmt.Errorf("unsupported bundle permission level %s", bundlePermission) } } + +// recordPermissionMetrics records telemetry describing how the deployment state +// folder's permissions relate to the bundle's declared permissions. stateFolderPerms +// is the folder's live ACL, or nil when it was not observed (no permissions declared, +// or the folder is in /Workspace/Shared). +func recordPermissionMetrics(b *bundle.Bundle, stateFolderPerms *WorkspacePathPermissions) { + b.Metrics.SetBoolValue(metrics.StatePathIsShared, libraries.IsWorkspaceSharedPath(b.Config.Workspace.StatePath)) + // Emit exactly one of the three auto-migration verdict keys. + b.Metrics.SetBoolValue(autoMigrationVerdict(b, stateFolderPerms), true) +} + +// autoMigrationVerdict returns the metric key describing whether the state folder is +// compatible with an automatic migration. It depends on where the state lives: +// +// - /Workspace/Shared: readable and writable by all workspace users, so it is +// compatible only when that broad access is declared via group_name: users +// CAN_MANAGE. Known statically. +// - Under another user's home (/Workspace/Users/): that user owns the folder, +// which is undeclared access we cannot account for, so it is not compatible. Known +// statically. +// - No permissions section, elsewhere: the folder is not synced, so its ACL is never +// observed and we cannot tell without an additional GetPermissions call. +// - Permissions set (non-shared): the SetPermissions response gives the live ACL. +// The folder is compatible when every principal on it is declared, with one +// exception: the deployer is allowed even when not declared, but only when the +// state is under their own home (/Workspace/Users/), where they own and +// retain access. Elsewhere the deployer has no inherent access and the exemption +// does not apply. +func autoMigrationVerdict(b *bundle.Bundle, stateFolderPerms *WorkspacePathPermissions) string { + statePath := b.Config.Workspace.StatePath + + if libraries.IsWorkspaceSharedPath(statePath) { + if usersGroupCanManage(b.Config.Permissions) { + return metrics.IsDefinitelyAutoMigrationCompatible + } + return metrics.IsNotAutoMigrationCompatible + } + + deployer := identityName(deployingUserPermission(b)) + owner := homeOwner(statePath) + + if stateFolderPerms == nil { + // No permissions section: the folder was not synced, so its ACL is unobserved. + if owner != "" && owner != deployer { + // Another user owns the home the state lives under; their access is undeclared. + return metrics.IsNotAutoMigrationCompatible + } + // The deployer's own home or a non-home path; we cannot tell without a GetPermissions call. + return metrics.IsMaybeAutoMigrationCompatible + } + + // The deployer's undeclared access is allowed only under their own home. + allowed := b.Config.Permissions + if deployer != "" && owner == deployer { + allowed = append(slices.Clone(allowed), deployingUserPermission(b)) + } + + if stateFolderPerms.Exceeds(allowed) { + return metrics.IsNotAutoMigrationCompatible + } + return metrics.IsDefinitelyAutoMigrationCompatible +} + +// homeOwner returns the user that owns the home directory the path is under, i.e. +// for /Workspace/Users//..., or "" if the path is not under a home. +func homeOwner(path string) string { + const prefix = "/Workspace/Users/" + if !strings.HasPrefix(path, prefix) { + return "" + } + rest := path[len(prefix):] + if before, _, ok := strings.Cut(rest, "/"); ok { + return before + } + return rest +} + +// identityName returns the user or service principal name of a permission. +func identityName(p resources.Permission) string { + if p.UserName != "" { + return p.UserName + } + return p.ServicePrincipalName +} + +// deployingUserPermission returns a CAN_MANAGE permission for the deploying identity. +func deployingUserPermission(b *bundle.Bundle) resources.Permission { + p := resources.Permission{Level: CAN_MANAGE} + cu := b.Config.Workspace.CurrentUser + if cu == nil { + return p + } + if iamutil.IsServicePrincipal(cu.User) { + p.ServicePrincipalName = cu.UserName + } else { + p.UserName = cu.UserName + } + return p +} + +func usersGroupCanManage(perms []resources.Permission) bool { + for _, p := range perms { + if p.GroupName == "users" && p.Level == CAN_MANAGE { + return true + } + } + return false +} diff --git a/bundle/permissions/workspace_root_test.go b/bundle/permissions/workspace_root_test.go index a3b5f5ac2d9..c97de014bba 100644 --- a/bundle/permissions/workspace_root_test.go +++ b/bundle/permissions/workspace_root_test.go @@ -70,7 +70,7 @@ func TestApplyWorkspaceRootPermissions(t *testing.T) { }, WorkspaceObjectId: "1234", WorkspaceObjectType: "directories", - }).Return(nil, nil) + }).Return(&workspace.WorkspaceObjectPermissions{}, nil) diags := bundle.ApplySeq(t.Context(), b, ValidateSharedRootPermissions(), ApplyWorkspaceRootPermissions()) require.Empty(t, diags) @@ -143,7 +143,7 @@ func TestApplyWorkspaceRootPermissionsForAllPaths(t *testing.T) { }, WorkspaceObjectId: "1", WorkspaceObjectType: "directories", - }).Return(nil, nil) + }).Return(&workspace.WorkspaceObjectPermissions{}, nil) workspaceApi.EXPECT().SetPermissions(mock.Anything, workspace.WorkspaceObjectPermissionsRequest{ AccessControlList: []workspace.WorkspaceObjectAccessControlRequest{ @@ -153,7 +153,7 @@ func TestApplyWorkspaceRootPermissionsForAllPaths(t *testing.T) { }, WorkspaceObjectId: "2", WorkspaceObjectType: "directories", - }).Return(nil, nil) + }).Return(&workspace.WorkspaceObjectPermissions{}, nil) workspaceApi.EXPECT().SetPermissions(mock.Anything, workspace.WorkspaceObjectPermissionsRequest{ AccessControlList: []workspace.WorkspaceObjectAccessControlRequest{ @@ -163,7 +163,7 @@ func TestApplyWorkspaceRootPermissionsForAllPaths(t *testing.T) { }, WorkspaceObjectId: "3", WorkspaceObjectType: "directories", - }).Return(nil, nil) + }).Return(&workspace.WorkspaceObjectPermissions{}, nil) workspaceApi.EXPECT().SetPermissions(mock.Anything, workspace.WorkspaceObjectPermissionsRequest{ AccessControlList: []workspace.WorkspaceObjectAccessControlRequest{ @@ -173,7 +173,7 @@ func TestApplyWorkspaceRootPermissionsForAllPaths(t *testing.T) { }, WorkspaceObjectId: "4", WorkspaceObjectType: "directories", - }).Return(nil, nil) + }).Return(&workspace.WorkspaceObjectPermissions{}, nil) workspaceApi.EXPECT().SetPermissions(mock.Anything, workspace.WorkspaceObjectPermissionsRequest{ AccessControlList: []workspace.WorkspaceObjectAccessControlRequest{ @@ -183,7 +183,7 @@ func TestApplyWorkspaceRootPermissionsForAllPaths(t *testing.T) { }, WorkspaceObjectId: "5", WorkspaceObjectType: "directories", - }).Return(nil, nil) + }).Return(&workspace.WorkspaceObjectPermissions{}, nil) diags := bundle.Apply(t.Context(), b, ApplyWorkspaceRootPermissions()) require.NoError(t, diags.Error()) diff --git a/libs/testserver/permissions.go b/libs/testserver/permissions.go index e7983b1afa7..c775245fc6e 100644 --- a/libs/testserver/permissions.go +++ b/libs/testserver/permissions.go @@ -3,6 +3,9 @@ package testserver import ( "encoding/json" "fmt" + "slices" + "strconv" + "strings" "github.com/databricks/databricks-sdk-go/service/iam" ) @@ -294,7 +297,103 @@ func (s *FakeWorkspace) SetPermissions(req Request) any { s.Permissions[responseObjectID] = existingPermissions + // Workspace folder permissions cascade to children: SetPermissions replaces the + // folder's direct ACL but inherited access persists. Reflect that in the response + // (not the stored value) so callers observe the effective ACL. + response := existingPermissions + if requestObjectType == "directories" { + response.AccessControlList = slices.Clone(existingPermissions.AccessControlList) + for _, entry := range s.inheritedDirectoryACLs(objectId) { + appendACLIfAbsent(&response, entry) + } + } + return Response{ - Body: existingPermissions, + Body: response, + } +} + +// inheritedDirectoryACLs returns the ACL entries a directory inherits from its location: +// the home-folder owner for a directory under /Workspace/Users/, plus the direct +// ACL of any ancestor directory. Must be called with s.mu held. +func (s *FakeWorkspace) inheritedDirectoryACLs(objectId string) []iam.AccessControlResponse { + path := s.directoryPath(objectId) + if path == "" { + return nil } + + var inherited []iam.AccessControlResponse + if owner := homeFolderOwner(path); owner != "" { + inherited = append(inherited, iam.AccessControlResponse{ + UserName: owner, + AllPermissions: []iam.Permission{{PermissionLevel: "CAN_MANAGE", Inherited: true}}, + }) + } + for ancestor := parentPath(path); ancestor != ""; ancestor = parentPath(ancestor) { + dir, ok := s.directories[ancestor] + if !ok { + continue + } + stored, ok := s.Permissions["/directories/"+strconv.FormatInt(dir.ObjectId, 10)] + if !ok { + continue + } + for _, acl := range stored.AccessControlList { + inherited = append(inherited, asInheritedACL(acl)) + } + } + return inherited +} + +func (s *FakeWorkspace) directoryPath(objectId string) string { + for path, info := range s.directories { + if strconv.FormatInt(info.ObjectId, 10) == objectId { + return path + } + } + return "" +} + +// homeFolderOwner returns for a path under /Workspace/Users/, else "". +func homeFolderOwner(path string) string { + const usersPrefix = "/Workspace/Users/" + if !strings.HasPrefix(path, usersPrefix) { + return "" + } + rest := path[len(usersPrefix):] + if before, _, ok := strings.Cut(rest, "/"); ok { + return before + } + return rest +} + +func parentPath(path string) string { + i := strings.LastIndexByte(path, '/') + if i <= 0 { + return "" + } + return path[:i] +} + +// asInheritedACL marks every permission level in the entry as inherited. +func asInheritedACL(acl iam.AccessControlResponse) iam.AccessControlResponse { + perms := make([]iam.Permission, len(acl.AllPermissions)) + for i, p := range acl.AllPermissions { + p.Inherited = true + perms[i] = p + } + acl.AllPermissions = perms + return acl +} + +// appendACLIfAbsent appends entry only when the principal is not already present, so a +// folder's direct ACL takes precedence over an inherited entry for the same principal. +func appendACLIfAbsent(perms *iam.ObjectPermissions, entry iam.AccessControlResponse) { + key := aclPrincipalKey(entry) + for _, acl := range perms.AccessControlList { + if aclPrincipalKey(acl) == key { + return + } + } + perms.AccessControlList = append(perms.AccessControlList, entry) }