Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 26 additions & 7 deletions cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey, UsageKey
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2
from openedx_authz.constants.permissions import COURSES_VIEW_COURSE
from openedx_authz.constants.permissions import (
COURSES_MANAGE_LIBRARY_UPDATES,
COURSES_VIEW_LIBRARY_UPDATES,
)
from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError
from rest_framework.fields import BooleanField
from rest_framework.request import Request
Expand All @@ -115,7 +118,6 @@
)
from cms.lib.xblock.upstream_sync_block import fetch_customizable_fields_from_block
from cms.lib.xblock.upstream_sync_container import fetch_customizable_fields_from_container
from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access
from openedx.core.djangoapps.authz.decorators import LegacyAuthoringPermission, user_has_course_permission
from openedx.core.djangoapps.content_libraries import api as lib_api
from openedx.core.djangoapps.video_config.transcripts_utils import clear_transcripts
Expand Down Expand Up @@ -198,7 +200,12 @@ def get(self, request: _AuthenticatedRequest):
except InvalidKeyError as exc:
raise ValidationError(detail=f"Malformed course key: {course_key_string}") from exc

if not has_studio_read_access(request.user, course_key):
if not user_has_course_permission(
request.user,
COURSES_VIEW_LIBRARY_UPDATES.identifier,
course_key,
LegacyAuthoringPermission.READ
):
raise PermissionDenied
if ready_to_sync is not None:
link_filter["ready_to_sync"] = BooleanField().to_internal_value(ready_to_sync)
Expand Down Expand Up @@ -306,7 +313,7 @@ def get(self, request: _AuthenticatedRequest, course_key_string: str):

if not user_has_course_permission(
request.user,
COURSES_VIEW_COURSE.identifier,
COURSES_VIEW_LIBRARY_UPDATES.identifier,
Comment thread
mariajgrimaldi marked this conversation as resolved.
course_key,
LegacyAuthoringPermission.READ
):
Expand Down Expand Up @@ -568,10 +575,22 @@ def _load_accessible_block(user: User, usage_key_string: str, *, require_write_a
usage_key = UsageKey.from_string(usage_key_string)
except InvalidKeyError as exc:
raise ValidationError(detail=f"Malformed block usage key: {usage_key_string}") from exc
if require_write_access and not has_studio_write_access(user, usage_key.context_key):
raise not_found
if not has_studio_read_access(user, usage_key.context_key):

context_key = usage_key.context_key
if not isinstance(context_key, CourseKey):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure this function will always be used for course keys? Given the description, it seems this is used for loading XBlocks, so this would break that functionality.

Perhaps we should conditionally test for different permissions depending if it's a course or something else, or understand if this will ever used to check for courses or not.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question — I looked into this specifically. Today the downstream must be a course, and this is enforced structurally, not just assumed: EntityLinkBase.downstream_context_key is a CourseKeyField, so a link whose downstream isn't a course can't even be persisted.

The abstraction is named and documented generically on purpose — EntityLinkBase is described as a link "between two publishable entities," and the module docstring in upstream_sync.py calls the "downstream is a course" rule an internal assumption that may loosen in the future (e.g. library-to-library links). So the model is built to eventually generalize.

But for now, a non-course downstream doesn't just fail the permission check — fetching the upstream breaks outright. UpstreamLink.get_for_block, which every one of these endpoints calls to load/serialize the link, raises BadDownstream("Cannot update content because it does not belong to a course.") when the context isn't a CourseKey. So the isinstance(context_key, CourseKey) guard in _load_accessible_block isn't inventing a new restriction — it mirrors, at the view boundary, an invariant the sync layer already depends on, and returns a clean 404 before the modulestore load instead of letting the fetch blow up deeper in.

Net: the guard is correct for everything that reaches it today. When the model does generalize to non-course downstreams, get_for_block and this guard are exactly the two places that would need to be revisited together — but until then, a non-course context can't be stored and couldn't be fetched even if it were.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for validating!

raise not_found

if require_write_access:
if not user_has_course_permission(
user, COURSES_MANAGE_LIBRARY_UPDATES.identifier, context_key, LegacyAuthoringPermission.WRITE
):
raise not_found
else:
if not user_has_course_permission(
user, COURSES_VIEW_LIBRARY_UPDATES.identifier, context_key, LegacyAuthoringPermission.READ
):
raise not_found

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see this check is being used twice for COURSES_VIEW_LIBRARY_UPDATES with LegacyAuthoringPermission.READ

Then one for COURSES_MANAGE_LIBRARY_UPDATES.identifier with LegacyAuthoringPermission.WRITE, I mention this since I had to do this last check twice in my PR here

https://github.com/openedx/openedx-platform/pull/39055/changes#diff-d227aea50405ed64e46580703d9051aaa18b17a5d90160bc01eaff8e632c171eR362

Do you think it would make sense to keep those validations in a single file we can both share? As thin wrappers, not sure if there is any repo/org-wide preference for doing one way or another

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. I lean towards it not being worth it in this case. Our code, though related, live in separate layers of the codebase. The wrappers would be overly specific for a nuetral location like the authz djangoapp, and I think openedx.core.djangoapps.authz.decorators. user_has_course_permission is already a good enough abstraction.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, I updated it on my PR, thanks for your comments!

try:
block = modulestore().get_item(usage_key)
except ItemNotFoundError as exc:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from freezegun import freeze_time
from opaque_keys.edx.keys import ContainerKey, UsageKey
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
from openedx_authz.constants.roles import COURSE_EDITOR
from openedx_authz.constants.roles import COURSE_AUDITOR, COURSE_EDITOR
from openedx_content import models_api as content_models
from organizations.models import Organization
from rest_framework import status
Expand Down Expand Up @@ -1686,3 +1686,77 @@ def test_delete_component_should_be_ready_to_sync(self):
}

