Skip to content
120 changes: 120 additions & 0 deletions docs/decisions/0016-rest-api-domain-ownership-boundary.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
0016: REST API Ownership and Package Layout

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.

Thanks for this definition, I agree with the proposal, just one question: I see that this speaks about the views, but what about the files for things like fields, paginatirs, filters, serializers, etc? Should we also specify that the accompanying code that is specific for the views owned by other parts of the platform should also be under the corresponding subfolders?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes! It'd make sense to extend the standard to other modules as well. I think we'd benefit from the separation of concerns when consuming those modules for extensibility, let's say.

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.

Hi @mariajgrimaldi, was this finally specified in the ADR? That it's not just the views but also the related code? Could we show it in the folder structure example?

############################################

Status
******

**Draft**

Context
*******

This ADR applies domain-driven design to make the responsibility and placement of REST API code easier to decide in future work. The boundary keeps application-specific rules out of reusable authorization endpoints, gives reviewers a consistent way to place new code, and makes temporary integrations easier to find and remove later.

``openedx_authz.rest_api`` currently keeps all API views in one ``views.py`` module. Some endpoints provide authorization data that several applications can use, some have request and response formats made for the Admin Console, and one exposes a course-authoring flag.

`PR #361`_ explored adding course-authoring flag checks to reusable authorization endpoints. That work raised two related questions. We need to know which concerns belong to authorization, and we need a package layout that makes those boundaries visible in the code.

The `edX DDD Bounded Contexts`_ documentation supports separating code by responsibility. `ADR 0018 in openedx-events`_ describes authorization as a supporting part of the system and explains that an admin interface can combine work from several areas without owning all of those responsibilities.

We reviewed the ten endpoints in ``openedx_authz.rest_api.v1``. Seven query or manage authorization data:

* ``PermissionValidationMeView``
* ``RoleUserAPIView``
* ``RoleListView``
* ``ScopesAPIView``
* ``TeamMembersAPIView``
* ``TeamMemberAssignmentsAPIView``
* ``AssignmentsAPIView``

``WaffleFlagStatesAPIView`` reads and returns a course-authoring flag. It is a temporary exception in this repository because the data is not authorization data. `ADR 0015`_ records why the endpoint exists, while the formal ownership of the flag remains open.

The ownership of ``UserValidationAPIView`` and ``AdminConsoleOrgsAPIView`` also remains open. We do not need to resolve those questions before separating authorization data from course-authoring data or placing the current endpoints.

Ownership and placement answer different questions. Ownership describes what an endpoint is responsible for and where its data comes from, while placement describes where its code belongs in this repository. For example, an assignment endpoint can return a username and a course scope, but its purpose is to query role assignments from Casbin, the authorization data store. Authorization therefore owns it. The Admin Console may use that endpoint, but it does not become the owner of the assignment data.

Decision
********

1. Authorization owns an endpoint when its main purpose is to query or manage authorization roles, permissions, assignments, or scopes.
2. An authorization endpoint that serves several applications must expose the same authorization behavior to all of them. Its code must not contain course-authoring rules or read course-authoring data directly.
3. A reusable authorization endpoint may call a general hook before returning its data. A separate implementation can then apply a rule based on data outside authorization without adding that rule to the endpoint itself. `ADR 0017 (authorization result extension)`_ defines this mechanism for course-authoring visibility.
4. Place code according to these rules:

* Keep a reusable authorization endpoint in ``rest_api/v1/views.py``.
* Put an authorization endpoint made for one application in a package named after that application. The Admin Console endpoints therefore belong in ``admin_console/``.
* Put a temporary endpoint that exposes data from another area in a package named after that area. ``WaffleFlagStatesAPIView`` therefore belongs in ``course_authoring/``, even though the Admin Console uses it.
* When the last two rules both appear to apply, the data exposed by the endpoint determines its placement. This keeps exceptions to the authorization boundary visible.

5. Keep supporting code with the package that uses it. If more than one package uses the code, place it in their closest common parent directory. For example, code shared by ``admin_console/`` and ``course_authoring/`` belongs in ``rest_api/v1/``.
6. Move these five Admin Console endpoints to ``openedx_authz/rest_api/v1/admin_console/``:

