Skip to content

feat: apply OEP-66 (queryset-scoping pattern) across 6 standardized APIs#38847

Open
Abdul-Muqadim-Arbisoft wants to merge 6 commits into
openedx:feat/axim-api_improvementsfrom
edly-io:feat/apply_queryset-scoping_oep_on_apis
Open

feat: apply OEP-66 (queryset-scoping pattern) across 6 standardized APIs#38847
Abdul-Muqadim-Arbisoft wants to merge 6 commits into
openedx:feat/axim-api_improvementsfrom
edly-io:feat/apply_queryset-scoping_oep_on_apis

Conversation

@Abdul-Muqadim-Arbisoft

Copy link
Copy Markdown
Contributor

Summary

Applies the OEP-66 "Separating Authorization Concerns in List Endpoints"
queryset-scoping pattern (added in openedx-proposals#802)
across the 6 standardized FC-0118 APIs.

OEP-66 keeps three authorization concerns separate on a list endpoint:

  • Endpoint accesspermission_classes (may this user call the endpoint?)
  • Record visibility → a ScopingPolicy applied in get_queryset() via
    ScopedQuerysetMixin, resolving the user's accessible scopes in one bulk
    lookup
    (openedx-authz get_scopes_for_*_and_permission) rather than per-row checks
  • User-driven filtering → query params that narrow (never widen) the authorized rows

Each API is applied and committed on its own (one commit per API). Single-object
endpoints get the ADR's detail-path treatment (a direct point check, documented);
list endpoints get the record-visibility layer, using the ORM mixin where an ORM
queryset exists and the ADR's documented in-memory fallback where the source is the
modulestore.

Commits

# Area Related PR Change
1 Grading v3 #38726 docs — single-object audit + point-check annotation
2 Xblock v1 #38723 docs — single-object audit (HasCourseAuthorAccess point check)
3 Course Details v3 #38708 docs — single-object audit + point-check annotation
4 Home v3 #38694 feat — shared scoping.py + list-endpoint compliance docs
5 Home v4 #38684 docs — list-endpoint compliance
6 Enrollments v2 #38724 feat — ScopedQuerysetMixin + AdminEnrollmentScopingPolicy on the admin list

What OEP-66 layers were added

  • Grading v3 / Xblock v1 / Course Details v3single-object endpoints
    (partial_update / CRUD-by-key / retrieve+update). No list action, no
    queryset, so the list-scoping layers are out of scope. Each already implements
    OEP-66's single-object point check (inline user_has_course_permission, or
    the HasCourseAuthorAccess permission class), now documented and annotated.
  • Home v3 / Home v4 — genuine list endpoints, but modulestore-backed
    (not a Django ORM queryset). Record visibility is already delegated to openedx-authz
    via get_courses_accessible_to_userget_scopes_for_user_and_permission(COURSES_VIEW_COURSE)
    (one bulk scope-set lookup, merged with legacy roles). Home v3's libraries action
    is scoped by a per-object has_studio_read_access check; its list action carries
    no records. Per OEP-66's "Data sources without an ORM" note, the scope decision is
    applied in memory. Documented per action; the reusable ORM abstractions can't attach
    here (no QuerySet).
  • Enrollments v2EnrollmentsAdminListView is a real ListAPIView over an ORM
    queryset, so it adopts the pattern literally: ScopedQuerysetMixin +
    AdminEnrollmentScopingPolicy applied in get_queryset(), with the form-based user
    filtering moved into filter_queryset(). The policy is a pass-through for platform
    admins (IsAdminUser), documented as the single seam for a narrower authz scope-set
    filter later — no behavior change today. EnrollmentViewSet.list (service-layer
    scoped) and UserRolesView (self-scoped) get compliance notes.

Shared utilities

New reusable module openedx/core/lib/api/scoping.pyScopingPolicy (ABC) and
ScopedQuerysetMixin — so any ORM-backed DRF list view can wire the OEP-66
record-visibility layer without re-implementing it. Adopted by Enrollments v2's admin
list; ready for future ORM-backed endpoints.

Tests added

Test File Cases
ScopingPolicyTests, ScopedQuerysetMixinTests openedx/core/lib/api/tests/test_scoping.py 4
TestEnrollmentsAdminListView openedx/core/djangoapps/enrollments/v2/tests/test_views.py 8

