Skip to content
Open
Show file tree
Hide file tree
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
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.

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

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
204 changes: 204 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,204 @@
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. Since this behavior will remain while the flag is in use, collection endpoints do not need backend filtering. However, 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
********

Call an Open edX Filter from the following 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 the public ``AuthorizationDataRequested`` filter with the filter type ``org.openedx.authz.authorization_data.requested.v1`` and the following signature:

.. code-block:: python

AuthorizationDataRequested.run_filter(items, user) -> (filtered_items, errors)

``items`` contains the authorization data being processed, and ``user`` is the authenticated Django user. The filter passes both values through the configured pipeline, then returns the filtered items and the errors produced by that pipeline. The caller continues its existing response or write logic with those values. If no pipeline is configured, the filter returns the original items and an empty error list.

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 example, the endpoint may receive this request:

.. code-block:: json

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

It then returns the following response:

.. 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 the filter after request validation and before writing any assignment. The views iterate over the returned data, append the returned errors to any errors raised by the role assignment APIs, and use their existing ``207 Multi-Status`` response.

For PUT, the course-authoring pipeline removes unavailable scopes and returns one error for each rejected user and scope pair. This allows a request that contains both available and unavailable scopes to complete the available operations, as shown in the following request:

.. code-block:: json

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

The available operation succeeds, while the rejected operation appears in ``errors``:

.. 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"
}
]
}

DELETE accepts one scope, so the pipeline removes all users when that scope is unavailable and returns one error for each rejected user. For example, ``DELETE /roles/users/?role=course_staff&scope=course-v1%3AOrg1%2BHIDDEN101%2B2024&users=jane`` produces the same error entry as the PUT example and does not call the role removal API.

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

The course-authoring implementation lives in ``openedx_authz/rest_api/v1/course_authoring/pipeline.py``. A deployment enables it by registering its pipeline step under the public filter type in ``OPEN_EDX_FILTERS_CONFIG``:

.. code-block:: python

OPEN_EDX_FILTERS_CONFIG = {
"org.openedx.authz.authorization_data.requested.v1": {
"pipeline": [
"openedx_authz.rest_api.v1.course_authoring.pipeline.CourseAuthoringVisibilityFilter",
],
"fail_silently": False,
},
}

This setting is typically added to edx-platform through a Tutor plugin patch. Without this entry, ``AuthorizationDataRequested`` returns the original data and an empty error list, so the endpoints keep their default behavior.

Once configured, the pipeline reads the effective ``authz.enable_course_authoring`` state for each course scope. It leaves library scopes available, while Django staff and superusers bypass the flag check.

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

#. Shared views depend on the filter contract, while the pipeline owns the course-authoring rule.
#. PUT and DELETE reject unavailable scopes before calling the role assignment APIs.
#. ``PermissionValidationMeView`` reports an unavailable scoped permission with ``allowed`` set to ``false``.
#. Collection endpoints continue to return Casbin data, and the Admin Console filters their responses using the flag-state endpoint.
#. PUT may complete visible scope operations and report unavailable scopes in the same ``207 Multi-Status`` response.
#. Pipeline errors are part of the filter output, but their values are defined by each pipeline.
#. ``openedx-filters`` becomes a runtime dependency of this repository.
#. The pipeline can be removed with the course-authoring flag, while the public filter remains 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. Extending the backend filter to collection endpoints would add work to a temporary implementation and could change pagination and count behavior.

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