Skip to content
Draft
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
193 changes: 193 additions & 0 deletions docs/decisions/0015-course-authoring-flag-visibility-in-rest-api.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
0015: Course Authoring Flag Visibility in the REST API
#######################################################

Status
******

**Draft**

Context
*******

``authz.enable_course_authoring`` is a three-tier flag (`ADR 0010`_), where a course override wins over an org override, which in turn wins over the platform default.

A migration is supposed to keep Casbin's role assignments in sync with this flag (`ADR 0011`_, `ADR 0013`_), but in practice it often doesn't, since it's off by default and never runs at all for platform-wide flag changes. That means Casbin can go on holding course data for a course whose flag is off, for as long as nobody runs the migration by hand.

Because of that lag, only the flag itself can answer whether course authoring is on for a given course. Casbin's contents cannot. This is exactly how openedx-platform's own code already treats it, checking the flag directly before it ever touches Casbin.

The flag never governs libraries either, since it only applies to courses; library access keeps using its own separate legacy path, unaffected by it.

The rule this ADR needs to enforce, that course authoring content is visible only when the user has permission and the flag resolves to on, is captured in this truth table, where "None" means no override exists for that tier:

.. list-table::
:header-rows: 1

* - Platform Flag
- Org Override
- Course Override
- Effective Authoring State
- User Has Permission?
- Show Authoring Roles?
* - Off
- None
- None
- Off
- No
- No
* - Off
- None
- None
- Off
- Yes
- No
* - On
- None
- None
- On
- No
- No
* - On
- None
- None
- On
- Yes
- Yes
* - Off
- Force On
- None
- On
- No
- No
* - Off
- Force On
- None
- On
- Yes
- Yes
* - On
- Force On
- None
- On
- No
- No
* - On
- Force On
- None
- On
- Yes
- Yes
* - Off
- Force Off
- None
- Off
- No
- No
* - Off
- Force Off
- None
- Off
- Yes
- No
* - On
- Force Off
- None
- Off
- No
- No
* - On
- Force Off
- None
- Off
- Yes
- No
* - Off
- None
- Force On
- On
- No
- No
* - Off
- None
- Force On
- On
- Yes
- Yes
* - On
- None
- Force On
- On
- No
- No
* - On
- None
- Force On
- On
- Yes
- Yes
* - Off
- None
- Force Off
- Off
- No
- No
* - Off
- None
- Force Off
- Off
- Yes
- No
* - On
- None
- Force Off
- Off
- No
- No
* - On
- None
- Force Off
- Off
- Yes
- No

This exact cascade is already implemented by ``enable_authz_course_authoring(course_key)`` in ``common.djangoapps.student.roles``, so nothing new needs to be built for it. Only the direct check for org-overrides needs to be added, since that function only accepts a course key and has no public API for checking an org alone.

Decision
********

1. Course-scoped REST API endpoints became flag-aware, starting with ``PermissionValidationMeView``, ahead of and independent from the separate effort to reduce this app's openedx-platform dependencies (`issue #360`_).
2. Staff and superusers get no bypass for flag visibility. The flag's effective state applies the same way to every user.
3. Every permission check backed by Casbin data should be short-circuited by the flag's effective state for that scope, so a stale Casbin grant never surfaces flag-disabled content.
4. Make openedx-platform a temporary dependency of this repo, so that the flag's effective state can be read directly from the same models that openedx-platform itself uses. This is a stopgap until the app is fully decoupled from openedx-platform.

This is implemented by ``is_scope_visible``/``has_visible_scope`` in ``openedx_authz.rest_api.utils``, and applied so far only to ``PermissionValidationMeView``. ``AssignmentsAPIView``, ``RoleUserAPIView``, ``TeamMembersAPIView``, ``TeamMemberAssignmentsAPIView``, ``RoleListView``, and ``ScopesAPIView`` read the same kind of Casbin data and carry the same staleness risk, but wiring them up is follow-up work, not part of this change.

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

1. **One place defines flag visibility**, reused by every call site instead of each view re-deriving it or trusting stale Casbin data.
2. **REST API behavior now matches openedx-platform's own enforcement points**, which already treat the flag as the source of truth.
3. **No staff/superuser bypass.** Once an endpoint is wired up, a flag-disabled course stays hidden or denied for every user, regardless of role.
4. **Wired-up endpoints stop returning whatever Casbin holds.** A course-scoped result can now be hidden or denied even though Casbin still has a matching row, whenever the flag is off and a rollback migration hasn't run yet. The REST API and Django Admin's migration status can visibly disagree during that window; this should be called out in user-facing docs about the flag.
5. **Per-row checks cost up to one flag resolution per distinct course/org in a response.** ``enable_authz_course_authoring`` reads models that are ``@request_cached()`` in openedx-platform, so repeat calls for the same course/org within one request are cheap, but a listing spanning N courses across M orgs still costs up to N + M + 1 lookups.
6. **These views depend on** ``enable_authz_course_authoring`` **and** ``WaffleFlagOrgOverrideModel``, guarded by the same standalone-import pattern already used in ``handlers.py`` for ``CourseAccessRole``, so the app keeps loading outside openedx-platform. There's no fail-open fallback: this repo runs as an openedx-platform plugin, so these imports are always available at runtime.

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

**Gating each endpoint as a whole (e.g. 404 for the entire endpoint when the flag is off)**: Every one of these endpoints legitimately continues to serve library data regardless of this flag's state. An endpoint-level gate would hide that data too.

**Relying on Casbin data presence as a proxy for flag state**: ADR 0013 establishes that Casbin content and the flag's effective state can diverge by design (opt-in automatic migration, never triggered for global flag changes). Therefore, treating "the row exists in Casbin" as equivalent to "the flag is on" is wrong and would break the truth table above.

References
**********

* `ADR 0010`_
* `ADR 0011`_
* `ADR 0013`_
* `issue #360`_

.. _ADR 0010: 0010-course-authoring-flag.rst
.. _ADR 0011: 0011-course-authoring-migration-process.rst
.. _ADR 0013: 0013-course-authoring-automatic-migration.rst
.. _0013: 0013-course-authoring-automatic-migration.rst
.. _issue #360: https://github.com/openedx/openedx-authz/issues/360
28 changes: 28 additions & 0 deletions openedx_authz/api/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"unassign_all_roles_from_user",
"validate_users",
"get_superadmin_assignments",
"is_user_allowed_in_scope",
]