12 new tests total, all mocked + MongoDB-free. The admin-list tests guard the
get_queryset()filter_queryset() split: endpoint access (401/403), pass-through
scoping (admin sees all rows), course_key/username filters still narrow, the
400-on-invalid-params path, and the ADR 0033 Deprecation header. Commits 1-3 and 5
are documentation-only (no runtime change) and add no tests, consistent with prior
declaration-only ADR commits (e.g. ADR 0034, #38796).

Backward compatibility

No default response or behavior changes. Commits 1-5 are docstring/comment
additions only. Commit 6 restructures EnrollmentsAdminListView internally
(get_queryset → base queryset + filter_queryset) but preserves every filter, the
form-validation 400 path, ordering, and the ADR 0033 header — the scoping policy is a
pass-through, so the same rows are returned. Guarded by the new regression tests above.

Out of scope

  • List-scoping mechanism on single-object endpoints (Grading, Xblock, Course
    Details) — no list/queryset to scope; documented as the OEP-66 detail-path point check.
  • Narrowing the admin enrollment listAdminEnrollmentScopingPolicy intentionally
    returns all rows for platform admins; introducing an org-scoped authz filter is a
    future, opt-in change flagged in the policy docstring.
  • ORM rewrite of Home v3/v4 — kept modulestore-backed (in-memory scoping) to avoid a
    data-source change; the ORM mixin is the sibling path for future ORM-backed endpoints.

Test plan

# CMS
pytest cms/djangoapps/contentstore/rest_api/v1/views/tests/test_xblock_viewset.py \
       cms/djangoapps/contentstore/rest_api/v3/views/tests/test_authoring_grading.py \
       cms/djangoapps/contentstore/rest_api/v3/views/tests/test_course_details.py \
       cms/djangoapps/contentstore/rest_api/v3/views/tests/test_home.py \
       cms/djangoapps/contentstore/rest_api/v3/tests/test_home.py \
       cms/djangoapps/contentstore/rest_api/v4/views/tests/test_home.py \
       cms/djangoapps/contentstore/rest_api/v4/tests/test_home.py

# LMS
pytest openedx/core/lib/api/tests/test_scoping.py \
       openedx/core/djangoapps/enrollments/v2/tests/test_views.py \
       openedx/core/djangoapps/enrollments/v2/tests/test_envelope.py
All green (CMS 92, LMS 49); ruff check clean on all changed files.

Abdul Muqadim added 3 commits July 4, 2026 23:49
Document how the v3 AuthoringGradingViewSet relates to the OEP-66
queryset-scoping pattern for list endpoints (openedx-proposals#802).

This endpoint is single-object: partial_update operates on one course
identified by course_key and exposes no list action or queryset, so the
list-scoping layers (ScopingPolicy / ScopedQuerysetMixin / get_queryset /
django-filter) are out of scope. The applicable OEP-66 guidance is the
single-object / detail path — a direct object-level point check rather
than building a visible-scope set — which the viewset already implements
via the inline user_has_course_permission check. Adds a compliance entry
to the module docstring and annotates the point check accordingly.

Validated: 15/15 v3 grading tests pass in Tutor dev (Python 3.12).
Document how XblockViewSet relates to the OEP-66 queryset-scoping pattern
for list endpoints (openedx-proposals#802).

This viewset is single-object: every action (create / retrieve / update /
partial_update / destroy) operates on one xblock identified by
usage_key_string and exposes no list action or queryset, so the
list-scoping layers (ScopingPolicy / ScopedQuerysetMixin / get_queryset /
django-filter) are out of scope. The applicable OEP-66 guidance is the
single-object / detail path — a direct object-level point check — which
the viewset already implements: initial() derives the xblock's course_key
and the HasCourseAuthorAccess permission class point-checks authoring
access to that one course. Adds a compliance entry to the module docstring
and annotates permission_classes.

Validated: 17/17 xblock v1 viewset tests pass in Tutor dev (Python 3.12).
Document how CourseDetailsViewSet relates to the OEP-66 queryset-scoping
pattern for list endpoints (openedx-proposals#802).

This viewset is single-object: retrieve and update operate on one course
identified by course_id and expose no list action or queryset, so the
list-scoping layers (ScopingPolicy / ScopedQuerysetMixin / get_queryset /
django-filter) are out of scope. The applicable OEP-66 guidance is the
single-object / detail path — a direct object-level point check — which
the viewset already implements via the per-action inline
user_has_course_permission checks. Adds a compliance entry to the module
docstring and annotates the retrieve point check.

Validated: 18/18 course_details v3 tests pass in Tutor dev (Python 3.12).
Abdul Muqadim added 3 commits July 5, 2026 02:07
Add the reusable OEP-66 building blocks and document how the v3 HomeViewSet
list endpoints conform to the queryset-scoping pattern (openedx-proposals#802).

New shared module openedx/core/lib/api/scoping.py provides ScopingPolicy
(ABC) and ScopedQuerysetMixin — the ORM-backed record-visibility layer for
DRF list endpoints, delegating scope resolution to openedx-authz in one bulk
lookup rather than per-row checks.

HomeViewSet is modulestore-backed (no Django ORM queryset), so the mixin
cannot attach. Per OEP-66's "Data sources without an ORM" note, record
visibility is applied in memory by the existing helpers, and differs per
action:
  * courses  — get_course_context -> get_courses_accessible_to_user resolves
    visible courses via one get_scopes_for_user_and_permission(COURSES_VIEW_COURSE)
    scope-set lookup (merged with legacy roles).
  * libraries — get_library_context -> _accessible_libraries_iter filters with
    a per-object has_studio_read_access check (per-object variant, not scope-set).
  * list     — carries no records (get_home_context(no_course=True) returns only
    Studio metadata); nothing to scope.
Endpoint access is IsAuthenticated; ?org=/?is_migrated= are user-driven filters.
Adds a compliance entry to the module docstring, annotates the courses and
libraries data sources, and adds openedx/core/lib/api/tests/test_scoping.py
covering the mixin's policy application and ImproperlyConfigured guard.

Validated in Tutor dev (Python 3.12): home v3 tests + new test_scoping pass.
Document how the v4 HomeCoursesViewSet paginated list endpoint conforms to
the OEP-66 queryset-scoping pattern (openedx-proposals#802).

Like Home v3, this is a genuine list endpoint that is modulestore-backed
(no Django ORM queryset): list sources courses from get_course_context_v2 →
get_courses_accessible_to_user, which resolves the user's visible courses in
one bulk get_scopes_for_user_and_permission(COURSES_VIEW_COURSE) lookup
(merged with legacy roles) — OEP-66's scope-set approach, not per-row checks.
Endpoint access is IsAuthenticated; ?org=/?search=/?active_only=/
?archived_only= are the user-driven filters. Because the source is the
modulestore (a list paginated in memory), the shared ORM ScopedQuerysetMixin
cannot attach; per OEP-66's "Data sources without an ORM" note the scope is
applied in memory by the accessible-courses helper. Adds a compliance entry
to the class docstring and annotates the data source.

Validated: 27/27 home v4 tests pass in Tutor dev (Python 3.12).
Wire the literal OEP-66 three-layer separation into the ORM-backed admin
enrollment list, document the sibling list surfaces, and add regression
tests (openedx-proposals#802).

EnrollmentsAdminListView is a real ListAPIView over CourseEnrollment, so it
adopts the pattern directly:
  * Endpoint access — permission_classes = (IsAdminUser,).
  * Record visibility — ScopedQuerysetMixin applies AdminEnrollmentScopingPolicy
    to a base `queryset` class attribute in get_queryset(). The policy is a
    pass-through for platform admins (who see all enrollments), documented as
    the single seam for a narrower openedx-authz scope-set filter later;
    behavior is unchanged.
  * User-driven filtering — the form-based course_key/course_keys/username/
    email/ordering filtering moves from get_queryset() into filter_queryset(),
    so it runs after scoping and only narrows the authorized queryset.

EnrollmentViewSet.list resolves record visibility (own vs. staff/api-key) in
the operations service, and UserRolesView is inherently self-scoped, so the
mixin does not attach there; compliance notes are added to both docstrings.

Adds TestEnrollmentsAdminListView regression tests guarding the
get_queryset->filter_queryset split: endpoint access (401/403), pass-through
scoping (admin sees all rows), course_key/username filters still narrow, the
400-on-invalid-params path (validation now in filter_queryset), the ADR 0033
Deprecation header, and the scoping-policy pass-through.

Validated in Tutor dev (Python 3.12): enrollments v2 view + envelope tests
and the new admin-list tests pass.
@Abdul-Muqadim-Arbisoft Abdul-Muqadim-Arbisoft force-pushed the feat/apply_queryset-scoping_oep_on_apis branch from e1b225a to ebf36d7 Compare July 4, 2026 21:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant