Skip to content

[FC-0118] docs: add ADR for introducing a row-level security layer - #38354

Closed
Abdul-Muqadim-Arbisoft wants to merge 3 commits into
openedx:docs/ADRs-axim_api_improvementsfrom
edly-io:docs/ADR-introduce-row-level-security-layer
Closed

[FC-0118] docs: add ADR for introducing a row-level security layer#38354
Abdul-Muqadim-Arbisoft wants to merge 3 commits into
openedx:docs/ADRs-axim_api_improvementsfrom
edly-io:docs/ADR-introduce-row-level-security-layer

Conversation

@Abdul-Muqadim-Arbisoft

Copy link
Copy Markdown
Contributor

Adds ADR proposing a reusable row-level security layer to standardize how Open edX list endpoints handle record visibility, replacing the current ad-hoc pattern of inline has_access checks mixed with endpoint guards and user-driven filtering.
Currently, ~150 has_access call sites and helpers like has_studio_read_access(), has_course_author_access(), and get_course_with_access() are applied inline across views in contentstore, enrollments, discussion, and course blocks APIs, tangling three distinct concerns into single blocks of view logic. This makes access behavior inconsistent across endpoints, difficult to audit, and hard to test in isolation. This ADR documents the decision to separate every list endpoint into three layers, endpoint access via DRF permission classes, record visibility via RLS-scoped querysets in get_queryset(), and user-driven filtering via django-filter, backed by a shared RLSQuerysetMixin and pluggable RLSPolicy interface. The rollout targets high-impact ORM-backed endpoints first (course listing, enrollment, certificates) with in-memory filtering fallback for modulestore-backed endpoints like Course Blocks. This ADR is complementary to the existing bridgekeeper ADR (0003) , it defines the architectural pattern that any permission engine plugs into, rather than prescribing a specific engine.
issue:#38282

@bradenmacdonald bradenmacdonald left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be an update to OEP-66 instead of an ADR?

How does this integrate with Casbin?

When the user requests a single object from a GET endpoint, does this still use a filter that could involve complicated/slow logic to construct, or does it just query the permissions of that one single object? i.e. is getting a single object more like "list all the courses the user is allowed to see and then select one" or is it like "check if the user is allowed to see course X".

Introduce an architectural decision record that standardizes how list endpoints separate three concerns: endpoint access (DRF permission classes), record visibility (RLS queryset scoping), and user-driven filtering (django-filter).

This complements the existing bridgekeeper ADR (0003) by defining the view-layer pattern that any permission engine plugs into, rather than prescribing a specific engine.

Includes code examples using CourseOverview, a reusable RLSQuerysetMixin, and a rollout plan targeting course listing, enrollment, and certificate endpoints first.
@Abdul-Muqadim-Arbisoft
Abdul-Muqadim-Arbisoft force-pushed the docs/ADR-introduce-row-level-security-layer branch from 985f725 to 4ea6c8f Compare April 26, 2026 08:53
@Abdul-Muqadim-Arbisoft

Abdul-Muqadim-Arbisoft commented Apr 28, 2026

Copy link
Copy Markdown
Contributor Author

Should this be an update to OEP-66 instead of an ADR?

How does this integrate with Casbin?

When the user requests a single object from a GET endpoint, does this still use a filter that could involve complicated/slow logic to construct, or does it just query the permissions of that one single object? i.e. is getting a single object more like "list all the courses the user is allowed to see and then select one" or is it like "check if the user is allowed to see course X".
You're right. OEP-66's "Django REST Framework" section already covers this same territory (filter backends with permission-aware querysets), so what I'm proposing reads as an extension of that, not a separate decision. ADR-0003 sits in the repo because it's an engine choice; this is wider than that.

