Skip to content

Commit fe7b889

Browse files
authored
Added WorkspacePathOwnership to determine transitive owners for files and notebooks (#3047)
1 parent 1054e35 commit fe7b889

File tree

3 files changed

+86
-3
lines changed

3 files changed

+86
-3
lines changed

src/databricks/labs/ucx/contexts/application.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from databricks.labs.ucx.assessment.export import AssessmentExporter
2929
from databricks.labs.ucx.aws.credentials import CredentialManager
3030
from databricks.labs.ucx.config import WorkspaceConfig
31-
from databricks.labs.ucx.framework.owners import AdministratorLocator
31+
from databricks.labs.ucx.framework.owners import AdministratorLocator, WorkspacePathOwnership
3232
from databricks.labs.ucx.hive_metastore import ExternalLocations, MountsCrawler, TablesCrawler
3333
from databricks.labs.ucx.hive_metastore.catalog_schema import CatalogSchema
3434
from databricks.labs.ucx.hive_metastore.grants import (
@@ -265,6 +265,10 @@ def tables_crawler(self) -> TablesCrawler:
265265
def table_ownership(self) -> TableOwnership:
266266
return TableOwnership(self.administrator_locator)
267267

268+
@cached_property
269+
def workspace_path_ownership(self) -> WorkspacePathOwnership:
270+
return WorkspacePathOwnership(self.administrator_locator, self.workspace_client)
271+
268272
@cached_property
269273
def tables_migrator(self) -> TablesMigrator:
270274
return TablesMigrator(

src/databricks/labs/ucx/framework/owners.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import logging
22
from abc import ABC, abstractmethod
33
from collections.abc import Callable, Iterable, Sequence
4+
from datetime import timedelta
45
from functools import cached_property
56
from typing import Generic, TypeVar, final
67

8+
from databricks.labs.blueprint.paths import WorkspacePath
79
from databricks.sdk import WorkspaceClient
8-
from databricks.sdk.errors import NotFound
9-
from databricks.sdk.service.iam import User
10+
from databricks.sdk.errors import NotFound, InternalError
11+
from databricks.sdk.retries import retried
12+
from databricks.sdk.service.iam import User, PermissionLevel
13+
from databricks.sdk.service.workspace import ObjectType
1014

1115
logger = logging.getLogger(__name__)
1216

@@ -190,3 +194,46 @@ def owner_of(self, record: Record) -> str:
190194
def _maybe_direct_owner(self, record: Record) -> str | None:
191195
"""Obtain the record-specific user-name associated with the given record, if any."""
192196
return None
197+
198+
199+
class WorkspacePathOwnership(Ownership[WorkspacePath]):
200+
def __init__(self, administrator_locator: AdministratorLocator, ws: WorkspaceClient) -> None:
201+
super().__init__(administrator_locator)
202+
self._ws = ws
203+
204+
@retried(on=[InternalError], timeout=timedelta(minutes=1))
205+
def _maybe_direct_owner(self, record: WorkspacePath) -> str | None:
206+
maybe_type_and_id = self._maybe_type_and_id(record)
207+
if not maybe_type_and_id:
208+
return None
209+
object_type, object_id = maybe_type_and_id
210+
try:
211+
object_permissions = self._ws.permissions.get(object_type, object_id)
212+
return self._infer_from_first_can_manage(object_permissions)
213+
except NotFound:
214+
logger.warning(f"removed on backend: {object_type} {object_id}")
215+
return None
216+
217+
@staticmethod
218+
def _maybe_type_and_id(path: WorkspacePath) -> tuple[str, str] | None:
219+
object_info = path._object_info # pylint: disable=protected-access
220+
object_id = str(object_info.object_id)
221+
match object_info.object_type:
222+
case ObjectType.NOTEBOOK:
223+
return 'notebooks', object_id
224+
case ObjectType.FILE:
225+
return 'files', object_id
226+
return None
227+
228+
@staticmethod
229+
def _infer_from_first_can_manage(object_permissions):
230+
for acl in object_permissions.access_control_list:
231+
for permission in acl.all_permissions:
232+
if permission.permission_level != PermissionLevel.CAN_MANAGE:
233+
continue
234+
if acl.user_name:
235+
return acl.user_name
236+
if acl.group_name:
237+
return acl.group_name
238+
return acl.service_principal_name
239+
return None

tests/integration/framework/test_owners.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
from databricks.sdk import WorkspaceClient
44
from databricks.sdk.service import iam
5+
from databricks.sdk.service.iam import PermissionLevel
56

67
from databricks.labs.ucx.contexts.workflow_task import RuntimeContext
8+
from databricks.labs.ucx.framework.owners import AdministratorLocator, WorkspacePathOwnership
79

810

911
def _find_admins_group_id(ws: WorkspaceClient) -> str:
@@ -51,3 +53,33 @@ def test_fallback_admin_user(ws, installation_ctx: RuntimeContext) -> None:
5153

5254
assert an_admin == the_user.user_name and the_user.active
5355
assert _user_is_member_of_group(the_user, admins_group_id) or _user_has_role(the_user, "account_admin")
56+
57+
58+
def test_notebook_owner(make_notebook, make_notebook_permissions, make_group, ws):
59+
notebook = make_notebook()
60+
new_group = make_group()
61+
make_notebook_permissions(
62+
object_id=notebook,
63+
permission_level=PermissionLevel.CAN_MANAGE,
64+
group_name=new_group.display_name,
65+
)
66+
67+
admin_locator = AdministratorLocator(ws)
68+
notebook_ownership = WorkspacePathOwnership(admin_locator, ws)
69+
70+
name = notebook_ownership.owner_of(notebook)
71+
72+
my_user = ws.current_user.me()
73+
assert name == my_user.user_name
74+
75+
76+
def test_file_owner(make_workspace_file, ws):
77+
ws_file = make_workspace_file()
78+
79+
admin_locator = AdministratorLocator(ws)
80+
notebook_ownership = WorkspacePathOwnership(admin_locator, ws)
81+
82+
name = notebook_ownership.owner_of(ws_file)
83+
84+
my_user = ws.current_user.me()
85+
assert name == my_user.user_name

0 commit comments

Comments
 (0)