Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 210 additions & 1 deletion oeps/best-practices/oep-0066-bp-authorization.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <openedx-authz-section>` 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) <openedx-authz ADR-0014_>`_;
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 <openedx-authz-section>`). 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
Expand Down Expand Up @@ -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 <https://github.com/openedx/openedx-proposals/pull/802>`_

2025-12-18
----------

Expand Down