What we can do is taht take the pattern part (three-layer separation, RLSQuerysetMixin, RLSPolicy interface) into an OEP-66 update that extends the DRF section, and keep a slim ADR here just for the rollout bits, the ~150 has_access call sites, migration ordering, the modulestore fallback, lint/CI guidance. If you'd rather skip the repo ADR entirely and put it all into one OEP-66 PR, I'm equally fine with that. Let me know which way and I'll restructure. Going to hold off on the other two revisions until this is settled.

@bradenmacdonald

Copy link
Copy Markdown
Contributor

I don't have a strong opinion on how you structure it; I'm just trying to understand how all the pieces fit together.

``lms.djangoapps.courseware.access.has_access`` to filter blocks by user after retrieval rather
than scoping the data source upfront.

Relationship to ADR-0003 (Bridgekeeper)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also add a section with respect to https://github.com/openedx/openedx-authz/tree/main/docs/decisions

That repo is the future of AuthZ in the openedx-platform so we should talk about how we expect this to work with that.

def get_queryset(self):
# RLS layer: scope the queryset to rows this user may see.
qs = CourseOverview.objects.all()
return rls_scope_courses(qs, self.request.user)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where does this function get defined, the example seems incomplete.


* **Endpoint access** — DRF permission classes (``permission_classes``). Answers: "Can this
user call this endpoint at all?"
* **Record visibility** — RLS policy applied in ``get_queryset()``. Answers: "Which rows is

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Within the get_queryset(), how will permissions be checked? How will this work with the new permission framework?

@Faraz32123
Faraz32123 force-pushed the docs/ADRs-axim_api_improvements branch 2 times, most recently from adf2e7b to ad4b7ba Compare June 10, 2026 10:51
@Abdul-Muqadim-Arbisoft

Copy link
Copy Markdown
Contributor Author

hi @feanil @bradenmacdonald, thanks for the review and the questions on this.

Based on Braden's first point, we agree this fits better as an update to OEP-66 rather than a standalone ADR. So we're going to close this PR and open a new one against OEP-66 with the same proposal, and we'll address the review comments raised here in that PR
We'll link the new OEP-66 PR here once it's up.

@feanil

feanil commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Gotcha, I think I misunderstood what you were saying in the meeting earlier. I'm good with closing this and opening the upstream OEP PR instead.

@Abdul-Muqadim-Arbisoft

Copy link
Copy Markdown
Contributor Author

Closing this PR in favour of openedx/openedx-proposals#802, since it's been moved to OEP-66.

Faraz32123 pushed a commit to openedx/openedx-proposals that referenced this pull request Jun 30, 2026
…t endpoints (#802)

* docs: extend OEP-66 with row-level security pattern for list endpoints

Adds a Django REST Framework subsection to OEP-66 covering the
separation of three authorization concerns in list endpoints: endpoint
access (permission_classes), record visibility (an RLS policy in
get_queryset), and user-driven filtering (django-filter). Record
visibility delegates to openedx-authz via get_scopes_for_subject_and_permission
rather than per-row enforcement, and documents the list vs. single-object
access paths and the modulestore fallback.

Supersedes the standalone ADR proposed in openedx/openedx-platform#38354,
moving the architectural pattern into OEP-66 per review feedback.

* docs: link OEP-66 row-level security change history entry to PR #802

* docs: reframe OEP-66 pattern as queryset scoping and use CourseRun examples

Addresses review on PR #802:

- Rename "row-level security (RLS)" to "queryset scoping" throughout. The
  pattern is application-layer queryset filtering, not database-enforced RLS,
  and is bypassable via direct ORM access. Rename the
  ScopingPolicy/ScopedQuerysetMixin/scoping_policy abstractions, add a note
  contrasting the two, and link the PostgreSQL RLS docs.
- Switch examples from CourseOverview to CourseRun. CourseRun has an internal
  integer pk, so the single-course scope matches on course_key, and the org
  scope joins through catalog_course__org__short_name since CourseRun has no
  org column.

---------

Co-authored-by: Abdul Muqadim <abdul.muqadim@A006-01811.local>
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.

5 participants