* ``AdminConsoleOrgsAPIView``
* ``ScopesAPIView``
* ``TeamMembersAPIView``
* ``TeamMemberAssignmentsAPIView``
* ``AssignmentsAPIView``

``AdminConsoleOrgsAPIView`` moves with this group because its API is made for the Admin Console. This placement does not settle who owns its organization data.

7. Keep ``PermissionValidationMeView``, ``RoleUserAPIView``, ``RoleListView``, and ``UserValidationAPIView`` in ``openedx_authz/rest_api/v1/views.py``.
8. Move ``WaffleFlagStatesAPIView`` to ``openedx_authz/rest_api/v1/course_authoring/``. The package name describes the data that the endpoint exposes without settling who formally owns the flag.

The proposed layout is shown below.

.. code-block:: text

openedx_authz/rest_api/v1/
views.py # Reusable authorization endpoints
admin_console/
views.py # APIs made for Admin Console workflows
course_authoring/
views.py # WaffleFlagStatesAPIView

Consequences
************

1. Reviewers can place future endpoints by checking what they do, which data they read, and whether their APIs are reusable or made for one application.
2. Rules based on data outside authorization, such as the course-authoring flag, remain separate from reusable endpoint code. A new rule must use a general hook or a later ADR must change this boundary.
3. Application-specific serializers, filters, and views can change without adding those details to the reusable API modules.
4. Temporary integrations have a named package, which makes their code and dependencies easier to find when the integration changes or is removed.
5. The endpoint moves will not change their URLs, so clients will not need endpoint URL changes.
6. ``WaffleFlagStatesAPIView`` will remain a named exception until `Issue #377`_ removes it.
7. The ownership of ``UserValidationAPIView`` and ``AdminConsoleOrgsAPIView`` will remain open.

Rejected Alternatives
*********************

**Keeping all views in one module**
The module would continue to hide the difference between reusable authorization APIs, Admin Console-specific APIs, and the temporary course-authoring endpoint.

**Deciding placement separately for each endpoint**
Similar endpoints could then follow different placement rules, and reviewers would have no shared test for new code.

**Grouping every endpoint only by the application that uses it**
This would place ``WaffleFlagStatesAPIView`` under ``admin_console/`` and hide that it exposes course-authoring data as an exception to the authorization boundary.

**Moving the five Admin Console endpoints to another repository or application**
Four of these endpoints query authorization roles, assignments, or scopes. Their Admin Console-specific APIs justify a separate package, but the authorization code still belongs in this repository. The ownership of ``AdminConsoleOrgsAPIView`` remains open.

**Adding application-specific visibility rules to reusable endpoint code**
This would make reusable authorization code interpret data that authorization does not own. A general hook keeps the rule in a separate implementation, and `openedx_catalog`_ follows a related approach for installation-specific visibility rules.

References
**********

* `edX DDD Bounded Contexts`_
* `ADR 0018 in openedx-events`_
* `ADR 0015`_
* `ADR 0017 (authorization result extension)`_
* `Issue #377`_
* `PR #361`_
* `openedx_catalog`_

.. _edX DDD Bounded Contexts: https://openedx.atlassian.net/wiki/spaces/AC/pages/663224968/edX+DDD+Bounded+Contexts
.. _ADR 0018 in openedx-events: https://github.com/openedx/openedx-events/blob/main/docs/decisions/0018-supporting-subdomain-modules.rst
.. _ADR 0015: 0015-expose-course-authoring-waffle-flag-state-via-rest-api.rst
.. _ADR 0017 (authorization result extension): 0017-cross-domain-filtering-via-openedx-filters.rst
.. _Issue #377: https://github.com/openedx/openedx-authz/issues/377
.. _PR #361: https://github.com/openedx/openedx-authz/pull/361
.. _openedx_catalog: https://github.com/openedx/openedx-core/blob/main/src/openedx_catalog/api.py
245 changes: 245 additions & 0 deletions docs/decisions/0017-cross-domain-filtering-via-openedx-filters.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
0017: Cross-Domain Filtering via Open edX Filters
##################################################

Status
******

**Proposed** - *2026-07-31*

Context
*******

Casbin assignments may remain after ``authz.enable_course_authoring`` is disabled because the migration that synchronizes them is optional and does not cover every flag change (`ADR 0013`_). As a result, permission checks and role assignment requests may refer to a course that is no longer available in the authoring experience.

