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) }