Expand Down Expand Up @@ -436,6 +437,33 @@ def is_user_allowed_in_any_scope(
return bool(get_scopes_for_user_and_permission(user_external_key, action_external_key))


def is_user_allowed_in_scope(
user_external_key: str,
action_external_key: str,
scope_external_key: str = None,
) -> bool:
"""Check if a user has a specific permission in a given scope or in any scope if none is provided.

Staff and superusers are always allowed, since they implicitly have every
permission across all scopes.

Args:
user_external_key (str): ID of the user (e.g., 'john_doe').
action_external_key (str): The action to check (e.g., 'view_course').
scope_external_key (str, optional): The scope in which to check the permission.
If None, checks if the user has the permission in any scope.

Returns:
bool: True if the user is staff/superuser or has the specified permission
in the given scope (or any scope if none is provided), False otherwise.
"""
if is_user_staff_or_superuser(user_external_key):
return True
if scope_external_key:
return is_user_allowed(user_external_key, action_external_key, scope_external_key)
return is_user_allowed_in_any_scope(user_external_key, action_external_key)


def get_users_for_role_in_scope(role_external_key: str, scope_external_key: str) -> list[UserData]:
"""Get all the users assigned to a specific role in a specific scope.

Expand Down
71 changes: 71 additions & 0 deletions openedx_authz/rest_api/utils.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"""Utility functions for the Open edX AuthZ REST API."""

from openedx_authz import api
from openedx_authz.api.data import (
GLOBAL_SCOPE_WILDCARD,
ScopeData,
)
from openedx_authz.api.users import get_scopes_for_user_and_permission
from openedx_authz.rest_api.data import (
AssignmentSortField,
BaseEnum,
Expand All @@ -13,6 +15,19 @@
UserAssignmentSortField,
)

try:
# common.djangoapps.student.roles and openedx.core are edx-platform's own modules. This app
# is an edx-platform plugin, so they're always available at runtime; the imports are only
# guarded so this module can still load under this repo's own standalone test suite
# (openedx_authz.settings.test, no edx-platform installed).
from common.djangoapps.student.roles import enable_authz_course_authoring
from openedx.core.djangoapps.waffle_utils.models import WaffleFlagOrgOverrideModel
from openedx.core.toggles import AUTHZ_COURSE_AUTHORING_FLAG
except ImportError:
enable_authz_course_authoring = None
WaffleFlagOrgOverrideModel = None
AUTHZ_COURSE_AUTHORING_FLAG = None


def get_generic_scope(scope: ScopeData) -> ScopeData:
"""
Expand Down Expand Up @@ -181,3 +196,59 @@ def sort_user_assignments(
list[dict]: The sorted assignments.
"""
return _sort_by_field(assignments, sort_by, order, UserAssignmentSortField)


def is_scope_visible(scope: api.ScopeData) -> bool:
"""Return whether a scope is visible under the course-authoring flag.

See ``docs/decisions/0015-course-authoring-flag-visibility-in-rest-api.rst``
for the reasoning: Casbin data cannot be trusted as a proxy for
``authz.enable_course_authoring``'s effective state, since the migration
that is supposed to keep Casbin in sync with the flag is opt-in, off by
default, and never runs for platform-wide flag changes. Only the flag
itself, checked directly, can answer whether a scope is visible.

- Library and other non-course scopes (e.g. 'lib:DemoX:CSPROB'): always visible.
- Concrete course (e.g. 'course-v1:DemoX+CS101+2024'): full course/org/platform
cascade via ``enable_authz_course_authoring(course_key)``.
- Org-level course glob (e.g. 'course-v1:DemoX+*'): org override, else platform default.
- Platform-level course glob ('course-v1:*'): platform tier only, no course or org.

Args:
scope (ScopeData): A resolved scope instance.

Returns:
bool: True if the scope should count as visible.
"""
if scope.NAMESPACE != api.CourseOverviewData.NAMESPACE:
return True
if isinstance(scope, api.CourseOverviewData):
return enable_authz_course_authoring(scope.course_key)
if isinstance(scope, api.OrgCourseOverviewGlobData):
# enable_authz_course_authoring only accepts a course key, and there's no public
# edx-platform API to check an org alone, so this checks the org override directly
# (see issue #360 for follow-up) when asked to check an org-level course glob
org_override = WaffleFlagOrgOverrideModel.override_value(AUTHZ_COURSE_AUTHORING_FLAG.name, scope.org)
if org_override == WaffleFlagOrgOverrideModel.ALL_CHOICES.on:
return True
if org_override == WaffleFlagOrgOverrideModel.ALL_CHOICES.off:
return False
return enable_authz_course_authoring()


def has_visible_scope(username: str, action: str, scope_value: str | None) -> bool:
"""Return whether the user has a course-authoring-visible scope for this action.

Args:
username (str): The user checking the action.
action (str): The action being validated.
scope_value (str | None): The external key of the scope being
validated, or None to check across any scope the user has the
action in.

Returns:
bool: True if the user has a visible scope for this action, False otherwise.
"""
if scope_value:
return is_scope_visible(api.ScopeData(external_key=scope_value))
return any(is_scope_visible(scope) for scope in get_scopes_for_user_and_permission(username, action))
15 changes: 7 additions & 8 deletions openedx_authz/rest_api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from openedx_authz.rest_api.utils import (
filter_users,
get_generic_scope,
has_visible_scope,
sort_users,
)
from openedx_authz.rest_api.v1.filters import (
Expand Down Expand Up @@ -148,8 +149,8 @@ class PermissionValidationMeView(APIView):
**Example Response (without scope)**::

[
{"action": "content_libraries.manage_library_team", "allowed": true},
{"action": "courses.manage_course_team", "allowed": false}
{"action": "content_libraries.manage_library_team", "allowed": true, "scope": null},
{"action": "courses.manage_course_team", "allowed": false, "scope": null}
]
"""

Expand All @@ -174,12 +175,10 @@ def post(self, request: HttpRequest) -> Response:
try:
action = permission["action"]
scope = permission.get("scope")
if scope:
allowed = api.is_user_allowed(username, action, scope)
response_data.append({"action": action, "scope": scope, "allowed": allowed})
else:
allowed = api.is_user_allowed_in_any_scope(username, action)
response_data.append({"action": action, "allowed": allowed})
allowed = api.is_user_allowed_in_scope(username, action, scope) and has_visible_scope(
username, action, scope
)
response_data.append({"action": action, "scope": scope, "allowed": allowed})
except ValueError as e:
logger.error(f"Error validating permission for user {username}: {e}")
return Response(data={"message": "Invalid scope format"}, status=status.HTTP_400_BAD_REQUEST)
Expand Down
Loading
Loading