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
5 changes: 4 additions & 1 deletion cms/djangoapps/contentstore/views/tests/test_course_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,10 @@ def test_number_of_calls_to_db(self):
"""
Test to check number of queries made to mysql and mongo
"""
with self.assertNumQueries(21, table_ignorelist=WAFFLE_TABLES):
# 22, not 21: EmbargoMiddleware now always resolves GlobalRestrictedCountry's cached
# country list (one query on a cold cache) even when the course has no RestrictedCourse
# row, so a global block applies without needing a per-course row.
with self.assertNumQueries(22, table_ignorelist=WAFFLE_TABLES):
with check_mongo_calls(3):
self.client.get(reverse_course_url('course_handler', self.course.id), content_type="application/json")

Expand Down
43 changes: 43 additions & 0 deletions lms/djangoapps/course_home_api/course_metadata/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES_STREAK_CELEBRATION,
)
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration
from openedx.core.djangoapps.embargo.models import Country, GlobalRestrictedCountry


@ddt.ddt
Expand Down Expand Up @@ -241,6 +242,48 @@ def test_course_access(

self._assert_course_access_response(response, expect_course_access, error_code)

@override_settings(EMBARGO=True)
def test_embargo_blocks_access(self):
"""
A `GlobalRestrictedCountry` block applies here too, not just to the legacy
courseware page - this is the metadata endpoint the Learning MFE actually
reads `course_access` from, which `EmbargoMiddleware`'s URL-pattern matching
does not cover.
"""
CourseEnrollment.enroll(self.user, self.course.id)
GlobalRestrictedCountry.objects.create(country=Country.objects.create(country='CU'))

with patch('openedx.core.djangoapps.embargo.api.country_code_from_ip', return_value='CU'):
response = self.client.get(self.url, HTTP_X_FORWARDED_FOR='1.2.3.4')

self._assert_course_access_response(response, False, 'embargo')

@override_settings(EMBARGO=True)
def test_embargo_does_not_mask_a_more_specific_denial(self):
"""
An unenrolled learner in a restricted country keeps `enrollment_required` - the
embargo check only runs once access is otherwise granted, so the learner is told
the thing they can act on first, and blocked requests skip the country lookups.
"""
GlobalRestrictedCountry.objects.create(country=Country.objects.create(country='CU'))

with patch('openedx.core.djangoapps.embargo.api.country_code_from_ip', return_value='CU'):
response = self.client.get(self.url, HTTP_X_FORWARDED_FOR='1.2.3.4')

self._assert_course_access_response(response, False, 'enrollment_required')

@override_settings(EMBARGO=True)
def test_embargo_staff_bypass(self):
""" Course staff should still get access even when their country is globally restricted. """
CourseInstructorRole(self.course.id).add_users(self.user)
GlobalRestrictedCountry.objects.create(country=Country.objects.create(country='CU'))

with patch('openedx.core.djangoapps.embargo.api.country_code_from_ip', return_value='CU'):
response = self.client.get(self.url, HTTP_X_FORWARDED_FOR='1.2.3.4')

assert response.status_code == 200
assert response.data['course_access']['has_access'] is True

@override_settings(ENABLE_DISCUSSION_SERVICE=True)
@ddt.data(True, False)
def test_discussion_tab_visible(self, visible):
Expand Down
11 changes: 11 additions & 0 deletions lms/djangoapps/course_home_api/course_metadata/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from lms.djangoapps.course_home_api.course_metadata.serializers import CourseHomeMetadataSerializer
from lms.djangoapps.course_home_api.toggles import new_discussion_sidebar_view_is_enabled
from lms.djangoapps.courseware.access import has_access, has_cms_access
from lms.djangoapps.courseware.access_utils import check_embargo_access
from lms.djangoapps.courseware.context_processor import user_timezone_locale_prefs
from lms.djangoapps.courseware.courses import check_course_access
from lms.djangoapps.courseware.exceptions import CourseAccessRedirect
Expand Down Expand Up @@ -104,6 +105,16 @@ def get(self, request, *args, **kwargs):
check_if_authenticated=True,
apply_priority_access_checks=True,
)
# A country embargo (GlobalRestrictedCountry / CountryAccessRule) is checked here -
# rather than shared into check_course_access() - so it stays scoped to metadata's
# UI-level access flag for now. Only worth checking once access is otherwise granted:
# a more specific denial (enrollment_required, authentication_required) keeps its own
# error code, and we skip the embargo check's country lookups on requests that are
# already blocked.
if load_access:
embargo_access = check_embargo_access(request.user, course)
if not embargo_access:
load_access = embargo_access

_, request.user = setup_masquerade(
request,
Expand Down
12 changes: 12 additions & 0 deletions lms/djangoapps/courseware/access_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,3 +290,15 @@ def __init__(self, courselike):
course_name=courselike.display_name_with_default,
)
super().__init__(error_code, developer_message, user_message)


class EmbargoAccessError(AccessError):
"""
Access denied because the user's country is blocked by embargo rules
(`GlobalRestrictedCountry` or a per-course `CountryAccessRule`).
"""
def __init__(self):
error_code = "embargo"
developer_message = "User's location is blocked by country embargo rules"
user_message = _("Access to this course is blocked from your current location")
super().__init__(error_code, developer_message, user_message)
30 changes: 30 additions & 0 deletions lms/djangoapps/courseware/access_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
from datetime import datetime, timedelta
from logging import getLogger

