Skip to content

Commit 4c56f39

Browse files
yazzylazyclaude
andcommitted
Fix organization channel edit/view permissions not reaching content and the channel API
PermissionCTE only read the personal editor/viewer m2m tables, so organization editors/viewers could not edit or view content nodes, assessment items, or files inside org-owned channels. Channel.filter_view_queryset also only exposed the personal-only edit/view checks, so the channel API reported edit: false for organization editors even though the channel itself was already correctly org-aware. PermissionCTE.editable_channels/view_only_channels now union the personal m2m grants with channels reachable via an active OrganizationRole. Channel.filter_view_queryset now folds organization grants directly into edit/view, so every consumer of that queryset sees combined permissions. Both layers treat organization viewing as viewer-only: an org editor/admin is already admitted through the edit check wherever permissions are checked, so including them in the view role set too would be redundant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent b2379a2 commit 4c56f39

4 files changed

Lines changed: 155 additions & 69 deletions

File tree

‎contentcuration/contentcuration/constants/organization_roles.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
(ORGANIZATION_VIEWER, "Viewer"),
99
)
1010

11+
# Viewing is ORGANIZATION_VIEWER alone; admins/editors are already admitted via the edit check.
12+
ORGANIZATION_CHANNEL_EDIT_ROLES = (ORGANIZATION_ADMIN, ORGANIZATION_EDITOR)
13+
1114
ORGANIZATION_ROLE_STATUS_ACTIVE = "active"
1215
ORGANIZATION_ROLE_STATUS_INACTIVE = "inactive"
1316
ORGANIZATION_ROLE_STATUS_PENDING = "pending"

‎contentcuration/contentcuration/models.py‎

Lines changed: 79 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from django.db import models
3030
from django.db.models import Count
3131
from django.db.models import Exists
32+
from django.db.models import ExpressionWrapper
3233
from django.db.models import F
3334
from django.db.models import Index
3435
from django.db.models import JSONField
@@ -80,6 +81,7 @@
8081
from contentcuration.constants import user_history
8182
from contentcuration.constants.contentnode import kind_activity_map
8283
from contentcuration.constants.organization_roles import ORGANIZATION_ADMIN
84+
from contentcuration.constants.organization_roles import ORGANIZATION_CHANNEL_EDIT_ROLES
8385
from contentcuration.constants.organization_roles import ORGANIZATION_EDITOR
8486
from contentcuration.constants.organization_roles import organization_role_choices
8587
from contentcuration.constants.organization_roles import ORGANIZATION_ROLE_STATUS_ACTIVE
@@ -1006,29 +1008,63 @@ class PermissionCTE(With):
10061008
tree_id_fields = [
10071009
"channel__{}__tree_id".format(tree_name) for tree_name in CHANNEL_TREES
10081010
]
1011+
channel_tree_id_fields = [
1012+
"{}__tree_id".format(tree_name) for tree_name in CHANNEL_TREES
1013+
]
10091014