self.assertDictEqual(data[0], expected_results) # noqa: PT009


class GetDownstreamListAuthzViewTest(
CourseAuthoringAuthzTestMixin,
_BaseDownstreamViewTestMixin,
ImmediateOnCommitMixin,
SharedModuleStoreTestCase,
):
"""
AuthZ tests for:
GET /api/contentstore/v2/downstreams/?course_id=...

Validates that view_library_updates grants read access and
manage_library_updates is required for sync operations.
"""

def call_list_api(self, client, course_id):
return client.get("/api/contentstore/v2/downstreams/", data={"course_id": str(course_id)})

def call_sync_api(self, client, usage_key):
return client.post(
f"/api/contentstore/v2/downstreams/{usage_key}/sync",
content_type="application/json",
)

def test_editor_can_list_downstreams(self):
"""Course editor (has view_library_updates) can list downstream links."""
self.add_user_to_role_in_course(
self.authorized_user, COURSE_EDITOR.external_key, self.course.id
)
response = self.call_list_api(self.authorized_client, self.course.id)
assert response.status_code == status.HTTP_200_OK

def test_auditor_can_list_downstreams(self):
"""Course auditor (has view_library_updates) can list downstream links."""
self.add_user_to_role_in_course(
self.authorized_user, COURSE_AUDITOR.external_key, self.course.id
)
response = self.call_list_api(self.authorized_client, self.course.id)
assert response.status_code == status.HTTP_200_OK

def test_unauthorized_user_cannot_list_downstreams(self):
"""User without any course role cannot list downstream links."""
response = self.call_list_api(self.unauthorized_client, self.course.id)
assert response.status_code == status.HTTP_403_FORBIDDEN

def test_editor_can_sync_downstream(self):
"""Course editor passes the manage_library_updates gate for sync.

Note: a full 200 sync additionally requires library-level access on the
upstream, which is out of scope for this PR and tracked in
openedx-authz#419. This asserts only that the course-level write gate is
satisfied — i.e. the request is not the 404 that _load_accessible_block
raises on permission denial.
"""
self.add_user_to_role_in_course(
self.authorized_user, COURSE_EDITOR.external_key, self.course.id
)
response = self.call_sync_api(self.authorized_client, str(self.downstream_video_key))
assert response.status_code != status.HTTP_404_NOT_FOUND

def test_auditor_cannot_sync_downstream(self):
"""Course auditor (only view_library_updates) cannot sync a downstream block."""
self.add_user_to_role_in_course(
self.authorized_user, COURSE_AUDITOR.external_key, self.course.id
)
response = self.call_sync_api(self.authorized_client, str(self.downstream_video_key))
# This 404 is permission-based, not "block missing": test_editor_can_sync_downstream
# hits the SAME downstream_video_key with a role that holds manage_library_updates and
# gets a non-404 response, so the block demonstrably exists and is reachable. The only
# variable between the two tests is the role, so the auditor's 404 is the
# _load_accessible_block permission denial (which returns 404, not 403, to avoid
# leaking block existence).
assert response.status_code == status.HTTP_404_NOT_FOUND
Loading