The Admin Console reads the flag state exposed in `ADR 0015`_ and filters course-authoring data before displaying it. This proposal leaves collection filtering in the Admin Console. Frontend filtering cannot protect role assignment writes or prevent permission validation from reporting an unavailable course as allowed.

These operations therefore need a backend extension point. Open edX Filters allows the views to expose authorization data to a separately configured pipeline, which keeps course-authoring state outside the shared authorization code and follows the boundary defined in `ADR 0016`_.

Decision
********

Add three operation-specific filters to the REST endpoints. The views pass data through their configured pipelines and continue with the returned data and errors. The course-authoring pipelines own visibility checks and rejection behavior.

The filters cover these operations:

* ``POST /validate/me/`` validates a user's permission in a scope.
* ``PUT /roles/users/`` assigns a role to users in one or more scopes.
* ``DELETE /roles/users/`` removes a role from users in a scope.

1. Filter contract
==================

Define one public filter for each operation:

* ``PermissionValidationRequested`` uses ``org.openedx.authz.permission_validation.requested.v1`` and receives computed permission results with ``action``, ``allowed``, and an optional ``scope``.
* ``RoleAssignmentRequested`` uses ``org.openedx.authz.role_assignment.requested.v1`` and receives validated ``role``, ``users``, and ``scopes`` before assignment writes.
* ``RoleRemovalRequested`` uses ``org.openedx.authz.role_removal.requested.v1`` and receives validated ``role``, ``users``, and ``scope`` before removal writes.

Each filter exposes the same calling convention with its own payload type:

.. code-block:: python

PermissionValidationRequested.run_filter(items) -> (filtered_items, errors)
RoleAssignmentRequested.run_filter(items) -> (filtered_items, errors)
RoleRemovalRequested.run_filter(items) -> (filtered_items, errors)

Each filter passes its data through its independently configured pipeline. With no pipeline configured for that filter, it returns the original items and an empty error list.

Each filter has a defined input shape and can be configured independently.

The pipeline starts with an empty error list. Each step preserves errors from previous steps and appends its own.

The public contract leaves rejection rules to each pipeline, which decides which items to keep and which errors to return. For example, a pipeline may receive validated role assignment data for two scopes, retain the available scope, and return an error for the rejected operation:

.. code-block:: python

items = {
"role": "course_staff",
"scopes": [
"course-v1:Org1+VISIBLE101+2024",
"course-v1:Org1+HIDDEN101+2024",
],
"users": ["jane"],
}

filtered_items = {
"role": "course_staff",
"scopes": ["course-v1:Org1+VISIBLE101+2024"],
"users": ["jane"],
}

errors = [
{
"user_identifier": "jane",
"scope": "course-v1:Org1+HIDDEN101+2024",
"error": "scope_not_available",
}
]

The view writes only the operations in ``filtered_items`` and returns ``errors`` together with any errors raised during those writes. In this example, the course-authoring pipeline defines ``scope_not_available`` because the public filter does not define error values.

2. Permission validation
========================

``PermissionValidationMeView`` calls the filter after computing the permission results and before serializing the response. Because clients expect one result for every requested permission, the course-authoring pipeline keeps the item and changes ``allowed`` to ``False`` when its course scope is unavailable. An unscoped request remains unchanged because it does not provide a course for the pipeline to check.

For a user subject to visibility filtering, a request to ``POST /validate/me/`` with an unavailable course scope:

.. code-block:: json

[
{
"action": "courses.view_course",
"scope": "course-v1:Org1+HIDDEN101+2024"
}
]

returns:

.. code-block:: json

[
{
"action": "courses.view_course",
"scope": "course-v1:Org1+HIDDEN101+2024",
"allowed": false
}
]

3. Role assignment writes
==========================

``RoleUserAPIView.put`` and ``RoleUserAPIView.delete`` call their filters after request validation and before writing any assignment. The views process the returned data and combine pipeline errors with errors from the role assignment APIs in their existing ``207 Multi-Status`` response.

For PUT, the course-authoring pipeline excludes unavailable scopes and returns one error for each rejected user and scope pair, as shown in the contract example. The view can still assign roles in the remaining scopes.