1010-
def __init__(self, model, user_id, **kwargs):
1011-
queryset = model.objects.filter(user_id=user_id).annotate(
1012-
tree_id=Unnest(
1013-
ArrayRemove(Array(*self.tree_id_fields), None),
1014-
output_field=models.IntegerField(),
1015+
@classmethod
1016+
def _personal_channels(cls, model, user_id):
1017+
return (
1018+
model.objects.filter(user_id=user_id)
1019+
.annotate(
1020+
tree_id=Unnest(
1021+
ArrayRemove(Array(*cls.tree_id_fields), None),
1022+
output_field=models.IntegerField(),
1023+
)
10151024
)
1025+
.values("user_id", "channel_id", "tree_id")
10161026
)
1017-
super(PermissionCTE, self).__init__(
1018-
queryset=queryset.values("user_id", "channel_id", "tree_id"), **kwargs
1027+
1028+
@classmethod
1029+
def _organization_channels(cls, user_id, roles):
1030+
# Organizations grant channel permissions to their members, on top of
1031+
# whatever personal editor/viewer access a user already has.
1032+
return (
1033+
Channel.objects.filter(
1034+
organization__user_roles__user_id=user_id,
1035+
organization__user_roles__role__in=roles,
1036+
organization__user_roles__status=ORGANIZATION_ROLE_STATUS_ACTIVE,
1037+
)
1038+
.annotate(
1039+
user_id=Value(user_id, output_field=models.IntegerField()),
1040+
channel_id=F("id"),
1041+
tree_id=Unnest(
1042+
ArrayRemove(Array(*cls.channel_tree_id_fields), None),
1043+
output_field=models.IntegerField(),
1044+
),
1045+
)
1046+
.values("user_id", "channel_id", "tree_id")
10191047
)
10201048

10211049
@classmethod
10221050
def editable_channels(cls, user_id):
1023-
return PermissionCTE(
1024-
User.editable_channels.through, user_id, name="editable_channels_cte"
1051+
queryset = cls._personal_channels(
1052+
User.editable_channels.through, user_id
1053+
).union(
1054+
cls._organization_channels(user_id, ORGANIZATION_CHANNEL_EDIT_ROLES),
1055+
all=True,
10251056
)
1057+
return PermissionCTE(queryset, name="editable_channels_cte")
10261058

10271059
@classmethod
10281060
def view_only_channels(cls, user_id):
1029-
return PermissionCTE(
1030-
User.view_only_channels.through, user_id, name="view_only_channels_cte"
1061+
queryset = cls._personal_channels(
1062+
User.view_only_channels.through, user_id
1063+
).union(
1064+
cls._organization_channels(user_id, (ORGANIZATION_VIEWER,)),
1065+
all=True,
10311066
)
1067+
return PermissionCTE(queryset, name="view_only_channels_cte")
10321068

10331069
def exists(self, *filters):
10341070
return Exists(self.queryset().filter(*filters).values("user_id"))
@@ -1218,6 +1254,17 @@ class Channel(models.Model):
12181254
def get_editable(cls, user, channel_id):
12191255
return cls.filter_edit_queryset(cls.objects.all(), user).get(id=channel_id)
12201256

1257+
@classmethod
1258+
def _organization_role_exists(cls, user_id, roles):
1259+
return Exists(
1260+
OrganizationRole.objects.filter(
1261+
user_id=user_id,
1262+
organization_id=OuterRef("organization_id"),
1263+
status=ORGANIZATION_ROLE_STATUS_ACTIVE,
1264+
role__in=roles,
1265+
)
1266+
)
1267+
12211268
@classmethod
12221269
def filter_edit_queryset(cls, queryset, user):
12231270
user_id = not user.is_anonymous and user.id
@@ -1231,16 +1278,8 @@ def filter_edit_queryset(cls, queryset, user):
12311278
user_id=user_id, channel_id=OuterRef("id")
12321279
)
12331280
)
1234-
organization_edit = Exists(
1235-
OrganizationRole.objects.filter(
1236-
user_id=user_id,
1237-
organization_id=OuterRef("organization_id"),
1238-
status=ORGANIZATION_ROLE_STATUS_ACTIVE,
1239-
role__in=(
1240-
ORGANIZATION_ADMIN,
1241-
ORGANIZATION_EDITOR,
1242-
),
1243-
)
1281+
organization_edit = cls._organization_role_exists(
1282+
user_id, ORGANIZATION_CHANNEL_EDIT_ROLES
12441283
)
12451284
queryset = queryset.annotate(
12461285
edit=edit,
@@ -1263,13 +1302,10 @@ def filter_delete_queryset(cls, queryset, user):
12631302
user_id=user_id, channel_id=OuterRef("id")
12641303
)
12651304
)
1266-
organization_delete = Exists(
1267-
OrganizationRole.objects.filter(
1268-
user_id=user_id,
1269-
organization_id=OuterRef("organization_id"),
1270-
status=ORGANIZATION_ROLE_STATUS_ACTIVE,
1271-
role=ORGANIZATION_ADMIN,
1272-
)
1305+
# Deleting a channel is restricted to organization admins, unlike
1306+
# editing it, which organization editors may also do.
1307+
organization_delete = cls._organization_role_exists(
1308+
user_id, (ORGANIZATION_ADMIN,)
12731309
)
12741310
queryset = queryset.annotate(
12751311
edit=edit,
@@ -1289,40 +1325,26 @@ def filter_view_queryset(cls, queryset, user):
12891325
if user_id:
12901326
filters = dict(user_id=user_id, channel_id=OuterRef("id"))
12911327

1292-
edit = Exists(
1293-
User.editable_channels.through.objects.filter(**filters).values(
1294-
"user_id"
1295-
)
1328+
# Fold organization grants into edit/view (viewer-only for view; an org editor/admin is admitted via the edit check instead).
1329+
edit = ExpressionWrapper(
1330+
Q(Exists(User.editable_channels.through.objects.filter(**filters)))
1331+
| Q(
1332+
cls._organization_role_exists(
1333+
user_id, ORGANIZATION_CHANNEL_EDIT_ROLES
1334+
)
1335+
),
1336+
output_field=models.BooleanField(),
12961337
)
1297-
1298-
view = Exists(
1299-
User.view_only_channels.through.objects.filter(**filters).values(
1300-
"user_id"
1301-
)
1302-
)
1303-
1304-
organization_view = Exists(
1305-
OrganizationRole.objects.filter(
1306-
user_id=user_id,
1307-
organization_id=OuterRef("organization_id"),
1308-
status=ORGANIZATION_ROLE_STATUS_ACTIVE,
1309-
role__in=(
1310-
ORGANIZATION_ADMIN,
1311-
ORGANIZATION_EDITOR,
1312-
ORGANIZATION_VIEWER,
1313-
),
1314-
)
1338+
view = ExpressionWrapper(
1339+
Q(Exists(User.view_only_channels.through.objects.filter(**filters)))
1340+
| Q(cls._organization_role_exists(user_id, (ORGANIZATION_VIEWER,))),
1341+
output_field=models.BooleanField(),
13151342
)
13161343
else:
13171344
edit = boolean_val(False)
13181345
view = boolean_val(False)
1319-
organization_view = boolean_val(False)
13201346

1321-
queryset = queryset.annotate(
1322-
edit=edit,
1323-
view=view,
1324-
organization_view=organization_view,
1325-
)
1347+
queryset = queryset.annotate(edit=edit, view=view)
13261348

13271349
if user_id and user.is_admin:
13281350
return queryset
@@ -1338,10 +1360,7 @@ def filter_view_queryset(cls, queryset, user):
13381360
).values_list("channel_id", flat=True)
13391361

13401362
permission_filter = (
1341-
Q(view=True)
1342-
| Q(edit=True)
1343-
| Q(organization_view=True)
1344-
| Q(deleted=False, id__in=pending_channels)
1363+
Q(view=True) | Q(edit=True) | Q(deleted=False, id__in=pending_channels)
13451364
)
13461365

13471366
return queryset.filter(permission_filter | Q(deleted=False, public=True))

‎contentcuration/contentcuration/tests/viewsets/test_organization.py‎

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,14 @@
1616
)
1717
from contentcuration.constants.organization_roles import ORGANIZATION_VIEWER
1818
from contentcuration.models import Channel
19+
from contentcuration.models import ContentNode
1920
from contentcuration.models import Organization
2021
from contentcuration.models import OrganizationRole
2122
from contentcuration.tests import testdata
2223
from contentcuration.tests.base import StudioAPITestCase
24+
from contentcuration.tests.viewsets.base import generate_update_event
25+
from contentcuration.tests.viewsets.base import SyncTestMixin
26+
from contentcuration.viewsets.sync.constants import CONTENTNODE
2327

2428

2529
class OrganizationAPITestCase(StudioAPITestCase):
@@ -602,7 +606,7 @@ def test_last_active_admin_cannot_be_removed(self):
602606
)
603607

604608

605-
class OrganizationChannelPermissionTestCase(OrganizationAPITestCase):
609+
class OrganizationChannelPermissionTestCase(SyncTestMixin, OrganizationAPITestCase):
606610
def setUp(self):
607611
super().setUp()
608612
self.channel = Channel.objects.create(
@@ -702,6 +706,71 @@ def test_pending_role_grants_no_channel_access(self):
702706
self.assertFalse(self._viewable_channel(self.pending_user).exists())
703707
self.assertFalse(self._editable_channel(self.pending_user).exists())
704708

709+
def test_org_role_channel_content_permissions(self):
710+
node = self.channel.main_tree
711+
self.assertTrue(
712+
ContentNode.filter_edit_queryset(
713+
ContentNode.objects.filter(pk=node.pk), self.editor_user
714+
).exists()
715+
)
716+
self.assertFalse(
717+
ContentNode.filter_edit_queryset(
718+
ContentNode.objects.filter(pk=node.pk), self.viewer_user
719+
).exists()
720+
)
721+
self.assertTrue(
722+
ContentNode.filter_view_queryset(
723+
ContentNode.objects.filter(pk=node.pk), self.viewer_user
724+
).exists()
725+
)
726+
727+
def test_channel_detail_reports_edit_view_by_org_role(self):
728+
detail_url = reverse("channel-detail", kwargs={"pk": self.channel.id})
729+
730+
self.authenticate_as(self.editor_user)
731+
response = self.client.get(detail_url)
732+
self.assertEqual(response.status_code, status.HTTP_200_OK)
733+
self.assertTrue(response.data["edit"])
734+
735+
self.authenticate_as(self.viewer_user)
736+
response = self.client.get(detail_url)
737+
self.assertEqual(response.status_code, status.HTTP_200_OK)
738+
self.assertFalse(response.data["edit"])
739+
self.assertTrue(response.data["view"])
740+
741+
def test_org_editor_channel_list_filters(self):
742+
self.authenticate_as(self.editor_user)
743+
744+
response = self.client.get(reverse("channel-list"), {"view": True})
745+
self.assertEqual(response.status_code, status.HTTP_200_OK)
746+
self.assertNotIn(
747+
str(self.channel.id), [result["id"] for result in response.data]
748+
)
749+
750+
response = self.client.get(reverse("channel-list"), {"edit": True})
751+
self.assertEqual(response.status_code, status.HTTP_200_OK)
752+
self.assertIn(str(self.channel.id), [result["id"] for result in response.data])
753+
754+
def test_org_editor_can_edit_channel_content_via_sync(self):
755+
self.authenticate_as(self.editor_user)
756+
node = self.channel.main_tree
757+
new_title = "Updated by org editor"
758+
759+
response = self.sync_changes(
760+
[
761+
generate_update_event(
762+
node.id,
763+
CONTENTNODE,
764+
{"title": new_title},
765+
channel_id=self.channel.id,
766+
)
767+
]
768+
)
769+
770+
self.assertEqual(response.status_code, 200, response.content)
771+
node.refresh_from_db()
772+
self.assertEqual(node.title, new_title)
773+
705774

706775
class OrganizationPaginationTestCase(OrganizationAPITestCase):
707776
def test_organization_list_is_paginated(self):

‎contentcuration/contentcuration/viewsets/channel.py‎

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -502,24 +502,19 @@ def perform_destroy(self, instance):
502502
instance.save(update_fields=["deleted"], actor_id=self.request.user.id)
503503

504504
def get_queryset(self):
505+
# edit/view are already annotated here with organization grants folded
506+
# in by Channel.filter_view_queryset.
505507
queryset = super(ChannelViewSet, self).get_queryset()
506-
user_id = not self.request.user.is_anonymous and self.request.user.id
507-
user_queryset = User.objects.filter(id=user_id)
508508
# Add the last modified node modified value as the channel last modified
509509
channel_main_tree_nodes = ContentNode.objects.filter(
510510
tree_id=OuterRef("main_tree__tree_id")
511511
)
512-
queryset = queryset.annotate(
512+
return queryset.annotate(
513513
modified=Subquery(
514514
channel_main_tree_nodes.values("modified").order_by("-modified")[:1]
515515
)
516516
)
517517

518-
return queryset.annotate(
519-
edit=Exists(user_queryset.filter(editable_channels=OuterRef("id"))),
520-
view=Exists(user_queryset.filter(view_only_channels=OuterRef("id"))),
521-
)
522-
523518
def _annotate_draft_token(self, queryset):
524519
draft_token_subquery = Subquery(
525520
ChannelVersion.objects.filter(channel=OuterRef("id"), version=None).values(

0 commit comments

Comments
 (0)