Skip to content

Commit 22a1f92

Browse files
dwong2708wgu-taylor-payne
authored andcommitted
feat: introduce Authz Django app
- Add decorator to enforce AuthZ course permissions - Add ADR documenting the new Authz Django app
1 parent ffbedba commit 22a1f92

14 files changed

Lines changed: 566 additions & 1 deletion

File tree

.github/workflows/pylint-checks.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ jobs:
2121
- module-name: openedx-1
2222
path: "openedx/core/types/ openedx/core/djangoapps/ace_common/ openedx/core/djangoapps/agreements/ openedx/core/djangoapps/api_admin/ openedx/core/djangoapps/auth_exchange/ openedx/core/djangoapps/bookmarks/ openedx/core/djangoapps/cache_toolbox/ openedx/core/djangoapps/catalog/ openedx/core/djangoapps/ccxcon/ openedx/core/djangoapps/commerce/ openedx/core/djangoapps/common_initialization/ openedx/core/djangoapps/common_views/ openedx/core/djangoapps/config_model_utils/ openedx/core/djangoapps/content/ openedx/core/djangoapps/content_libraries/ openedx/core/djangoapps/content_staging/ openedx/core/djangoapps/contentserver/ openedx/core/djangoapps/cookie_metadata/ openedx/core/djangoapps/cors_csrf/ openedx/core/djangoapps/course_apps/ openedx/core/djangoapps/course_date_signals/ openedx/core/djangoapps/course_groups/ openedx/core/djangoapps/courseware_api/ openedx/core/djangoapps/crawlers/ openedx/core/djangoapps/credentials/ openedx/core/djangoapps/credit/ openedx/core/djangoapps/dark_lang/ openedx/core/djangoapps/debug/ openedx/core/djangoapps/discussions/ openedx/core/djangoapps/django_comment_common/ openedx/core/djangoapps/embargo/ openedx/core/djangoapps/enrollments/ openedx/core/djangoapps/external_user_ids/ openedx/core/djangoapps/zendesk_proxy/ openedx/core/djangolib/ openedx/core/lib/ openedx/core/djangoapps/course_live/"
2323
- module-name: openedx-2
24-
path: "openedx/core/djangoapps/geoinfo/ openedx/core/djangoapps/header_control/ openedx/core/djangoapps/heartbeat/ openedx/core/djangoapps/lang_pref/ openedx/core/djangoapps/models/ openedx/core/djangoapps/monkey_patch/ openedx/core/djangoapps/oauth_dispatch/ openedx/core/djangoapps/olx_rest_api/ openedx/core/djangoapps/password_policy/ openedx/core/djangoapps/plugin_api/ openedx/core/djangoapps/plugins/ openedx/core/djangoapps/profile_images/ openedx/core/djangoapps/programs/ openedx/core/djangoapps/safe_sessions/ openedx/core/djangoapps/schedules/ openedx/core/djangoapps/service_status/ openedx/core/djangoapps/session_inactivity_timeout/ openedx/core/djangoapps/signals/ openedx/core/djangoapps/site_configuration/ openedx/core/djangoapps/system_wide_roles/ openedx/core/djangoapps/theming/ openedx/core/djangoapps/user_api/ openedx/core/djangoapps/user_authn/ openedx/core/djangoapps/util/ openedx/core/djangoapps/verified_track_content/ openedx/core/djangoapps/video_config/ openedx/core/djangoapps/video_pipeline/ openedx/core/djangoapps/waffle_utils/ openedx/core/djangoapps/xblock/ openedx/core/djangoapps/xmodule_django/ openedx/core/tests/ openedx/features/ openedx/testing/ openedx/tests/ openedx/envs/ openedx/core/djangoapps/notifications/ openedx/core/djangoapps/staticfiles/ openedx/core/djangoapps/content_tagging/"
24+
path: "openedx/core/djangoapps/geoinfo/ openedx/core/djangoapps/header_control/ openedx/core/djangoapps/heartbeat/ openedx/core/djangoapps/lang_pref/ openedx/core/djangoapps/models/ openedx/core/djangoapps/monkey_patch/ openedx/core/djangoapps/oauth_dispatch/ openedx/core/djangoapps/olx_rest_api/ openedx/core/djangoapps/password_policy/ openedx/core/djangoapps/plugin_api/ openedx/core/djangoapps/plugins/ openedx/core/djangoapps/profile_images/ openedx/core/djangoapps/programs/ openedx/core/djangoapps/safe_sessions/ openedx/core/djangoapps/schedules/ openedx/core/djangoapps/service_status/ openedx/core/djangoapps/session_inactivity_timeout/ openedx/core/djangoapps/signals/ openedx/core/djangoapps/site_configuration/ openedx/core/djangoapps/system_wide_roles/ openedx/core/djangoapps/theming/ openedx/core/djangoapps/user_api/ openedx/core/djangoapps/user_authn/ openedx/core/djangoapps/util/ openedx/core/djangoapps/verified_track_content/ openedx/core/djangoapps/video_config/ openedx/core/djangoapps/video_pipeline/ openedx/core/djangoapps/waffle_utils/ openedx/core/djangoapps/xblock/ openedx/core/djangoapps/xmodule_django/ openedx/core/tests/ openedx/features/ openedx/testing/ openedx/tests/ openedx/envs/ openedx/core/djangoapps/notifications/ openedx/core/djangoapps/staticfiles/ openedx/core/djangoapps/content_tagging/ openedx/core/djangoapps/authz/"
2525
- module-name: common
2626
path: "common"
2727
- module-name: cms

