fix: enforce GlobalRestrictedCountry on course access, not just registration - #39079
fix: enforce GlobalRestrictedCountry on course access, not just registration#39079asadali145 wants to merge 1 commit into
Conversation
|
Thanks for the pull request, @asadali145! This repository is currently maintained by 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 approvalIf you haven't already, check this list to see if your contribution needs to go through the product review process.
🔘 Provide contextTo 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:
🔘 Get a green buildIf one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green. DetailsWhere 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:
💡 As a result it may take up to several weeks or months to complete a review and merge your PR. |
There was a problem hiding this comment.
🔵 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
GlobalRestrictedCountryintoembargo.api.check_course_access()while preserving staff bypass and ensuring per-coursedisable_access_checkcannot override global blocks. - Add a courseware-layer helper to surface embargo denial as an
AccessErrorand apply it in thecourse_metadataendpoint’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.
- 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>
There was a problem hiding this comment.
🔵 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>
5c3b6d8 to
b84bcdb
Compare
|
@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. |
@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. |
Description
GlobalRestrictedCountry(added in #36202 / #36398) only blocks accountregistration 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 toblock a country from every course at once.
This PR wires
GlobalRestrictedCountryintoembargo.api.check_course_access()so a listed country blocks every course,with or without a
RestrictedCourseentry. Per-courseCountryAccessRulechecks still apply on top where configured, staff still bypass every check,
and a per-course
disable_access_checkoverride can never bypass a globalblock (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'scourse_home_apiendpoints (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/GlobalRestrictedCountryconfiguration.
This PR closes that gap for the
course_metadataendpoint specifically —the one endpoint the Learning MFE already reads a
course_access(
hasAccess/errorCode) flag from to redirect denied learners. A newcheck_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 embargoedlearner's
course_access.errorCodecomes back as"embargo".Known, deliberate limitation (not fixed here): the check is not wired
into the shared
check_course_access()inlms/djangoapps/courseware/courses.pythatoutline/dates/progress/navigationroute through — an earlier version of this PR did that, but itmeant 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_metadataonly avoids that: those four endpoints still serve real course content to an
embargoed learner today, even though
course_metadatacorrectly reports theblock. 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-layerblock. 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-flaggedLearning 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-messagepage).
Testing instructions
FEATURES['EMBARGO'](or confirmsettings.EMBARGOis alreadyTrue— it defaultsTrueunderlms.envs.tutor.development,Falsein general
envs/common.py).GlobalRestrictedCountryin Django Admin (noRestrictedCourserow needed), or via shell:test without IP-header spoofing tooling):
(
/courses/<course_id>/course/) are blocked as before (unchanged by thisPR) — the legacy page redirects to
/embargo/blocked-message/courseware/default/.GET /api/course_home/course_metadata/<course_id>now returnscourse_access: {"has_access": false, "error_code": "embargo", ...}forthat learner (status is still
200—course_metadataalways reportsaccess in-band, same as
enrollment_required/other denial reasons).has_access: true).MFE as the test learner and confirm every course tab (outline included)
redirects to the legacy blocked-message page instead of rendering.
pytest openedx/core/djangoapps/embargo/ lms/djangoapps/courseware/tests/test_access.py lms/djangoapps/course_home_api/GlobalRestrictedCountryrow and reset the testlearner's
profile.country.Deadline
None.
Other information
No migration (reuses the existing
GlobalRestrictedCountrytable), nodependency changes, no public API change (
check_course_access()'ssignature/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.