Skip to content

fix: enforce GlobalRestrictedCountry on course access, not just registration - #39079

Open
asadali145 wants to merge 4 commits into
openedx:masterfrom
mitodl:asadali145/embargo-global-restricted-country
Open

fix: enforce GlobalRestrictedCountry on course access, not just registration#39079
asadali145 wants to merge 4 commits into
openedx:masterfrom
mitodl:asadali145/embargo-global-restricted-country

Conversation

@asadali145

@asadali145 asadali145 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Description

GlobalRestrictedCountry (added in #36202 / #36398) only blocks account
registration and profile-country changes today — it has no effect on course
access. The only mechanism that enforces course access is RestrictedCourse +
CountryAccessRule, which needs a row per course, so there was no way to
block a country from every course at once.

This PR wires GlobalRestrictedCountry into
embargo.api.check_course_access() so a listed country blocks every course,
with or without a RestrictedCourse entry. Per-course CountryAccessRule
checks still apply on top where configured, staff still bypass every check,
and a per-course disable_access_check override can never bypass a global
block (only a per-course one).

It also closes a related enforcement gap discovered while testing this
change
: embargo enforcement previously ran only inside EmbargoMiddleware,
which recognizes legacy /course///courses/ URLs. The Learning MFE's
course_home_api endpoints (outline, dates, progress, navigation,
course_metadata) don't match that URL pattern, so a learner in an embargoed
country could view all course content through the MFE regardless of any
RestrictedCourse/GlobalRestrictedCountry configuration. This PR also wires
the embargo check into check_course_access() in
lms/djangoapps/courseware/courses.py — the shared choke point every
course-home BFF view and legacy view already routes through — so both entry
points are covered by one check.

Impact: Operators can now block a country platform-wide via
GlobalRestrictedCountry (Django admin) instead of a row per course.
Learners in a globally-restricted country are blocked from every course, not
just ones with a RestrictedCourse row, and not just on the legacy
courseware pages — the Learning MFE is covered too. No change for Course
Authors/Developers, and no behavior change for existing deployments (both
tables are empty by default everywhere).

Supporting information

Motivating use case: mitodl/hq#13170 (OFAC embargo requirement).

Testing instructions

  1. Enable FEATURES['EMBARGO'].
  2. Add a country to GlobalRestrictedCountry in Django Admin (no
    RestrictedCourse row needed).
  3. From that country, confirm any course is blocked — enrollment, legacy
    courseware page, and the Learning MFE (e.g. /api/course_home/v1/outline/<id>).
  4. Confirm staff still get access, and unrelated countries/courses are unaffected.
  5. pytest openedx/core/djangoapps/embargo/ lms/djangoapps/courseware/tests/test_access.py lms/djangoapps/course_home_api/

Deadline

None.

Other information

No migration (reuses the existing GlobalRestrictedCountry table), no
dependency changes, no public API change (check_course_access()'s
signature/return contract is unchanged).

asadali145 and others added 2 commits September 4, 2026 23:54
…stration

GlobalRestrictedCountry previously only blocked account registration and
profile-country changes - it had no effect on actually accessing a course.
The real course-access enforcement (RestrictedCourse + CountryAccessRule)
requires a row per course, with no way to block a country across every
course at once.

Wire GlobalRestrictedCountry into embargo.api.check_course_access() so a
listed country blocks every course, regardless of whether it has a
RestrictedCourse entry. Per-course CountryAccessRule checks still apply on
top for courses that have them, and staff continue to bypass both. Also
ensure disable_access_check (a per-course escape hatch) can never bypass a
global restriction, and restore a fast path so courses/installs that never
use this feature don't pay for the extra lookups.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…block

check_course_access() now delegates to an internal _check_course_access()
that reports *why* access was denied (blocked_globally) as well as
whether it was, instead of redirect_if_blocked() re-deriving the same
IP/profile country lookups a second time via a separate helper.

This also fixes an ordering bug in that split: a per-course
CountryAccessRule match on an earlier IP could return before the
profile country was checked against GlobalRestrictedCountry, letting a
globally-restricted user slip through disable_access_check just
because their IP also happened to fail an unrelated per-course rule
first. The global check now runs across every IP and the profile
country before any per-course rule is considered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@openedx-webhooks

Copy link
Copy Markdown

Thanks for the pull request, @asadali145!

This repository is currently maintained by @openedx/wg-maintenance-openedx-platform-oncall.

Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review.

🔘 Get product approval

If you haven't already, check this list to see if your contribution needs to go through the product review process.

  • If it does, you'll need to submit a product proposal for your contribution, and have it reviewed by the Product Working Group.
    • This process (including the steps you'll need to take) is documented here.
  • If it doesn't, simply proceed with the next step.
🔘 Provide context

To help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:

  • Dependencies

    This PR must be merged before / after / at the same time as ...

  • Blockers

    This PR is waiting for OEP-1234 to be accepted.

  • Timeline information

    This PR must be merged by XX date because ...

  • Partner information

    This is for a course on edx.org.

  • Supporting documentation
  • Relevant Open edX discussion forum threads
🔘 Get a green build

If one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green.

Details
Where can I find more information?

If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources:

When can I expect my changes to be merged?

Our goal is to get community contributions seen and reviewed as efficiently as possible.

However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:

  • The size and impact of the changes that it introduces
  • The need for product review
  • Maintenance status of the parent repository

💡 As a result it may take up to several weeks or months to complete a review and merge your PR.

@openedx-webhooks openedx-webhooks added the open-source-contribution PR author is not from Axim or 2U label Sep 7, 2026
@github-project-automation github-project-automation Bot moved this to Needs Triage in Contributions Sep 7, 2026
Actual query count is 3, not 4: CountryAccessRule.check_country_access
caches its per-course allowed-countries list, so the IP-based check
and the profile-country check share a single query instead of each
paying for their own. Verified by running the full embargo suite
(101 passed) after fixing an unrelated missing openedx-learning
dependency in the local test environment.
…pass

GlobalRestrictedCountry (and CountryAccessRule) previously took effect only
through EmbargoMiddleware, which recognizes legacy /course/ and /courses/
URLs. The Learning MFE's course_home_api endpoints (outline, dates,
progress, navigation, course_metadata) don't match that URL pattern, so a
learner in an embargoed country could view all course content through the
MFE even with GlobalRestrictedCountry configured.

Wires the check into check_course_access() in courseware/courses.py - the
shared choke point every course_home_api view and legacy view already
routes through via has_access()/get_course_with_access(). Staff bypass
falls out of the existing check_course_access() fallback for free.

Verified live against a running devstack: the MFE outline API went from
200 OK (unblocked) to 403 with the embargo error code, while the legacy
courseware page continued to redirect as before.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

open-source-contribution PR author is not from Axim or 2U

Projects

Status: Needs Triage

Development

Successfully merging this pull request may close these issues.

2 participants