.github/workflows/unit-test-shards.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@
147147
"openedx/core/djangoapps/xblock/",
148148
"openedx/core/djangoapps/xmodule_django/",
149149
"openedx/core/djangoapps/zendesk_proxy/",
150+
"openedx/core/djangoapps/authz/",
150151
"openedx/core/djangolib/",
151152
"openedx/core/lib/",
152153
"openedx/core/tests/",
@@ -227,6 +228,7 @@
227228
"openedx/core/djangoapps/xblock/",
228229
"openedx/core/djangoapps/xmodule_django/",
229230
"openedx/core/djangoapps/zendesk_proxy/",
231+
"openedx/core/djangoapps/authz/",
230232
"openedx/core/lib/",
231233
"openedx/tests/"
232234
]

cms/envs/common.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -907,6 +907,9 @@ def make_lms_template_path(settings):
907907
# alternative swagger generator for CMS API
908908
'drf_spectacular',
909909

910+
# Authz
911+
'openedx.core.djangoapps.authz',
912+
910913
'openedx_events',
911914

912915
# Core models to represent courses

lms/envs/common.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2088,6 +2088,9 @@
20882088
# Notifications
20892089
'openedx.core.djangoapps.notifications',
20902090

2091+
# Authz
2092+
'openedx.core.djangoapps.authz',
2093+
20912094
'openedx_events',
20922095

20932096
# Core models to represent courses
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
AuthZ Django Integration
2+
########################
3+
4+
Overview
5+
********
6+
7+
The ``openedx.core.djangoapps.authz`` app provides Django integrations for the
8+
`openedx-authz` authorization framework within ``edx-platform``.
9+
10+
The `openedx-authz` library implements a centralized authorization system based
11+
on explicit permissions and policy evaluation. This Django app acts as a thin
12+
integration layer between ``edx-platform`` and the external library, providing
13+
utilities that make it easier to enforce authorization checks in Django views.
14+
15+
Currently, the app provides a decorator used to enforce AuthZ permissions in
16+
views. The app may also host additional Django-specific helpers and utilities
17+
as the integration with the AuthZ framework evolves.
18+
19+
Purpose
20+
*******
21+
22+
This app exists to:
23+
24+
- Provide Django-specific integrations for the ``openedx-authz`` framework
25+
- Offer reusable decorators for enforcing authorization checks in views
26+
- Centralize AuthZ-related utilities used across LMS and Studio
27+
28+
Keeping these integrations in a dedicated app avoids coupling authorization
29+
logic with unrelated apps and provides a clear location for future extensions.
30+
31+
Location in the Platform
32+
************************
33+
34+
The app lives in ``openedx/core/djangoapps`` because the functionality it
35+
provides is a **platform-level concern shared across LMS and Studio**, rather
36+
than something specific to either service.
37+
38+
Usage
39+
*****
40+
41+
The primary utility currently provided by this app is a decorator that enforces
42+
authorization checks using the AuthZ framework.
43+
44+
Example usage::
45+
46+
from openedx.core.djangoapps.authz.decorators import authz_permission_required
47+
48+
49+
@authz_permission_required("course.read")
50+
def my_view(request, course_key):
51+
...
52+
53+
The decorator ensures that the requesting user has the required permission
54+
before allowing the view to execute.
55+
56+
Additional parameters may allow compatibility with legacy permission checks
57+
during the transition to the new authorization framework.
58+
59+
Contents
60+
********
61+
62+
The app currently includes:
63+
64+
- **Decorators** for enforcing AuthZ permissions in Django views
65+
- **Constants** used by the AuthZ integration
66+
- **Tests** validating decorator behavior
67+
68+
Relationship with ``openedx-authz``
69+
***********************************
70+
71+
This app does not implement the authorization framework itself. Instead, it
72+
provides Django-specific integrations that connect ``edx-platform`` with the
73+
external ``openedx-authz`` library.
74+
75+
Keeping these integrations in ``edx-platform`` ensures that the external
76+
library remains framework-agnostic.
77+
78+
References
79+
**********
80+
81+
- `openedx-authz repository <https://github.com/openedx/openedx-authz>`_
82+
- `openedx-authz documentation <https://openedx-authz.readthedocs.io/>`_

