From e1812fa89da18c074dbee3be38e7aebcb2f8d033 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Fri, 19 Jun 2026 17:22:30 +0200 Subject: [PATCH] bundle: add DMS auto-migration breakdown telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merged dms_compat_* verdict tells us whether a deploy is auto-migration compatible, but not why. This adds an independent boolean breakdown recorded on every deploy so the population can be sliced directly: - permissions_section_set: separates the always-compatible no-permissions population from declared-permissions deploys (both land on dms_compat_auto). - state_path_in_deployer_home / state_path_in_other_user_home: where the deployment state lives (with the existing state_path_is_shared; all false means some other /Workspace folder). - dms_undeclared_deploying_user / _other_user / _service_principal / _group: which principal types hold undeclared write access to the state folder — the access an auto-migration governed by the permissions section would drop. These co-occur. All keys go through the existing BoolValues map, so no telemetry proto change is needed. Exact per-type counts would need a numeric proto field and are left out. Covered by the existing deploy-workspace-folder-permissions acceptance test (extended with an other-user-home target) plus unit tests for the principal-type classification and home-owner parsing. Co-authored-by: Shreyas Goenka --- .../job_tasks/out.telemetry.direct.txt | 7 ++ .../job_tasks/out.telemetry.terraform.txt | 7 ++ .../resource_deps/resources_var/output.txt | 7 ++ .../out.telemetry.terraform.txt | 7 ++ .../deploy-app-lifecycle-started/output.txt | 28 +++++++ .../telemetry/deploy-compute-type/output.txt | 56 +++++++++++++ .../telemetry/deploy-experimental/output.txt | 28 +++++++ .../deploy-name-prefix/custom/output.txt | 28 +++++++ .../mode-development/output.txt | 28 +++++++ .../telemetry/deploy-whl-artifacts/output.txt | 56 +++++++++++++ .../databricks.yml | 9 +++ .../output.txt | 53 +++++++++++- .../script | 3 +- .../bundle/telemetry/deploy/out.telemetry.txt | 28 +++++++ bundle/metrics/metrics.go | 22 +++++ bundle/permissions/workspace_root.go | 80 ++++++++++++++++--- bundle/permissions/workspace_root_test.go | 49 ++++++++++++ 17 files changed, 485 insertions(+), 11 deletions(-) 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 25d32d98382..5f71760b8ee 100644 --- a/acceptance/bundle/resource_deps/job_tasks/out.telemetry.direct.txt +++ b/acceptance/bundle/resource_deps/job_tasks/out.telemetry.direct.txt @@ -1,12 +1,19 @@ dms_compat_auto true +dms_undeclared_deploying_user false +dms_undeclared_group false +dms_undeclared_other_user false +dms_undeclared_service_principal false experimental.use_legacy_run_as false has_classic_interactive_compute false has_classic_job_compute false has_serverless_compute true local.cache.attempt true local.cache.miss true +permissions_section_set false presets_name_prefix_is_set false python_wheel_wrapper_is_set false run_as_set false skip_artifact_cleanup false +state_path_in_deployer_home true +state_path_in_other_user_home 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 25d32d98382..5f71760b8ee 100644 --- a/acceptance/bundle/resource_deps/job_tasks/out.telemetry.terraform.txt +++ b/acceptance/bundle/resource_deps/job_tasks/out.telemetry.terraform.txt @@ -1,12 +1,19 @@ dms_compat_auto true +dms_undeclared_deploying_user false +dms_undeclared_group false +dms_undeclared_other_user false +dms_undeclared_service_principal false experimental.use_legacy_run_as false has_classic_interactive_compute false has_classic_job_compute false has_serverless_compute true local.cache.attempt true local.cache.miss true +permissions_section_set false presets_name_prefix_is_set false python_wheel_wrapper_is_set false run_as_set false skip_artifact_cleanup false +state_path_in_deployer_home true +state_path_in_other_user_home 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 eb05259f812..4391d7522e8 100644 --- a/acceptance/bundle/resource_deps/resources_var/output.txt +++ b/acceptance/bundle/resource_deps/resources_var/output.txt @@ -37,14 +37,21 @@ >>> print_telemetry_bool_values dms_compat_auto true +dms_undeclared_deploying_user false +dms_undeclared_group false +dms_undeclared_other_user false +dms_undeclared_service_principal false experimental.use_legacy_run_as false has_classic_interactive_compute false has_classic_job_compute false has_serverless_compute false local.cache.attempt true local.cache.hit true +permissions_section_set false presets_name_prefix_is_set true python_wheel_wrapper_is_set false run_as_set false skip_artifact_cleanup false +state_path_in_deployer_home true +state_path_in_other_user_home 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 2b07a4f52c0..312e6eb39ca 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 @@ -1,4 +1,8 @@ dms_compat_auto true +dms_undeclared_deploying_user false +dms_undeclared_group false +dms_undeclared_other_user false +dms_undeclared_service_principal false experimental.use_legacy_run_as false has_classic_interactive_compute false has_classic_job_compute false @@ -6,8 +10,11 @@ has_serverless_compute false has_tf_only_references true local.cache.attempt true local.cache.hit true +permissions_section_set false presets_name_prefix_is_set false python_wheel_wrapper_is_set false run_as_set false skip_artifact_cleanup false +state_path_in_deployer_home true +state_path_in_other_user_home 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 b829ad36e52..5c64eba59d5 100644 --- a/acceptance/bundle/telemetry/deploy-app-lifecycle-started/output.txt +++ b/acceptance/bundle/telemetry/deploy-app-lifecycle-started/output.txt @@ -41,6 +41,34 @@ Deployment complete! "key": "state_path_is_shared", "value": false }, + { + "key": "permissions_section_set", + "value": false + }, + { + "key": "state_path_in_deployer_home", + "value": true + }, + { + "key": "state_path_in_other_user_home", + "value": false + }, + { + "key": "dms_undeclared_deploying_user", + "value": false + }, + { + "key": "dms_undeclared_other_user", + "value": false + }, + { + "key": "dms_undeclared_service_principal", + "value": false + }, + { + "key": "dms_undeclared_group", + "value": false + }, { "key": "dms_compat_auto", "value": true diff --git a/acceptance/bundle/telemetry/deploy-compute-type/output.txt b/acceptance/bundle/telemetry/deploy-compute-type/output.txt index 7895c1d82e0..ac640f00478 100644 --- a/acceptance/bundle/telemetry/deploy-compute-type/output.txt +++ b/acceptance/bundle/telemetry/deploy-compute-type/output.txt @@ -45,6 +45,34 @@ Deployment complete! "key": "state_path_is_shared", "value": false }, + { + "key": "permissions_section_set", + "value": false + }, + { + "key": "state_path_in_deployer_home", + "value": true + }, + { + "key": "state_path_in_other_user_home", + "value": false + }, + { + "key": "dms_undeclared_deploying_user", + "value": false + }, + { + "key": "dms_undeclared_other_user", + "value": false + }, + { + "key": "dms_undeclared_service_principal", + "value": false + }, + { + "key": "dms_undeclared_group", + "value": false + }, { "key": "dms_compat_auto", "value": true @@ -95,6 +123,34 @@ Deployment complete! "key": "state_path_is_shared", "value": false }, + { + "key": "permissions_section_set", + "value": false + }, + { + "key": "state_path_in_deployer_home", + "value": true + }, + { + "key": "state_path_in_other_user_home", + "value": false + }, + { + "key": "dms_undeclared_deploying_user", + "value": false + }, + { + "key": "dms_undeclared_other_user", + "value": false + }, + { + "key": "dms_undeclared_service_principal", + "value": false + }, + { + "key": "dms_undeclared_group", + "value": false + }, { "key": "dms_compat_auto", "value": true diff --git a/acceptance/bundle/telemetry/deploy-experimental/output.txt b/acceptance/bundle/telemetry/deploy-experimental/output.txt index 665b1fab807..6f7c0a67e72 100644 --- a/acceptance/bundle/telemetry/deploy-experimental/output.txt +++ b/acceptance/bundle/telemetry/deploy-experimental/output.txt @@ -44,6 +44,34 @@ Deployment complete! "key": "state_path_is_shared", "value": false }, + { + "key": "permissions_section_set", + "value": false + }, + { + "key": "state_path_in_deployer_home", + "value": true + }, + { + "key": "state_path_in_other_user_home", + "value": false + }, + { + "key": "dms_undeclared_deploying_user", + "value": false + }, + { + "key": "dms_undeclared_other_user", + "value": false + }, + { + "key": "dms_undeclared_service_principal", + "value": false + }, + { + "key": "dms_undeclared_group", + "value": false + }, { "key": "dms_compat_auto", "value": true diff --git a/acceptance/bundle/telemetry/deploy-name-prefix/custom/output.txt b/acceptance/bundle/telemetry/deploy-name-prefix/custom/output.txt index 8c54c247c38..f83173472c4 100644 --- a/acceptance/bundle/telemetry/deploy-name-prefix/custom/output.txt +++ b/acceptance/bundle/telemetry/deploy-name-prefix/custom/output.txt @@ -40,6 +40,34 @@ Deployment complete! "key": "state_path_is_shared", "value": false }, + { + "key": "permissions_section_set", + "value": false + }, + { + "key": "state_path_in_deployer_home", + "value": true + }, + { + "key": "state_path_in_other_user_home", + "value": false + }, + { + "key": "dms_undeclared_deploying_user", + "value": false + }, + { + "key": "dms_undeclared_other_user", + "value": false + }, + { + "key": "dms_undeclared_service_principal", + "value": false + }, + { + "key": "dms_undeclared_group", + "value": false + }, { "key": "dms_compat_auto", "value": true 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 9bc57dfd206..60ace1ea42d 100644 --- a/acceptance/bundle/telemetry/deploy-name-prefix/mode-development/output.txt +++ b/acceptance/bundle/telemetry/deploy-name-prefix/mode-development/output.txt @@ -40,6 +40,34 @@ Deployment complete! "key": "state_path_is_shared", "value": false }, + { + "key": "permissions_section_set", + "value": false + }, + { + "key": "state_path_in_deployer_home", + "value": true + }, + { + "key": "state_path_in_other_user_home", + "value": false + }, + { + "key": "dms_undeclared_deploying_user", + "value": false + }, + { + "key": "dms_undeclared_other_user", + "value": false + }, + { + "key": "dms_undeclared_service_principal", + "value": false + }, + { + "key": "dms_undeclared_group", + "value": false + }, { "key": "dms_compat_auto", "value": true diff --git a/acceptance/bundle/telemetry/deploy-whl-artifacts/output.txt b/acceptance/bundle/telemetry/deploy-whl-artifacts/output.txt index a8b577bde3b..45f7e090b4d 100644 --- a/acceptance/bundle/telemetry/deploy-whl-artifacts/output.txt +++ b/acceptance/bundle/telemetry/deploy-whl-artifacts/output.txt @@ -44,6 +44,34 @@ Deployment complete! "key": "state_path_is_shared", "value": false }, + { + "key": "permissions_section_set", + "value": false + }, + { + "key": "state_path_in_deployer_home", + "value": true + }, + { + "key": "state_path_in_other_user_home", + "value": false + }, + { + "key": "dms_undeclared_deploying_user", + "value": false + }, + { + "key": "dms_undeclared_other_user", + "value": false + }, + { + "key": "dms_undeclared_service_principal", + "value": false + }, + { + "key": "dms_undeclared_group", + "value": false + }, { "key": "dms_compat_auto", "value": true @@ -96,6 +124,34 @@ Deployment complete! "key": "state_path_is_shared", "value": false }, + { + "key": "permissions_section_set", + "value": false + }, + { + "key": "state_path_in_deployer_home", + "value": true + }, + { + "key": "state_path_in_other_user_home", + "value": false + }, + { + "key": "dms_undeclared_deploying_user", + "value": false + }, + { + "key": "dms_undeclared_other_user", + "value": false + }, + { + "key": "dms_undeclared_service_principal", + "value": false + }, + { + "key": "dms_undeclared_group", + "value": false + }, { "key": "dms_compat_auto", "value": true diff --git a/acceptance/bundle/telemetry/deploy-workspace-folder-permissions/databricks.yml b/acceptance/bundle/telemetry/deploy-workspace-folder-permissions/databricks.yml index a4a69cbce2d..7b07e22ba9a 100644 --- a/acceptance/bundle/telemetry/deploy-workspace-folder-permissions/databricks.yml +++ b/acceptance/bundle/telemetry/deploy-workspace-folder-permissions/databricks.yml @@ -23,6 +23,15 @@ targets: - group_name: team level: CAN_MANAGE + # The state lives under another user's home, where that user has inherited CAN_MANAGE + # that the bundle does not declare. + other_user_not_declared: + permissions: + - user_name: ${workspace.current_user.userName} + level: CAN_MANAGE + workspace: + state_path: /Workspace/Users/other@example.com/test-bundle-state + # A shared state folder is writable by all workspace users; that access is declared # via group_name: users CAN_MANAGE. shared_users_can_manage: diff --git a/acceptance/bundle/telemetry/deploy-workspace-folder-permissions/output.txt b/acceptance/bundle/telemetry/deploy-workspace-folder-permissions/output.txt index 236a16b41a5..7d1c4ef8dc6 100644 --- a/acceptance/bundle/telemetry/deploy-workspace-folder-permissions/output.txt +++ b/acceptance/bundle/telemetry/deploy-workspace-folder-permissions/output.txt @@ -6,6 +6,13 @@ Deployment complete! >>> print_telemetry_bool_values dms_compat_auto true +dms_undeclared_deploying_user false +dms_undeclared_group false +dms_undeclared_other_user false +dms_undeclared_service_principal false +permissions_section_set false +state_path_in_deployer_home true +state_path_in_other_user_home false state_path_is_shared false >>> [CLI] bundle deploy -t user_declared @@ -15,6 +22,13 @@ Deployment complete! >>> print_telemetry_bool_values dms_compat_auto true +dms_undeclared_deploying_user false +dms_undeclared_group false +dms_undeclared_other_user false +dms_undeclared_service_principal false +permissions_section_set true +state_path_in_deployer_home true +state_path_in_other_user_home false state_path_is_shared false >>> [CLI] bundle deploy -t user_not_declared @@ -36,6 +50,29 @@ Deployment complete! >>> print_telemetry_bool_values dms_compat_only_self_undeclared true +dms_undeclared_deploying_user true +dms_undeclared_group false +dms_undeclared_other_user false +dms_undeclared_service_principal false +permissions_section_set true +state_path_in_deployer_home true +state_path_in_other_user_home false +state_path_is_shared false + +>>> [CLI] bundle deploy -t other_user_not_declared +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/other_user_not_declared/files... +Deploying resources... +Deployment complete! + +>>> print_telemetry_bool_values +dms_compat_not true +dms_undeclared_deploying_user false +dms_undeclared_group false +dms_undeclared_other_user true +dms_undeclared_service_principal false +permissions_section_set true +state_path_in_deployer_home false +state_path_in_other_user_home true state_path_is_shared false >>> [CLI] bundle deploy -t shared_users_can_manage @@ -49,7 +86,7 @@ Consider using a adding a top-level permissions section such as the following: level: CAN_MANAGE See https://docs.databricks.com/dev-tools/bundles/permissions.html to learn more about permission configuration. - in databricks.yml:30:7 + in databricks.yml:39:7 Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/shared_users_can_manage/files... Deploying resources... @@ -57,6 +94,13 @@ Deployment complete! >>> print_telemetry_bool_values dms_compat_auto true +dms_undeclared_deploying_user false +dms_undeclared_group false +dms_undeclared_other_user false +dms_undeclared_service_principal false +permissions_section_set true +state_path_in_deployer_home false +state_path_in_other_user_home false state_path_is_shared true >>> [CLI] bundle deploy -t shared_not_declared @@ -66,4 +110,11 @@ Deployment complete! >>> print_telemetry_bool_values dms_compat_not true +dms_undeclared_deploying_user false +dms_undeclared_group true +dms_undeclared_other_user false +dms_undeclared_service_principal false +permissions_section_set true +state_path_in_deployer_home false +state_path_in_other_user_home false state_path_is_shared true diff --git a/acceptance/bundle/telemetry/deploy-workspace-folder-permissions/script b/acceptance/bundle/telemetry/deploy-workspace-folder-permissions/script index 4d11d70c3aa..61a2417e000 100644 --- a/acceptance/bundle/telemetry/deploy-workspace-folder-permissions/script +++ b/acceptance/bundle/telemetry/deploy-workspace-folder-permissions/script @@ -2,9 +2,10 @@ for target in \ no_permissions \ user_declared \ user_not_declared \ + other_user_not_declared \ shared_users_can_manage \ shared_not_declared; do trace $CLI bundle deploy -t "$target" - trace print_telemetry_bool_values | grep -E "state_path|dms_compat" + trace print_telemetry_bool_values | grep -E "state_path|permissions_section|dms_" rm out.requests.txt done diff --git a/acceptance/bundle/telemetry/deploy/out.telemetry.txt b/acceptance/bundle/telemetry/deploy/out.telemetry.txt index 2171a3f680a..1c06f0f3e07 100644 --- a/acceptance/bundle/telemetry/deploy/out.telemetry.txt +++ b/acceptance/bundle/telemetry/deploy/out.telemetry.txt @@ -74,6 +74,34 @@ "key": "state_path_is_shared", "value": false }, + { + "key": "permissions_section_set", + "value": false + }, + { + "key": "state_path_in_deployer_home", + "value": true + }, + { + "key": "state_path_in_other_user_home", + "value": false + }, + { + "key": "dms_undeclared_deploying_user", + "value": false + }, + { + "key": "dms_undeclared_other_user", + "value": false + }, + { + "key": "dms_undeclared_service_principal", + "value": false + }, + { + "key": "dms_undeclared_group", + "value": false + }, { "key": "dms_compat_auto", "value": true diff --git a/bundle/metrics/metrics.go b/bundle/metrics/metrics.go index eadc06cb7dc..c0e60440f4e 100644 --- a/bundle/metrics/metrics.go +++ b/bundle/metrics/metrics.go @@ -39,4 +39,26 @@ const ( DMSCompatAuto = "dms_compat_auto" DMSCompatOnlySelfUndeclared = "dms_compat_only_self_undeclared" DMSCompatNot = "dms_compat_not" + + // Breakdown dimensions recorded on every deploy alongside the verdict above, so the + // DMS auto-migration population can be sliced without inferring it from the verdict. + // Each is an independent boolean. + + // Whether a top-level permissions section is set. The no-permissions case is always + // auto-migration compatible (folder ACLs are mirrored), so this separates the two + // populations that both land on dms_compat_auto. + PermissionsSectionSet = "permissions_section_set" + + // Where the deployment state folder lives. Mutually exclusive with each other and + // with StatePathIsShared; all false means some other /Workspace folder. + StatePathInDeployerHome = "state_path_in_deployer_home" + StatePathInOtherUserHome = "state_path_in_other_user_home" + + // Which principal types have undeclared write access to the state folder — the + // access an auto-migration governed by the permissions section would drop. These can + // co-occur; all false when the deploy is auto-migration compatible. + DMSUndeclaredDeployingUser = "dms_undeclared_deploying_user" + DMSUndeclaredOtherUser = "dms_undeclared_other_user" + DMSUndeclaredServicePrincipal = "dms_undeclared_service_principal" + DMSUndeclaredGroup = "dms_undeclared_group" ) diff --git a/bundle/permissions/workspace_root.go b/bundle/permissions/workspace_root.go index 9bb9065fe80..88ba900b801 100644 --- a/bundle/permissions/workspace_root.go +++ b/bundle/permissions/workspace_root.go @@ -5,6 +5,7 @@ import ( "fmt" "slices" "strconv" + "strings" "github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle/config/resources" @@ -139,16 +140,42 @@ func GetWorkspaceObjectPermissionLevel(bundlePermission string) (workspace.Works // folder's permissions relate to the bundle's declared permissions. stateFolderPerms // is the folder's resulting ACL, or nil when no permissions are declared (no folders // are synced in that case). +// +// Alongside the single auto-migration verdict it emits an independent boolean breakdown +// (state folder location, whether a permissions section is set, and which principal +// types have undeclared write access) so the population can be sliced directly. func recordPermissionMetrics(b *bundle.Bundle, stateFolderPerms *WorkspacePathPermissions) { - b.Metrics.SetBoolValue(metrics.StatePathIsShared, libraries.IsWorkspaceSharedPath(b.Config.Workspace.StatePath)) + statePath := b.Config.Workspace.StatePath + deployer := deployingUserName(b) + + b.Metrics.SetBoolValue(metrics.StatePathIsShared, libraries.IsWorkspaceSharedPath(statePath)) + b.Metrics.SetBoolValue(metrics.PermissionsSectionSet, len(b.Config.Permissions) > 0) + + owner, underUserHome := userHomeOwner(statePath) + b.Metrics.SetBoolValue(metrics.StatePathInDeployerHome, underUserHome && deployer != "" && owner == deployer) + b.Metrics.SetBoolValue(metrics.StatePathInOtherUserHome, underUserHome && owner != deployer) + + // stateFolderPerms is nil when no permissions are declared, in which case there are + // no undeclared writers (the migration mirrors the folder's ACLs). + var undeclared []resources.Permission + if stateFolderPerms != nil { + undeclared = stateFolderPerms.UndeclaredWriters(b.Config.Permissions) + } + self, otherUser, servicePrincipal, group := undeclaredWriterTypes(undeclared, deployer) + b.Metrics.SetBoolValue(metrics.DMSUndeclaredDeployingUser, self) + b.Metrics.SetBoolValue(metrics.DMSUndeclaredOtherUser, otherUser) + b.Metrics.SetBoolValue(metrics.DMSUndeclaredServicePrincipal, servicePrincipal) + b.Metrics.SetBoolValue(metrics.DMSUndeclaredGroup, group) + // Emit exactly one of the auto-migration verdict keys. - b.Metrics.SetBoolValue(autoMigrationVerdict(b, stateFolderPerms), true) + b.Metrics.SetBoolValue(autoMigrationVerdict(b, stateFolderPerms, undeclared), true) } // autoMigrationVerdict returns the metric key describing whether this deploy is // compatible with an automatic migration of the deployment state to a dedicated -// state storage service. See metrics.DMSCompatAuto. -func autoMigrationVerdict(b *bundle.Bundle, stateFolderPerms *WorkspacePathPermissions) string { +// state storage service. undeclared is the state folder's undeclared writers (empty +// when no permissions are declared). See metrics.DMSCompatAuto. +func autoMigrationVerdict(b *bundle.Bundle, stateFolderPerms *WorkspacePathPermissions, undeclared []resources.Permission) string { // No permissions section: the migration mirrors the state folder's ACLs onto the // deployment (CAN_EDIT -> CAN_EDIT, CAN_MANAGE -> CAN_MANAGE), preserving // everyone's access wherever the state lives. @@ -167,7 +194,6 @@ func autoMigrationVerdict(b *bundle.Bundle, stateFolderPerms *WorkspacePathPermi // The migration applies exactly the declared permissions to the deployment, so // anyone with write access to the state folder who is not declared loses the // ability to deploy. - undeclared := stateFolderPerms.UndeclaredWriters(b.Config.Permissions) switch { case len(undeclared) == 0: return metrics.DMSCompatAuto @@ -181,10 +207,46 @@ func autoMigrationVerdict(b *bundle.Bundle, stateFolderPerms *WorkspacePathPermi } } -// isDeployingUser reports whether p is the user performing the deploy. -func isDeployingUser(b *bundle.Bundle, p resources.Permission) bool { +// undeclaredWriterTypes classifies undeclared writers by principal type, distinguishing +// the deploying user from other users. +func undeclaredWriterTypes(undeclared []resources.Permission, deployer string) (self, otherUser, servicePrincipal, group bool) { + for _, p := range undeclared { + switch { + case p.UserName != "" && p.UserName == deployer: + self = true + case p.UserName != "": + otherUser = true + case p.ServicePrincipalName != "": + servicePrincipal = true + case p.GroupName != "": + group = true + } + } + return self, otherUser, servicePrincipal, group +} + +// userHomeOwner returns the owner of the user home folder containing path, i.e. +// for a path under /Workspace/Users/. ok is false when path is not under a user +// home folder. +func userHomeOwner(path string) (owner string, ok bool) { + const prefix = "/Workspace/Users/" + if !strings.HasPrefix(path, prefix) { + return "", false + } + owner, _, _ = strings.Cut(path[len(prefix):], "/") + return owner, owner != "" +} + +// deployingUserName returns the user performing the deploy, or "" when not yet resolved. +func deployingUserName(b *bundle.Bundle) string { if b.Config.Workspace.CurrentUser == nil { - return false + return "" } - return p.UserName != "" && p.UserName == b.Config.Workspace.CurrentUser.UserName + return b.Config.Workspace.CurrentUser.UserName +} + +// isDeployingUser reports whether p is the user performing the deploy. +func isDeployingUser(b *bundle.Bundle, p resources.Permission) bool { + deployer := deployingUserName(b) + return p.UserName != "" && p.UserName == deployer } diff --git a/bundle/permissions/workspace_root_test.go b/bundle/permissions/workspace_root_test.go index c97de014bba..db4ea36fc8c 100644 --- a/bundle/permissions/workspace_root_test.go +++ b/bundle/permissions/workspace_root_test.go @@ -188,3 +188,52 @@ func TestApplyWorkspaceRootPermissionsForAllPaths(t *testing.T) { diags := bundle.Apply(t.Context(), b, ApplyWorkspaceRootPermissions()) require.NoError(t, diags.Error()) } + +func TestUndeclaredWriterTypes(t *testing.T) { + const deployer = "me@example.com" + self := resources.Permission{Level: CAN_MANAGE, UserName: deployer} + other := resources.Permission{Level: CAN_MANAGE, UserName: "other@example.com"} + sp := resources.Permission{Level: CAN_MANAGE, ServicePrincipalName: "sp-1"} + group := resources.Permission{Level: CAN_MANAGE, GroupName: "team"} + + cases := []struct { + name string + undeclared []resources.Permission + wantSelf, wantOther, wantSP, wantGroup bool + }{ + {"empty", nil, false, false, false, false}, + {"deploying user", []resources.Permission{self}, true, false, false, false}, + {"other user", []resources.Permission{other}, false, true, false, false}, + {"service principal", []resources.Permission{sp}, false, false, true, false}, + {"group", []resources.Permission{group}, false, false, false, true}, + {"all types", []resources.Permission{self, other, sp, group}, true, true, true, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotSelf, gotOther, gotSP, gotGroup := undeclaredWriterTypes(tc.undeclared, deployer) + require.Equal(t, tc.wantSelf, gotSelf) + require.Equal(t, tc.wantOther, gotOther) + require.Equal(t, tc.wantSP, gotSP) + require.Equal(t, tc.wantGroup, gotGroup) + }) + } +} + +func TestUserHomeOwner(t *testing.T) { + cases := []struct { + path string + owner string + ok bool + }{ + {"/Workspace/Users/alice@example.com/.bundle/x/state", "alice@example.com", true}, + {"/Workspace/Users/alice@example.com", "alice@example.com", true}, + {"/Workspace/Shared/state", "", false}, + {"/Workspace/team/state", "", false}, + {"/Workspace/Users/", "", false}, + } + for _, tc := range cases { + owner, ok := userHomeOwner(tc.path) + require.Equal(t, tc.ok, ok, tc.path) + require.Equal(t, tc.owner, owner, tc.path) + } +}