For example, ``PUT /roles/users/`` receives:

.. code-block:: json

{
"role": "course_staff",
"scopes": [
"course-v1:Org1+VISIBLE101+2024",
"course-v1:Org1+HIDDEN101+2024"
],
"users": ["jane"]
}

If visibility filtering applies and the assignment in the available scope succeeds, the ``207 Multi-Status`` response is:

.. code-block:: json

{
"completed": [
{
"user_identifier": "jane",
"scope": "course-v1:Org1+VISIBLE101+2024",
"status": "role_added"
}
],
"errors": [
{
"user_identifier": "jane",
"scope": "course-v1:Org1+HIDDEN101+2024",
"error": "scope_not_available"
}
]
}

For DELETE, the pipeline returns an empty ``users`` list when the scope is unavailable and an error for each requested user, so the view performs no removals.

For example, ``DELETE /roles/users/?role=course_staff&scope=course-v1%3AOrg1%2BHIDDEN101%2B2024&users=jane`` returns the following ``207 Multi-Status`` response when visibility filtering applies:

.. code-block:: json

{
"completed": [],
"errors": [
{
"user_identifier": "jane",
"scope": "course-v1:Org1+HIDDEN101+2024",
"error": "scope_not_available"
}
]
}

4. Course-authoring pipeline
============================

The course-authoring implementation lives in ``openedx_authz/rest_api/v1/course_authoring/pipeline.py``. Each operation has a separate pipeline step; the steps share visibility checks and error handling.

A deployment enables each operation independently in ``OPEN_EDX_FILTERS_CONFIG``:

.. code-block:: python

OPEN_EDX_FILTERS_CONFIG = {
"org.openedx.authz.permission_validation.requested.v1": {
"pipeline": [
"openedx_authz.rest_api.v1.course_authoring.pipeline.CourseAuthoringPermissionValidationFilter",
],
"fail_silently": False,
},
"org.openedx.authz.role_assignment.requested.v1": {
"pipeline": [
"openedx_authz.rest_api.v1.course_authoring.pipeline.CourseAuthoringRoleAssignmentFilter",
],
"fail_silently": False,
},
"org.openedx.authz.role_removal.requested.v1": {
"pipeline": [
"openedx_authz.rest_api.v1.course_authoring.pipeline.CourseAuthoringRoleRemovalFilter",
],
"fail_silently": False,
},
}

This setting is typically added to edx-platform through a Tutor plugin patch. An operation without a configured pipeline retains its default behavior.

Once configured, the pipeline reads the effective ``authz.enable_course_authoring`` state for each course scope and leaves library scopes available.

Consequences
************

* Deployments must configure three filters to apply visibility rules to all three operations. Each operation can also be configured independently.
* Permission filtering happens after authorization checks, so it does not avoid the work of computing results that the pipeline later denies. Role changes are filtered before writes.
* ``openedx-filters`` becomes a runtime dependency of this repository.
* The course-authoring implementation can be removed with the flag while the public filters remain available for other authorization rules.

Alternatives Considered
***********************

Check the flag in each view
===========================

This would add course-authoring dependencies to shared authorization views and repeat the same check across the protected operations.

Protect writes in the frontend
==============================

Frontend checks control the Admin Console, but stale clients and direct API requests can still reach the write endpoints.

Filter collection responses in the API
======================================

The Admin Console already filters these responses using the exposed flag states. Backend collection filtering would also need to account for pagination and counts. This proposal is limited to permission validation and role changes.

Return a flag-specific response from the view
=============================================

``RoleUserAPIView`` already reports errors for each operation through ``207 Multi-Status``. The pipeline can use that response and keep flag-specific decisions out of the view.

References
**********

* `ADR 0013`_
* `ADR 0015`_
* `ADR 0016`_
* `Issue #363`_
* `PR #361`_

.. _ADR 0013: 0013-course-authoring-automatic-migration.rst
.. _ADR 0015: 0015-expose-course-authoring-waffle-flag-state-via-rest-api.rst
.. _ADR 0016: 0016-rest-api-domain-ownership-boundary.rst
.. _Issue #363: https://github.com/openedx/openedx-authz/issues/363
.. _PR #361: https://github.com/openedx/openedx-authz/pull/361
Loading