openedx/core/djangoapps/authz/__init__.py

Whitespace-only changes.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Django app configuration for authz app."""
2+
3+
from django.apps import AppConfig
4+
5+
6+
class AuthzConfig(AppConfig):
7+
"""Django application configuration for the Open edX Authorization (AuthZ) app.
8+
9+
This app provides a centralized location for integrations with the
10+
openedx-authz library, including permission helpers, decorators,
11+
and other utilities used to enforce RBAC-based authorization across
12+
the platform."""
13+
14+
default_auto_field = 'django.db.models.BigAutoField'
15+
name = 'openedx.core.djangoapps.authz'
16+
verbose_name = "Open edX Authorization Framework"
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Constants used by the Open edX Authorization (AuthZ) framework."""
2+
3+
from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access
4+
from enum import Enum
5+
6+
7+
class LegacyAuthoringPermission(Enum):
8+
READ = "read"
9+
WRITE = "write"
10+
11+
12+
LEGACY_PERMISSION_HANDLER_MAP = {
13+
LegacyAuthoringPermission.READ: has_studio_read_access,
14+
LegacyAuthoringPermission.WRITE: has_studio_write_access,
15+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""Decorators for AuthZ-based permissions enforcement."""
2+
import logging
3+
from functools import wraps
4+
from collections.abc import Callable
5+
6+
from django.contrib.auth.models import AbstractUser
7+
from opaque_keys import InvalidKeyError
8+
from opaque_keys.edx.keys import CourseKey, UsageKey
9+
from openedx.core.djangoapps.authz.constants import LEGACY_PERMISSION_HANDLER_MAP, LegacyAuthoringPermission
10+
from openedx_authz import api as authz_api
11+
from rest_framework import status
12+
13+
from openedx.core import toggles as core_toggles
14+
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin
15+
16+
log = logging.getLogger(__name__)
17+
18+
19+
def authz_permission_required(
20+
authz_permission: str,
21+
legacy_permission: LegacyAuthoringPermission | None = None) -> Callable:
22+
"""
23+
Decorator enforcing course author permissions via AuthZ
24+
with optional legacy fallback.
25+
26+
This decorator checks if the requesting user has the specified AuthZ permission for the course.
27+
If AuthZ is not enabled for the course, and a legacy_permission is provided, it falls back to checking
28+
the legacy permission.
29+
30+
Raises:
31+
DeveloperErrorResponseException: If the user does not have the required permissions.
32+
"""
33+
34+
def decorator(view_func):
35+
36+
@wraps(view_func)
37+
def _wrapped_view(self, request, course_id, *args, **kwargs):
38+
course_key = get_course_key(course_id)
39+
40+
if not user_has_course_permission(
41+
request.user,
42+
authz_permission,
43+
course_key,
44+
legacy_permission
45+
):
46+
raise DeveloperErrorViewMixin.api_error(
47+
status_code=status.HTTP_403_FORBIDDEN,
48+
developer_message="You do not have permission to perform this action.",
49+
error_code="permission_denied",
50+
)
51+
52+
return view_func(self, request, course_key, *args, **kwargs)
53+
54+
return _wrapped_view
55+
56+
return decorator
57+
58+
59+
def user_has_course_permission(
60+
user: AbstractUser,
61+
authz_permission: str,
62+
course_key: CourseKey,
63+
legacy_permission: LegacyAuthoringPermission | None = None,
64+
) -> bool:
65+
"""
66+
Checks if the user has the specified AuthZ permission for the course,
67+
with optional fallback to legacy permissions.
68+
"""
69+
if core_toggles.enable_authz_course_authoring(course_key):
70+
# If AuthZ is enabled for this course, check the permission via AuthZ only.
71+
is_user_allowed = authz_api.is_user_allowed(user.username, authz_permission, str(course_key))
72+
log.info(
73+
"AuthZ permission granted = {}".format(is_user_allowed),
74+
extra={
75+
"user_id": user.id,
76+
"authz_permission": authz_permission,
77+
"course_key": str(course_key),
78+
},
79+
)
80+
return is_user_allowed
81+
82+
# If AuthZ is not enabled for this course, fall back to legacy course author
83+
# access check if legacy_permission is provided.
84+
has_legacy_permission: Callable | None = LEGACY_PERMISSION_HANDLER_MAP.get(legacy_permission)
85+
if legacy_permission and has_legacy_permission and has_legacy_permission(user, course_key):
86+
log.info(
87+
"AuthZ fallback used",
88+
extra={
89+
"user_id": user.id,
90+
"authz_permission": authz_permission,
91+
"legacy_permission": legacy_permission,
92+
"course_key": str(course_key),
93+
},
94+
)
95+
return True
96+
97+
log.info(
98+
"AuthZ permission denied",
99+
extra={
100+
"user_id": user.id,
101+
"authz_permission": authz_permission,
102+
"course_key": str(course_key),
103+
},
104+
)
105+
return False
106+
107+
108+
def get_course_key(course_id: str) -> CourseKey:
109+
"""
110+
Given a course_id string, attempts to parse it as a CourseKey.
111+
If that fails, attempts to parse it as a UsageKey and extract the course key from it.
112+
"""
113+
try:
114+
return CourseKey.from_string(course_id)
115+
except InvalidKeyError:
116+
# If the course_id doesn't match the COURSE_KEY_PATTERN, it might be a usage key.
117+
# Attempt to parse it as such and extract the course key.
118+
usage_key = UsageKey.from_string(course_id)
119+
return usage_key.course_key
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
0001 AUTHZ DJANGO INTEGRATION APP
2+
####################
3+
4+
Status
5+
******
6+
7+
Accepted
8+
9+
Context
10+
*******
11+
12+
This ADR defines where Django integrations for the openedx-authz framework should live within edx-platform.
13+
The openedx-authz library introduces a new authorization framework for Open edX based on explicit permissions and a centralized policy engine. Integrating this framework into edx-platform requires several Django-specific utilities.
14+
One of the first integrations is a view decorator used to enforce authorization checks using the new AuthZ framework. This decorator is expected to be reused across multiple views in LMS and Studio.
15+
During implementation, the question arose of where these Django integrations should live.
16+
17+
Some options considered were:
18+
19+
- common/djangoapps/student/auth.py
20+
- openedx/core/authz.py (a new python module)
21+
- openedx/core/djangoapps/authz (a new Django app)
22+
23+
The student app contains legacy authentication and authorization logic tied to student functionality. Adding new platform-level authorization integrations there would introduce cross-cutting concerns into an unrelated app.
24+
25+
Another option was creating a single module such as openedx/core/authz.py. However, the integration already includes multiple components (decorators, constants, tests) and is expected to grow over time.
26+
27+
Because of this, a dedicated Django app provides a clearer and more scalable structure for these integrations.
28+
29+
Decision
30+
********
31+
32+
edx-platform will introduce a new lightweight Django app openedx.core.djangoapps.authz to host Django integrations for the openedx-authz framework.
33+
34+
- The app will contain reusable decorators enforcing AuthZ permissions in Django views.
35+
- Supporting modules such as constants and helper utilities will live in this app.
36+
- The app will include tests validating these integrations.
37+
- The app acts as a thin integration layer between edx-platform and the external openedx-authz library.
38+
- The app will live in openedx/core/djangoapps because this functionality is a platform-level concern shared by LMS and Studio.
39+
40+
Initial contents include:
41+
42+
- An authorization decorator for Django views.
43+
- A constants.py module for AuthZ-related constants.
44+
- Tests validating the decorator behavior.
45+
46+
Consequences
47+
************
48+
49+
- Django integrations for the AuthZ framework have a centralized and discoverable location.
50+
- Future integrations can be added without expanding unrelated modules.
51+
- The separation clarifies the distinction between authentication (authn) and authorization (authz) responsibilities.
52+
53+
However:
54+
55+
- Introducing a new Django app slightly increases project structure complexity.
56+
- Some authorization logic may remain elsewhere until future refactoring occurs.
57+
58+
Rejected Alternatives
59+
**********************
60+
61+
- Add the decorator to common/djangoapps/student/auth.py
62+
Rejected because the module belongs to the student app and already mixes authentication and authorization responsibilities.
63+
64+
- Create a single module openedx/core/authz.py
65+
Rejected because the integration already includes multiple components and is expected to grow.
66+
67+
- Implement the decorator in the openedx-authz library
68+
Rejected because the decorator is Django-specific and tied to how edx-platform integrates authorization checks into views.
69+
70+
References
71+
**********
72+
73+
.. _openedx-authz repository: https://github.com/openedx/openedx-authz
74+
.. _openedx-authz documentation: https://openedx-authz.readthedocs.io/

0 commit comments

Comments
 (0)