Skip to content

Commit 4e51074

Browse files
yolanferybramj
andauthored
refactor: filter_for_user pattern to support ServicePrincipal (HEXA-1619) (#1835)
* fix: add is_service_principal attribute to authentication * fix: inherit from ServicePrincipal in PipelineRunUser class * fix: update filter_for_user methods to use UserInterface and include archived workspaces * test: add unit tests for workspace and connection filtering logic * feat: add ServicePrincipal mixin and update filter_for_user methods to support UserInterface * test: add unit tests for organization filtering logic based on user roles * fix: update filter_for_user methods to use UserInterface and support ServicePrincipal * fix: update filter_for_user methods to use UserInterface for improved user handling * fix: update filter_for_user method to use UserInterface for improved user handling * fix: add workspace_id to pipeline_run for accurate user filtering * fix: refactor user filtering to utilize UserInterface and improve organization access * test: add test for pipeline user visibility of organization shared datasets * fix: add real_user property to ServicePrincipal for accurate user attribution * fix: add slug property to workspaces for improved identification * fix: test * feat: add workspace and workspace_id properties to enhance ServicePrincipal * fix: remove include_archived parameter from filter_for_user calls for workspaces * fix: simplify permission checks for ServicePrincipal by removing unnecessary workspace queries * fix: restrict organization invitations visibility for non-direct members * Update backend/hexa/user_management/models.py Co-authored-by: Bram Jans <ik@bramjans.com> * fix: restrict organization dataset visibility for external collaborators --------- Co-authored-by: Bram Jans <ik@bramjans.com>
1 parent cd41a3a commit 4e51074

11 files changed

Lines changed: 705 additions & 240 deletions

File tree

backend/hexa/core/tests/test_pipeline_user_filtering.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,17 @@ def setUp(self):
2828
self.pipeline_run = MagicMock(PipelineRun)
2929
self.pipeline_run.pipeline = MagicMock(Pipeline)
3030
self.pipeline_run.pipeline.workspace = self.WORKSPACE1
31+
self.pipeline_run.pipeline.workspace_id = self.WORKSPACE1.id
32+
self.pipeline_run.user = self.USER_ROOT
3133
self.pipeline_user = PipelineRunUser(self.pipeline_run)
3234

35+
def test_real_user_returns_triggering_user(self):
36+
self.assertEqual(self.pipeline_user.real_user, self.USER_ROOT)
37+
38+
def test_real_user_is_none_for_scheduled_run(self):
39+
self.pipeline_run.user = None
40+
self.assertIsNone(self.pipeline_user.real_user)
41+
3342
def test_pipeline_user_filtering(self):
3443
Pipeline.objects.create(
3544
name="Test Pipeline1",

backend/hexa/datasets/models.py

Lines changed: 69 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@
1818
from hexa.datasets.api import get_blob
1919
from hexa.files import storage
2020
from hexa.metadata.models import MetadataMixin
21-
from hexa.user_management.models import OrganizationMembershipRole, User
21+
from hexa.user_management.models import (
22+
Organization,
23+
ServicePrincipal,
24+
User,
25+
UserInterface,
26+
)
2227

2328
logger = logging.getLogger(__name__)
2429

@@ -37,90 +42,63 @@ def create_dataset_slug(name: str, workspace):
3742

3843

3944
class DatasetQuerySet(BaseQuerySet):
40-
@staticmethod
41-
def _workspace_query(user):
42-
return Q(links__workspace__members=user)
43-
44-
@staticmethod
45-
def _org_shared_query(user):
46-
return Q(
47-
shared_with_organization=True,
48-
workspace__organization__organizationmembership__user=user,
49-
)
50-
51-
@staticmethod
52-
def _org_admin_or_owner_query(user):
53-
return Q(
54-
workspace__organization__organizationmembership__user=user,
55-
workspace__organization__organizationmembership__role__in=[
56-
OrganizationMembershipRole.ADMIN,
57-
OrganizationMembershipRole.OWNER,
58-
],
59-
)
60-
6145
@staticmethod
6246
def optimize_query(qs: models.QuerySet) -> models.QuerySet:
6347
return qs.select_related(
6448
"workspace", "workspace__organization", "created_by"
6549
).prefetch_related("versions", "links", "links__workspace")
6650

67-
def filter_for_user(self, user: AnonymousUser | User):
68-
from hexa.pipelines.authentication import PipelineRunUser
51+
def filter_for_user(self, user: AnonymousUser | UserInterface) -> models.QuerySet:
52+
from hexa.workspaces.models import Workspace
6953

70-
if isinstance(user, PipelineRunUser):
71-
return self._filter_for_user_and_query_object(
72-
user, models.Q(links__workspace=user.pipeline_run.pipeline.workspace)
73-
)
74-
else:
75-
return self.optimize_query(
76-
self._filter_for_user_and_query_object(
77-
user,
78-
self._workspace_query(user) | self._org_shared_query(user),
79-
return_all_if_superuser=True,
80-
return_all_if_organization_admin_or_owner=True,
54+
accessible_workspaces = Workspace.objects.filter_for_user(user)
55+
accessible_organizations = Organization.objects.filter_for_user(
56+
user, direct_membership_only=True
57+
)
58+
return self.optimize_query(
59+
self.filter(
60+
Q(links__workspace__in=accessible_workspaces)
61+
| Q(
62+
shared_with_organization=True,
63+
workspace__organization__in=accessible_organizations,
8164
)
82-
)
65+
).distinct()
66+
)
8367

8468
def filter_for_workspace_slugs(
85-
self, user: AnonymousUser | User, workspace_slugs: list[str]
69+
self, user: AnonymousUser | UserInterface, workspace_slugs: list[str]
8670
):
87-
return self.optimize_query(
88-
self._filter_for_user_and_query_object(
89-
user,
90-
self._workspace_query(user) & Q(workspace__slug__in=workspace_slugs)
91-
| (
92-
self._org_shared_query(user)
93-
& Q(links__workspace__slug__in=workspace_slugs)
94-
)
95-
| (
96-
self._org_admin_or_owner_query(user)
97-
& Q(workspace__slug__in=workspace_slugs)
98-
),
99-
return_all_if_superuser=False,
100-
)
71+
# Datasets auto-link to their primary workspace, so `links__workspace`
72+
# uniformly covers both primary and shared scopes — no separate
73+
# primary-workspace clause needed.
74+
return (
75+
self.filter_for_user(user)
76+
.filter(links__workspace__slug__in=workspace_slugs)
77+
.distinct()
10178
)
10279

10380

10481
class DatasetManager(models.Manager):
10582
def create_if_has_perm(
10683
self,
107-
principal: User,
84+
principal: UserInterface,
10885
workspace: any,
10986
*,
11087
name: str,
11188
description: str,
11289
files: list[dict] | None = None,
11390
):
114-
from hexa.pipelines.authentication import PipelineRunUser
115-
116-
# FIXME: Use a generic permission system instead of differencing between User and PipelineRunUser
117-
if isinstance(principal, PipelineRunUser):
118-
if principal.pipeline_run.pipeline.workspace != workspace:
91+
if isinstance(principal, ServicePrincipal):
92+
if principal.workspace_id != workspace.pk:
11993
raise PermissionDenied
12094
elif not principal.has_perm("datasets.create_dataset", workspace):
12195
raise PermissionDenied
12296

123-
created_by = principal if not isinstance(principal, PipelineRunUser) else None
97+
created_by = (
98+
principal.real_user
99+
if isinstance(principal, ServicePrincipal)
100+
else principal
101+
)
124102

125103
with transaction.atomic():
126104
dataset = self.create(
@@ -224,7 +202,7 @@ def link(self, principal: User, workspace: any):
224202

225203

226204
class DatasetVersionQuerySet(BaseQuerySet):
227-
def filter_for_user(self, user: AnonymousUser | User):
205+
def filter_for_user(self, user: AnonymousUser | UserInterface):
228206
# TODO: It should also check workspace where it's added
229207
return self._filter_for_user_and_query_object(
230208
user,
@@ -236,25 +214,24 @@ def filter_for_user(self, user: AnonymousUser | User):
236214
class DatasetVersionManager(models.Manager):
237215
def create_if_has_perm(
238216
self,
239-
principal: User,
217+
principal: UserInterface,
240218
dataset: Dataset,
241219
*,
242220
name: str,
243221
changelog: str,
244222
files: list[dict] | None = None,
245223
):
246-
# FIXME: Use a generic permission system instead of differencing between User and PipelineRunUser
247-
from hexa.pipelines.authentication import PipelineRunUser
248-
249-
if isinstance(principal, PipelineRunUser):
250-
if principal.pipeline_run.pipeline.workspace != dataset.workspace:
224+
if isinstance(principal, ServicePrincipal):
225+
if principal.workspace_id != dataset.workspace_id:
251226
raise PermissionDenied
252227
elif not principal.has_perm("datasets.create_dataset_version", dataset):
253228
raise PermissionDenied
254-
created_by = principal if not isinstance(principal, PipelineRunUser) else None
255-
pipeline_run = (
256-
principal.pipeline_run if isinstance(principal, PipelineRunUser) else None
229+
created_by = (
230+
principal.real_user
231+
if isinstance(principal, ServicePrincipal)
232+
else principal
257233
)
234+
pipeline_run = getattr(principal, "pipeline_run", None)
258235

259236
uploaded_uris = []
260237
with transaction.atomic():
@@ -357,7 +334,7 @@ def get_file_by_name(self, name: str):
357334

358335

359336
class DatasetVersionFileQuerySet(BaseQuerySet):
360-
def filter_for_user(self, user: AnonymousUser | User):
337+
def filter_for_user(self, user: AnonymousUser | UserInterface):
361338
return self._filter_for_user_and_query_object(
362339
user,
363340
models.Q(
@@ -373,27 +350,25 @@ def filter_by_filename(self, filename: str):
373350
class DatasetVersionFileManager(models.Manager):
374351
def create_if_has_perm(
375352
self,
376-
principal: User,
353+
principal: UserInterface,
377354
dataset_version: DatasetVersion,
378355
*,
379356
uri: str,
380357
content_type: str,
381358
):
382-
# FIXME: Use a generic permission system instead of differencing between User and PipelineRunUser
383-
from hexa.pipelines.authentication import PipelineRunUser
384-
385-
if isinstance(principal, PipelineRunUser):
386-
if (
387-
principal.pipeline_run.pipeline.workspace
388-
!= dataset_version.dataset.workspace
389-
):
359+
if isinstance(principal, ServicePrincipal):
360+
if principal.workspace_id != dataset_version.dataset.workspace_id:
390361
raise PermissionDenied
391362
elif not principal.has_perm(
392363
"datasets.create_dataset_version_file", dataset_version
393364
):
394365
raise PermissionDenied
395366

396-
created_by = principal if not isinstance(principal, PipelineRunUser) else None
367+
created_by = (
368+
principal.real_user
369+
if isinstance(principal, ServicePrincipal)
370+
else principal
371+
)
397372

398373
return self.create(
399374
dataset_version=dataset_version,
@@ -591,35 +566,22 @@ def filter_for_workspaces(self, workspaces, pinned=None, query=None):
591566
)
592567
return qs
593568

594-
def filter_for_user(self, user: AnonymousUser | User):
595-
# FIXME: Use a generic permission system instead of differencing between User and PipelineRunUser
596-
from hexa.pipelines.authentication import PipelineRunUser
597-
598-
if isinstance(user, PipelineRunUser):
599-
workspace = user.pipeline_run.pipeline.workspace
600-
return self.optimize_query(
601-
self._filter_for_user_and_query_object(
602-
user,
603-
models.Q(workspace=workspace)
604-
| models.Q(
605-
dataset__shared_with_organization=True,
606-
workspace__organization=workspace.organization,
607-
),
608-
)
609-
)
610-
else:
611-
return self.optimize_query(
612-
self._filter_for_user_and_query_object(
613-
user,
614-
models.Q(workspace__members=user)
615-
| models.Q(
616-
dataset__shared_with_organization=True,
617-
workspace__organization__organizationmembership__user=user,
618-
),
619-
return_all_if_superuser=True,
620-
return_all_if_organization_admin_or_owner=True,
569+
def filter_for_user(self, user: AnonymousUser | UserInterface) -> models.QuerySet:
570+
from hexa.workspaces.models import Workspace
571+
572+
accessible_workspaces = Workspace.objects.filter_for_user(user)
573+
accessible_organizations = Organization.objects.filter_for_user(
574+
user, direct_membership_only=True
575+
)
576+
return self.optimize_query(
577+
self.filter(
578+
Q(workspace__in=accessible_workspaces)
579+
| Q(
580+
dataset__shared_with_organization=True,
581+
workspace__organization__in=accessible_organizations,
621582
)
622-
)
583+
).distinct()
584+
)
623585

624586

625587
class DatasetLink(Base):

backend/hexa/datasets/tests/test_models.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,45 @@ def test_dataset_filter_for_user_excludes_non_organization_members(self):
449449
datasets = Dataset.objects.filter_for_user(self.USER_SERENA)
450450
self.assertNotIn(org_dataset, datasets)
451451

452+
def test_dataset_filter_for_user_excludes_external_collaborator_in_same_org(self):
453+
"""An external collaborator — a member of one workspace in the
454+
organization but not a member of the organization itself — must not see
455+
organization-shared datasets belonging to other workspaces in that org.
456+
"""
457+
shared_workspace = Workspace.objects.create_if_has_perm(
458+
self.USER_ADMIN,
459+
name="Shared Dataset Workspace",
460+
description="Holds the organization-shared dataset",
461+
organization=self.ORGANIZATION,
462+
)
463+
org_dataset = Dataset.objects.create_if_has_perm(
464+
self.USER_ADMIN,
465+
shared_workspace,
466+
name="Org Dataset",
467+
description="Organization shared dataset",
468+
)
469+
org_dataset.shared_with_organization = True
470+
org_dataset.save()
471+
472+
collaborator = User.objects.create_user("collaborator@example.com", "password")
473+
collaborator_workspace = Workspace.objects.create_if_has_perm(
474+
self.USER_ADMIN,
475+
name="Collaborator Workspace",
476+
description="The only workspace the collaborator belongs to",
477+
organization=self.ORGANIZATION,
478+
)
479+
WorkspaceMembership.objects.create(
480+
workspace=collaborator_workspace,
481+
user=collaborator,
482+
role=WorkspaceMembershipRole.EDITOR,
483+
)
484+
485+
self.assertNotIn(org_dataset, Dataset.objects.filter_for_user(collaborator))
486+
self.assertNotIn(
487+
org_dataset.links.first(),
488+
DatasetLink.objects.filter_for_user(collaborator),
489+
)
490+
452491
def test_dataset_filter_for_user_excludes_non_shared_datasets(self):
453492
other_workspace = Workspace.objects.create_if_has_perm(
454493
self.USER_ADMIN,
@@ -898,3 +937,41 @@ def test_pipeline_user_cannot_access_non_shared_dataset_links_from_same_organiza
898937
links = DatasetLink.objects.filter_for_user(self.pipeline_user)
899938
link_datasets = [link.dataset for link in links]
900939
self.assertNotIn(self.DATASET_IN_WORKSPACE_3, link_datasets)
940+
941+
def test_pipeline_user_sees_org_shared_datasets_via_dataset_queryset(self):
942+
"""A pipeline run sees datasets shared with its workspace's organization."""
943+
self.DATASET_IN_OTHER_WORKSPACE_SAME_ORG.shared_with_organization = True
944+
self.DATASET_IN_OTHER_WORKSPACE_SAME_ORG.save()
945+
self.DATASET_IN_DIFFERENT_ORG.shared_with_organization = True
946+
self.DATASET_IN_DIFFERENT_ORG.save()
947+
948+
datasets = Dataset.objects.filter_for_user(self.pipeline_user)
949+
950+
self.assertIn(self.DATASET_IN_PIPELINE_WORKSPACE, datasets)
951+
self.assertIn(self.DATASET_IN_OTHER_WORKSPACE_SAME_ORG, datasets)
952+
# Same-org but not shared → still hidden.
953+
self.assertNotIn(self.DATASET_IN_WORKSPACE_3, datasets)
954+
# Different-org dataset → hidden even when shared.
955+
self.assertNotIn(self.DATASET_IN_DIFFERENT_ORG, datasets)
956+
957+
def test_pipeline_user_attributes_dataset_to_triggering_user(self):
958+
"""`created_by` is set to the user who triggered the run."""
959+
self.pipeline_run.user = self.USER_ADMIN
960+
dataset = Dataset.objects.create_if_has_perm(
961+
self.pipeline_user,
962+
self.WORKSPACE,
963+
name="Pipeline-created Dataset",
964+
description="",
965+
)
966+
self.assertEqual(dataset.created_by, self.USER_ADMIN)
967+
968+
def test_pipeline_user_leaves_created_by_null_for_scheduled_run(self):
969+
"""A scheduled run (no triggering user) leaves created_by null."""
970+
self.pipeline_run.user = None
971+
dataset = Dataset.objects.create_if_has_perm(
972+
self.pipeline_user,
973+
self.WORKSPACE,
974+
name="Scheduled-created Dataset",
975+
description="",
976+
)
977+
self.assertIsNone(dataset.created_by)

0 commit comments

Comments
 (0)