Skip to content

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

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

fix: enforce GlobalRestrictedCountry on course access, not just registration#39079
asadali145 wants to merge 1 commit 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 surfaces 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 browse a course through the MFE with no indication they were
blocked, regardless of RestrictedCourse/GlobalRestrictedCountry
configuration.

This PR closes that gap for the course_metadata endpoint specifically —
the one endpoint the Learning MFE already reads a course_access
(hasAccess/errorCode) flag from to redirect denied learners. A new
check_embargo_access() helper (lms/djangoapps/courseware/access_utils.py)
is called directly from CourseHomeMetadataView
(lms/djangoapps/course_home_api/course_metadata/views.py), so an embargoed
learner's course_access.errorCode comes back as "embargo".

Known, deliberate limitation (not fixed here): the check is not wired
into the shared check_course_access() in
lms/djangoapps/courseware/courses.py that outline/dates/progress/
navigation route through — an earlier version of this PR did that, but it
meant those endpoints started hard-403ing, which crashed the Learning MFE
(it wasn't written to expect a 403 there) and would have required frontend
data-layer changes to fix cleanly. Scoping the check down to course_metadata
only avoids that: those four endpoints still serve real course content to an
embargoed learner today, even though course_metadata correctly reports the
block. A companion Learning MFE PR
(openedx/frontend-app-learning#2055) makes the MFE redirect away from every
tab (including outline, which previously rendered anyway) once it sees
errorCode: "embargo" — but that's a UI-level mitigation, not a data-layer
block. Extending enforcement to those endpoints is left as follow-up work if/
when it's needed.

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 enrollment, the
legacy courseware pages, and (via course_metadata) get a correctly-flagged
Learning MFE that redirects them away. Real course content served by the
Learning MFE's other BFF endpoints is not yet blocked at the data layer (see
above). 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).

Companion PR: openedx/frontend-app-learning#2055 (Learning MFE reacts to
errorCode: "embargo" by redirecting every tab to the legacy blocked-message
page).

Testing instructions

  1. Enable FEATURES['EMBARGO'] (or confirm settings.EMBARGO is already
    True — it defaults True under lms.envs.tutor.development, False
    in general envs/common.py).
  2. Add a country to GlobalRestrictedCountry in Django Admin (no
    RestrictedCourse row needed), or via shell:
    from openedx.core.djangoapps.embargo.models import Country, GlobalRestrictedCountry
    country, _ = Country.objects.get_or_create(country='IR')
    GlobalRestrictedCountry.objects.get_or_create(country=country)
  3. Set a test learner's profile country to the same code (easiest way to
    test without IP-header spoofing tooling):
    from django.contrib.auth import get_user_model
    from django.core.cache import cache
    user = get_user_model().objects.get(username='<test-learner>')
    user.profile.country = 'IR'
    user.profile.save()
    cache.clear()
  4. Confirm enrollment and the legacy courseware page
    (/courses/<course_id>/course/) are blocked as before (unchanged by this
    PR) — the legacy page redirects to /embargo/blocked-message/courseware/default/.
  5. Confirm GET /api/course_home/course_metadata/<course_id> now returns
    course_access: {"has_access": false, "error_code": "embargo", ...} for
    that learner (status is still 200course_metadata always reports
    access in-band, same as enrollment_required/other denial reasons).
  6. Confirm staff/course-author accounts bypass the block (still has_access: true).
  7. Optional, if also running the companion Learning MFE branch: log into the
    MFE as the test learner and confirm every course tab (outline included)
    redirects to the legacy blocked-message page instead of rendering.
  8. pytest openedx/core/djangoapps/embargo/ lms/djangoapps/courseware/tests/test_access.py lms/djangoapps/course_home_api/
  9. Clean up: remove the GlobalRestrictedCountry row and reset the test
    learner's profile.country.

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). See "Known, deliberate limitation"
above for the accepted scope gap (outline/dates/progress/navigation
endpoints still serve real content to an embargoed learner via the Learning
MFE) and its companion frontend PR.

@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.

Copilot AI left a comment

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.

🔵 Needs a closer look

It changes core access-control enforcement paths (including caching and request-derived GeoIP behavior), so it warrants a final human review despite only minor issues found.

