diff --git a/oeps/best-practices/oep-0066-bp-authorization.rst b/oeps/best-practices/oep-0066-bp-authorization.rst index 1362ebb37..654a1de31 100644 --- a/oeps/best-practices/oep-0066-bp-authorization.rst +++ b/oeps/best-practices/oep-0066-bp-authorization.rst @@ -11,7 +11,7 @@ OEP-66: User Authorization * - Title - User Authorization * - Last Modified - - 2025-12-18 + - 2026-06-17 * - Authors - Hilary Sinkoff (hsinkoff@2u.com), Jeremy Bowman (jbowman@edx.org), Maria F Magallanes (maria.magallanes@edunext.co) * - Arbiter @@ -305,6 +305,207 @@ could be used by default for all view classes which don't override it. .. _BasePermission: https://www.django-rest-framework.org/api-guide/permissions/#custom-permissions .. _filter_backends: https://www.django-rest-framework.org/api-guide/filtering/#setting-filter-backends +Separating Authorization Concerns in List Endpoints +--------------------------------------------------- + +The filter backend above answers a single question: "which rows may this user +see?" In practice, list endpoints tend to tangle that question together with two +others inside view logic, frequently as inline ``has_access`` checks. List +endpoints should instead keep three concerns separate, each handled by a +dedicated layer: + +* **Endpoint access**: DRF ``permission_classes``. Answers: "May this user call + this endpoint at all?" Returns ``403`` when denied. +* **Record visibility (queryset scoping)**: a policy applied in + ``get_queryset()``. Answers: "Which rows is this user allowed to see?" Rows the + user may not see are simply absent from the response; their absence is never a + ``403``. +* **User-driven filtering**: a ``django-filter`` ``FilterSet``. Answers: "Of the + visible rows, which did the user ask for?" (for example, ``?org=edX``). This + narrows an already-authorized queryset and must never widen it. + +Keeping these separate makes access behavior consistent across endpoints, +auditable in one place per model, and testable in isolation. + +This record-visibility layer is *application-level queryset scoping*, not +database `row-level security (RLS)`_. True RLS is enforced by the database engine +itself (for example, PostgreSQL ``CREATE POLICY``) and therefore cannot be +bypassed by application code. The pattern described here only constrains queries +that go through the view's scoped queryset; code that queries the model directly +(for example, ``CourseRun.objects.all()``) is not protected by it. It is an +authorization-filtered queryset, and the rest of this section describes a +reusable pattern for it. + +.. _row-level security (RLS): https://www.postgresql.org/docs/current/ddl-rowsecurity.html + +A pluggable policy and a mixin +============================== + +Record visibility is expressed as a policy object with a single ``scope`` method +and applied by a mixin that calls it from ``get_queryset()``. The policy is +deliberately separate from the view so that the same visibility rule can be +reused and unit-tested independently of any endpoint: + +.. code-block:: python + + from abc import ABC, abstractmethod + + from django.core.exceptions import ImproperlyConfigured + + + class ScopingPolicy(ABC): + """Scopes a queryset to the rows a user is permitted to see.""" + + @abstractmethod + def scope(self, queryset, user): + """Return ``queryset`` filtered to the rows visible to ``user``.""" + + + class ScopedQuerysetMixin: + """Applies ``scoping_policy`` to the base queryset of a DRF view.""" + + scoping_policy = None # a ScopingPolicy instance + + def get_queryset(self): + queryset = super().get_queryset() + if self.scoping_policy is None: + raise ImproperlyConfigured("ScopedQuerysetMixin requires a scoping_policy") + return self.scoping_policy.scope(queryset, self.request.user) + +How ``scope()`` checks permissions +================================== + +A policy must not re-implement access rules. It delegates to the platform's +authorization engine and translates the engine's answer into a queryset filter. +The key idea is to ask the engine *which scopes* (organizations, courses, etc.) the +subject may act in for a given permission, and then turn that scope set into a +``WHERE`` clause. :ref:`openedx-authz ` exposes exactly +this lookup via ``get_scopes_for_subject_and_permission``: + +.. code-block:: python + + from django.db.models import Q + + from openedx_authz.api.data import ( + ActionData, + CourseOverviewData, + OrgCourseOverviewGlobData, + PermissionData, + PlatformCourseOverviewGlobData, + UserData, + ) + from openedx_authz.api.roles import get_scopes_for_subject_and_permission + + + class CourseScopingPolicy(ScopingPolicy): + # A permission wraps the action being authorized; "view_course" is + # illustrative; use the action name registered for courses. + permission = PermissionData(action=ActionData(external_key="view_course")) + + def scope(self, queryset, user): + subject = UserData(external_key=user.username) + scopes = get_scopes_for_subject_and_permission(subject, self.permission) + + org_codes, course_keys = [], [] + for scope in scopes: + # Platform-wide scope (``course-v1:*``): the user sees everything. + if isinstance(scope, PlatformCourseOverviewGlobData): + return queryset + # Organization scope (``course-v1:ORG+*``): all courses in the org. + if isinstance(scope, OrgCourseOverviewGlobData): + org_codes.append(scope.org) + # A single course scope, matched on the course key. + elif isinstance(scope, CourseOverviewData): + course_keys.append(scope.course_id) + + # CourseRun stores the course key in ``course_key`` (its integer ``id`` + # is internal and not filtered on) and has no ``org`` column, so the + # organization is reached through the ``catalog_course`` relation. + return queryset.filter( + Q(catalog_course__org__short_name__in=org_codes) + | Q(course_key__in=course_keys) + ) + + + class CourseListView(ScopedQuerysetMixin, ListAPIView): + permission_classes = [IsAuthenticated] # endpoint access + scoping_policy = CourseScopingPolicy() # record visibility + filterset_class = CourseFilterSet # user-driven filtering + queryset = CourseRun.objects.all() + +This resolves the question of where the scoping logic lives: the visibility rule +is owned by the authorization engine, the policy only maps the engine's scope set +onto the model's columns, and the view wires the three layers together. + +Crucially, ``scope()`` resolves the user's accessible scopes in **one bulk +lookup** and filters in the database, rather than fetching every row and running +an ``enforce``-style check per object. openedx-authz established this same +scope-set approach, for performance reasons, in its +`ADR-0014 (bulk assignment queries without Casbin enforce) `_; +list endpoints should reuse it instead of per-row checks. + +List vs. single-object access +============================= + +The scoping filter applies to **list** responses. Retrieving a single object should +**not** build the full visible-scope set and then select from it; it should ask +the narrower question directly ("may this user see course X?") as an +object-level point check: + +* **List** (``GET /courses/``): ``get_queryset()`` applies ``scoping_policy.scope``, + so the user sees only rows within their accessible scopes. +* **Detail** (``GET /courses/{course_key}/``): a point check against the engine for that + one object, via DRF's ``check_object_permissions`` (for example using + ``DjangoObjectPermissions`` / ``has_perm``, or openedx-authz's + ``is_subject_allowed(subject, action, scope)``), returning ``404``/``403`` when + the object is not visible. + +Both paths consult the same authorization engine, so they cannot disagree, but +each uses the cheapest query for its shape: a scope-set filter for lists, a +single point check for detail. + +Note one DRF subtlety: ``GenericAPIView.get_object()`` filters +``get_queryset()`` before fetching the object, so a single view class that both +lists and retrieves will run ``scope()`` on the detail path too. That is safe +and often acceptable, but it builds the whole visible-scope set just to fetch +one row. To get the cheaper point check, give the detail action its own view (or +override ``get_object``) and authorize the single object directly rather than +inheriting the list queryset. Apply ``ScopedQuerysetMixin`` to the list view in +either case. + +Relationship to openedx-authz +============================= + +`openedx-authz`_ is the platform's emerging unified authorization framework (see +:ref:`openedx-authz `). The queryset-scoping pattern above +is the view-layer counterpart to it: openedx-authz owns *who may do what, and +where* (roles, permissions, and scopes, evaluated by `Casbin`_), while the +scoping policy owns *how that decision is applied to a Django queryset*. The +pattern is deliberately engine-agnostic (a ``bridgekeeper``-backed policy can +implement the same ``scope`` interface during migration), but new policies +should target openedx-authz. The relevant decisions there are: + +* `openedx-authz ADR-0002`_ defines the Scoped-RBAC / ABAC model and the + subject-action-object-context check that the policy maps onto. +* `openedx-authz ADR-0004`_ records the selection of Casbin as the policy engine. +* `openedx-authz ADR-0014`_ introduces the bulk scope-lookup primitive + (``get_scopes_for_subject_and_permission``) that ``scope()`` should use instead + of per-row enforcement. + +.. _openedx-authz ADR-0002: https://github.com/openedx/openedx-authz/blob/main/docs/decisions/0002-authorization-model-foundation.rst +.. _openedx-authz ADR-0004: https://github.com/openedx/openedx-authz/blob/main/docs/decisions/0004-technology-selection.rst +.. _openedx-authz ADR-0014: https://github.com/openedx/openedx-authz/blob/main/docs/decisions/0014-bulk-assignment-queries-without-casbin-enforce.rst + +Data sources without an ORM +=========================== + +The ``get_queryset()`` filter assumes a Django ORM queryset. Some endpoints are +backed by the modulestore (for example, Course Blocks), which cannot be filtered +at the database layer. For those, the same three-layer separation still holds, +but the scoping policy applies its scope decision in memory after retrieval. This is +less efficient than ORM-level scoping and should be treated as a fallback, used +only where an ORM-backed source is unavailable. + Systems/Protocols Overview ************************** The following systems/protocols are currently used in the Open edX ecosystem @@ -604,6 +805,14 @@ References Change History ************** +2026-06-17 +---------- + +* Extend the Django REST Framework section with a queryset-scoping pattern for + list endpoints: separating endpoint access, record visibility, and + user-driven filtering, with record visibility delegated to ``openedx-authz``. +* `Pull request #802 `_ + 2025-12-18 ----------