-
Notifications
You must be signed in to change notification settings - Fork 4.3k
feat: apply OEP-66 (queryset-scoping pattern) across 6 standardized APIs #38847
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/axim-api_improvements
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This shared DRF tooling should probably live in the https://github.com/openedx/edx-drf-extensions repo. We want them to be re-used by libraries that plugin to the openedx-platform as well. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| """ | ||
| OEP-66 queryset-scoping building blocks for DRF list endpoints. | ||
|
|
||
| OEP-66 ("Separating Authorization Concerns in List Endpoints", | ||
| openedx-proposals#802) prescribes keeping three authorization concerns | ||
| separate on a list endpoint, each handled by a dedicated layer: | ||
|
|
||
| * **Endpoint access** — DRF ``permission_classes`` (may this user call the | ||
| endpoint at all?). Returns ``403`` when denied. | ||
| * **Record visibility (queryset scoping)** — a :class:`ScopingPolicy` applied | ||
| in ``get_queryset()`` by :class:`ScopedQuerysetMixin` (which rows may this | ||
| user 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`` (of the visible | ||
| rows, which did the user ask for?). Narrows an already-authorized queryset | ||
| and must never widen it. | ||
|
|
||
| This is *application-level queryset scoping*, not database-enforced | ||
| `row-level security`_: it only constrains queries that go through the view's | ||
| scoped queryset. Code that queries the model directly is not protected by it. | ||
|
|
||
| A policy resolves the subject's accessible scopes in **one bulk lookup** | ||
| (see openedx-authz ``get_scopes_for_subject_and_permission`` / | ||
| ``get_scopes_for_user_and_permission``) and turns that scope set into a | ||
| ``WHERE`` clause, rather than fetching every row and running an ``enforce``-style | ||
| check per object. | ||
|
|
||
| .. _row-level security: https://www.postgresql.org/docs/current/ddl-rowsecurity.html | ||
| """ | ||
|
|
||
| from abc import ABC, abstractmethod | ||
|
|
||
| from django.core.exceptions import ImproperlyConfigured | ||
|
|
||
|
|
||
| class ScopingPolicy(ABC): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rather than using ABCs which have a lot of drawbacks, the big one being that we only find out about issues at runtime. Another being that if we end up wanting to change anything about this base policy, everything will break until it can all be updated together. This becomes extra complex when the updates need to land in multiple libraries and the core platform all at once. I think the goal here is to provide documentation for what we except the interface to be for a ScopingPolicy to be. Just using duck typing checks or writing a structural type definition might work better here, and then we can have mypy find issues with any code at CI time instead of runtime if it's got a type. Or be more resilient to interface changes if it's just simply duck-typed. In the ScopedQuerysitMixin below we can the class can verify that a policy matches our expected type definition (duck typing) and then proceed. |
||
| """ | ||
| Scopes a queryset to the rows a user is permitted to see (OEP-66). | ||
|
|
||
| A policy must not re-implement access rules; it delegates to the platform's | ||
| authorization engine (e.g. openedx-authz) to resolve the subject's | ||
| accessible scopes and translates that answer into a queryset filter. It is | ||
| kept separate from the view so the same visibility rule can be reused and | ||
| unit-tested independently of any endpoint. | ||
| """ | ||
|
|
||
| @abstractmethod | ||
| def scope(self, queryset, user): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cosmetic: the module docstring talks about openedx-authz |
||
| """Return ``queryset`` filtered to the rows visible to ``user``.""" | ||
|
|
||
|
|
||
| class ScopedQuerysetMixin: | ||
| """ | ||
| Applies :attr:`scoping_policy` to the base queryset of a DRF view (OEP-66). | ||
|
|
||
| Mix into a ``GenericAPIView`` / ``ListAPIView`` (or a ``GenericViewSet``) | ||
| and set :attr:`scoping_policy` to a :class:`ScopingPolicy` instance. The | ||
| mixin runs the policy on top of the view's ``get_queryset()`` result so the | ||
| ``list`` response only contains rows within the user's accessible scopes. | ||
| """ | ||
|
|
||
| scoping_policy = None # a ScopingPolicy instance | ||
|
|
||
| def get_queryset(self): | ||
| """Return the base queryset scoped to the rows the request user may see.""" | ||
| queryset = super().get_queryset() | ||
| if self.scoping_policy is None: | ||
| raise ImproperlyConfigured( | ||
| f"{type(self).__name__} uses ScopedQuerysetMixin but does not set a scoping_policy" | ||
| ) | ||
| return self.scoping_policy.scope(queryset, self.request.user) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| """Tests for the OEP-66 queryset-scoping building blocks (``scoping.py``).""" | ||
| from unittest import mock | ||
|
|
||
| import pytest | ||
| from django.core.exceptions import ImproperlyConfigured | ||
| from django.test import RequestFactory, TestCase | ||
| from rest_framework.generics import GenericAPIView | ||
|
|
||
| from common.djangoapps.student.tests.factories import UserFactory | ||
| from openedx.core.lib.api.scoping import ScopedQuerysetMixin, ScopingPolicy | ||
|
|
||
|
|
||
| class _RecordingPolicy(ScopingPolicy): | ||
| """Test policy that records its ``scope`` call and returns a sentinel.""" | ||
|
|
||
| def __init__(self, returns): | ||
| self.returns = returns | ||
| self.calls = [] | ||
|
|
||
| def scope(self, queryset, user): | ||
| self.calls.append((queryset, user)) | ||
| return self.returns | ||
|
|
||
|
|
||
| class ScopingPolicyTests(TestCase): | ||
| """``ScopingPolicy`` is an abstract base requiring ``scope``.""" | ||
|
|
||
| def test_cannot_instantiate_without_scope(self): | ||
| with pytest.raises(TypeError): | ||
| ScopingPolicy() # pylint: disable=abstract-class-instantiated | ||
|
|
||
| def test_subclass_implementing_scope_is_usable(self): | ||
| policy = _RecordingPolicy(returns="scoped") | ||
| assert policy.scope("base", user=None) == "scoped" | ||
|
|
||
|
|
||
| class ScopedQuerysetMixinTests(TestCase): | ||
| """``ScopedQuerysetMixin.get_queryset`` applies the policy on top of ``super()``.""" | ||
|
|
||
| def setUp(self): | ||
| super().setUp() | ||
| self.user = UserFactory() | ||
| self.request = RequestFactory().get("/") | ||
| self.request.user = self.user | ||
|
|
||
| def _build_view(self, policy): | ||
| """Return a mixin-based view instance wired with ``policy`` and the test request.""" | ||
| test_request = self.request | ||
|
|
||
| class _View(ScopedQuerysetMixin, GenericAPIView): | ||
| scoping_policy = policy | ||
| request = test_request | ||
|
|
||
| return _View() | ||
|
|
||
| def test_applies_policy_to_super_queryset(self): | ||
| policy = _RecordingPolicy(returns="SCOPED") | ||
| view = self._build_view(policy) | ||
| with mock.patch.object(GenericAPIView, "get_queryset", return_value="BASE"): | ||
| result = view.get_queryset() | ||
| assert result == "SCOPED" | ||
| # The policy was handed the base queryset and the request user. | ||
| assert policy.calls == [("BASE", self.user)] | ||
|
|
||
| def test_missing_policy_raises_improperly_configured(self): | ||
| view = self._build_view(policy=None) | ||
| with mock.patch.object(GenericAPIView, "get_queryset", return_value="BASE"): | ||
| with pytest.raises(ImproperlyConfigured): | ||
| view.get_queryset() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the scoping policy is not doing anything, do we still need it? Can we not just comment that there admin view doesn't have any object scoping instead of adding more code here? Or do we need this in order to build the filtering on top of it. If so we should indicate that so someone doesn't try to "clean" this up later.