from crum import get_current_request
from django.conf import settings
from edx_django_utils import ip
from openedx_filters.learning.filters import CourseStartDateValidationFailed
from pytz import UTC

Expand All @@ -15,10 +17,12 @@
from lms.djangoapps.courseware.access_response import (
AccessResponse,
AuthenticationRequiredAccessError,
EmbargoAccessError,
EnrollmentRequiredAccessError,
StartDateError,
)
from lms.djangoapps.courseware.masquerade import get_course_masquerade, is_masquerading_as_student
from openedx.core.djangoapps.embargo import api as embargo_api
from openedx.features.course_experience import (
COURSE_ENABLE_UNENROLLED_ACCESS_FLAG,
COURSE_PRE_START_ACCESS_FLAG,
Expand Down Expand Up @@ -159,6 +163,32 @@ def check_authentication(user, course):
return AuthenticationRequiredAccessError()


def check_embargo_access(user, course):
"""
Deny access if the user's country is blocked by embargo rules.

Currently called only from the course_metadata BFF view, to report embargo denial
via its `course_access` JSON field - not from the shared `check_course_access()`
that other course-home BFF views (outline, dates, progress, navigation) route
through, so those still serve content to an embargoed learner. Legacy courseware
pages are covered separately by `EmbargoMiddleware` for URLs it recognizes.

Callers should only reach for this once access is otherwise granted: a more specific
denial keeps its own error code, and the country lookups behind this check are not free.

Returns:
AccessResponse: Either ACCESS_GRANTED or EmbargoAccessError.
"""
request = get_current_request()

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.

It can be replaced with access_checks filter setp

class EmbargoAccessCheck(PipelineStep):
    def run_filter(self, user, course_key):
        request = crum.get_current_request()
        if not embargo_api.check_course_access(course_key, user=user,
                                               ip_addresses=ip.get_all_client_ips(request)):
            raise CoursewareAccessChecksRequested.PreventCoursewareAccess(
                "Embargo", error_code="embargo",
                user_message=_("Access to this course is blocked from your current location"),
            )

This will require an update in env, i.e

OPEN_EDX_FILTERS_CONFIG = {
    "org.openedx.learning.courseware.access_checks.requested.v1": {
        "fail_silently": False,
        "pipeline": [
            "openedx.core.djangoapps.embargo.filters.EnforceCountryEmbargo",
        ],
    },
}

and you can revert changes in lms/djangoapps/courseware/access_response.py and │ lms/.../course_metadata/views.py after this, as they will no longer be required.

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.

Thanks for the pointer — I looked into it properly. The mechanism does fit: CoursewareAccessChecksRequested fires at exactly the call site I patched, and error_code: "embargo" would still reach the MFE, so access_response.py and the view edit could both go away.

What gave me pause is whether a core, settings.EMBARGO-gated feature should route through the extension framework. docs/concepts/extension_points.rst describes filters as being for plugin developers extending the platform, and there isn't a single non-test PipelineStep in edx-platform today — the platform defines the trigger points and plugins supply the steps. Going the filter route here would also mean embargo enforcement is off unless an operator sets OPEN_EDX_FILTERS_CONFIG, and since that dict gets replaced wholesale by env overrides, an OFAC-style control could be dropped silently. Would you agree that's a bit risky for this particular feature?

One thing I did take from your suggestion though: the filter chain runs after the enrollment check, which is better precedence than what I had. I've reordered the view so the embargo check only runs once access is otherwise granted, so a more specific denial keeps its own error code.

ip_addresses = ip.get_all_client_ips(request) if request is not None else None
url = request.path if request is not None else None

if embargo_api.check_course_access(course.id, user=user, ip_addresses=ip_addresses, url=url):
return ACCESS_GRANTED

return EmbargoAccessError()


def check_public_access(course, visibilities):
"""
This checks if the unenrolled access waffle flag for the course is set
Expand Down
9 changes: 6 additions & 3 deletions lms/djangoapps/courseware/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1270,8 +1270,8 @@ def test_view_certificate_link(self):
self.assertContains(resp, "earned a certificate for this course.")

@ddt.data(
(True, 56),
(False, 56),
(True, 57),
(False, 57),
)
@ddt.unpack
def test_progress_queries_paced_courses(self, self_paced, query_count):
Expand All @@ -1285,8 +1285,11 @@ def test_progress_queries_paced_courses(self, self_paced, query_count):
def test_progress_queries(self):
ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1))
self.setup_course()
# 57, not 56: EmbargoMiddleware now always resolves GlobalRestrictedCountry's cached
# country list (one query on a cold cache) even when the course has no RestrictedCourse
# row, so a global block applies without needing a per-course row.
with self.assertNumQueries(
56, table_ignorelist=QUERY_COUNT_TABLE_IGNORELIST
57, table_ignorelist=QUERY_COUNT_TABLE_IGNORELIST
), check_mongo_calls(2):
self._get_progress_page()

Expand Down
Loading
Loading