Pull request overview

This PR extends the Embargo subsystem so GlobalRestrictedCountry is enforced for course access (not just registration/profile country changes), and closes a key enforcement gap for the Learning MFE by ensuring the course_metadata BFF endpoint reports embargo denial via course_access.error_code = "embargo".

Changes:

  • Wire GlobalRestrictedCountry into embargo.api.check_course_access() while preserving staff bypass and ensuring per-course disable_access_check cannot override global blocks.
  • Add a courseware-layer helper to surface embargo denial as an AccessError and apply it in the course_metadata endpoint’s access computation.
  • Expand test coverage for global restriction behavior, staff bypass, redirect behavior, and query/caching expectations.
File summaries
File Description
openedx/core/djangoapps/embargo/api.py Implements global-country enforcement in course access checks and exposes a richer internal result for redirect logic.
openedx/core/djangoapps/embargo/tests/test_api.py Updates/expands tests to cover global restriction, staff bypass, redirect behavior, and revised query counts.
lms/djangoapps/courseware/access_utils.py Adds check_embargo_access() helper for use by LMS access flows / BFF endpoints.
lms/djangoapps/courseware/access_response.py Introduces EmbargoAccessError with error_code = "embargo" for in-band API signaling.
lms/djangoapps/course_home_api/course_metadata/views.py Applies embargo access check to ensure metadata reports embargo denial ahead of other denial reasons.
lms/djangoapps/course_home_api/course_metadata/tests/test_views.py Adds coverage verifying embargo denial and staff bypass behavior for course metadata endpoint.
Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread openedx/core/djangoapps/embargo/api.py Outdated
Comment thread lms/djangoapps/courseware/access_utils.py Outdated
asadali145 added a commit to mitodl/edx-platform that referenced this pull request Sep 8, 2026
- embargo/api.py: resolve each IP's country lazily in the global pass
  instead of eagerly building the whole ip_countries list upfront, so a
  block on an early IP in the chain skips GeoIP lookups for the rest.
  Per-course pass still reuses whatever was resolved.
- courseware/access_utils.py: fix check_embargo_access()'s stale docstring,
  which still claimed to be "the single choke point shared by every caller
  of check_course_access" - no longer true since the previous commit scoped
  it down to the course_metadata view only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@asadali145
asadali145 requested a balanced review from Copilot September 8, 2026 09:19

Copilot AI left a comment

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.

🔵 Needs a closer look

Country-based access control is security-sensitive, and the acknowledged data-layer gap across other course-home endpoints requires final human risk acceptance.

Review details
  • Files reviewed: 7/7 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

…stration

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

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).

Also closes a related enforcement gap for the Learning MFE: the
course_metadata BFF endpoint (the one endpoint the MFE already reads a
course_access hasAccess/errorCode flag from to redirect denied learners)
now runs the same embargo check directly via a new check_embargo_access()
helper, so an embargoed learner's course_access.errorCode comes back as
"embargo". This is deliberately scoped to course_metadata only, not the
shared check_course_access() that outline/dates/progress/navigation route
through - wiring it there too would hard-403 those endpoints, which broke
the Learning MFE (companion frontend fix: openedx/frontend-app-learning#2055
makes the MFE redirect away from every tab, including outline, on
errorCode "embargo").

Motivating use case: mitodl/hq#13170 (OFAC embargo requirement). No
migration (reuses the existing GlobalRestrictedCountry table), no
behavior change for existing deployments (both tables are empty by
default everywhere).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@asadali145
asadali145 force-pushed the asadali145/embargo-global-restricted-country branch from 5c3b6d8 to b84bcdb Compare September 8, 2026 11:59
@pdpinch

pdpinch commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

@asadali145 I'm concerned this PR is doing too much at once -- adding the global block and also changing the API response. What would you think about separating them out?

Since the API change is intended to facilitate a UX change, I think it should be gated on some product review.

@asadali145

Copy link
Copy Markdown
Contributor Author

I'm concerned this PR is doing too much at once -- adding the global block and also changing the API response. What would you think about separating them out?

@pdpinch Even if we add a global block, learners would still be able to access courses in learning MFE because there is no gating there, so I think it's best to add both changes in a single PR.

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.

4 participants