Skip to content

Commit dc99738

Browse files
authored
feat: add PoC permission and role (#209)
1 parent ced9562 commit dc99738

File tree

18 files changed

+558
-45
lines changed

18 files changed

+558
-45
lines changed

CHANGELOG.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ Added
2020
* ADR on the AuthZ for Course Authoring implementation plan.
2121
* ADR on the AuthZ for Course Authoring Feature Flag Implementation Details.
2222

23+
24+
0.21.0 - 2026-02-12
25+
********************
26+
27+
Added
28+
=====
29+
30+
* Add course staff role, permission to manage advanced course settings, and introduce course scope
31+
2332
0.20.0 - 2025-11-27
2433
********************
2534

openedx_authz/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@
44

55
import os
66

7-
__version__ = "0.20.0"
7+
__version__ = "0.21.0"
88

99
ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))

openedx_authz/api/data.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,19 @@
99

1010
from attrs import define
1111
from opaque_keys import InvalidKeyError
12+
from opaque_keys.edx.keys import CourseKey
1213
from opaque_keys.edx.locator import LibraryLocatorV2
1314

1415
try:
1516
from openedx.core.djangoapps.content_libraries.models import ContentLibrary
1617
except ImportError:
1718
ContentLibrary = None
1819

20+
try:
21+
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
22+
except ImportError:
23+
CourseOverview = None
24+
1925
__all__ = [
2026
"UserData",
2127
"PermissionData",
@@ -212,6 +218,8 @@ def get_subclass_by_namespaced_key(mcs, namespaced_key: str) -> Type["ScopeData"
212218
The ScopeData subclass for the namespace, or ScopeData if namespace not recognized.
213219
214220
Examples:
221+
>>> ScopeMeta.get_subclass_by_namespaced_key('course-v1^course-v1:WGU+CS002+2025_T1')
222+
<class 'CourseOverviewData'>
215223
>>> ScopeMeta.get_subclass_by_namespaced_key('lib^lib:DemoX:CSPROB')
216224
<class 'ContentLibraryData'>
217225
>>> ScopeMeta.get_subclass_by_namespaced_key('global^generic')
@@ -462,6 +470,108 @@ def __repr__(self):
462470
return self.namespaced_key
463471

464472

473+
@define
474+
class CourseOverviewData(ScopeData):
475+
"""A course scope for authorization in the Open edX platform.
476+
477+
Courses uses the CourseKey format for identification.
478+
479+
Attributes:
480+
NAMESPACE: 'course-v1' for course scopes.
481+
external_key: The course identifier (e.g., 'course-v1:TestOrg+TestCourse+2024_T1').
482+
Must be a valid CourseKey format.
483+
namespaced_key: The course identifier with namespace (e.g., 'course-v1^course-v1:TestOrg+TestCourse+2024_T1').
484+
course_id: Property alias for external_key.
485+
486+
Examples:
487+
>>> course = CourseOverviewData(external_key='course-v1:TestOrg+TestCourse+2024_T1')
488+
>>> course.namespaced_key
489+
'course-v1^course-v1:TestOrg+TestCourse+2024_T1'
490+
>>> course.course_id
491+
'course-v1:TestOrg+TestCourse+2024_T1'
492+
493+
"""
494+
495+
NAMESPACE: ClassVar[str] = "course-v1"
496+
497+
@property
498+
def course_id(self) -> str:
499+
"""The course identifier as used in Open edX (e.g., 'course-v1:TestOrg+TestCourse+2024_T1').
500+
501+
This is an alias for external_key that represents the course ID without the namespace prefix.
502+
503+
Returns:
504+
str: The course identifier without namespace.
505+
"""
506+
return self.external_key
507+
508+
@property
509+
def course_key(self) -> CourseKey:
510+
"""The CourseKey object for the course.
511+
512+
Returns:
513+
CourseKey: The course key object.
514+
"""
515+
return CourseKey.from_string(self.course_id)
516+
517+
@classmethod
518+
def validate_external_key(cls, external_key: str) -> bool:
519+
"""Validate the external_key format for CourseOverviewData.
520+
521+
Args:
522+
external_key: The external key to validate.
523+
524+
Returns:
525+
bool: True if valid, False otherwise.
526+
"""
527+
try:
528+
CourseKey.from_string(external_key)
529+
return True
530+
except InvalidKeyError:
531+
return False
532+
533+
def get_object(self) -> CourseOverview | None:
534+
"""Retrieve the CourseOverview instance associated with this scope.
535+
536+
This method converts the course_id to a CourseKey and queries the
537+
database to fetch the corresponding CourseOverview object.
538+
539+
Returns:
540+
CourseOverview | None: The CourseOverview instance if found in the database,
541+
or None if the course does not exist or has an invalid key format.
542+
543+
Examples:
544+
>>> course_scope = CourseOverviewData(external_key='course-v1:TestOrg+TestCourse+2024_T1')
545+
>>> course_obj = course_scope.get_object() # CourseOverview object
546+
"""
547+
try:
548+
course_obj = CourseOverview.get_from_id(self.course_key)
549+
# Validate canonical key: get_by_key is case-insensitive, but we require exact match
550+
# This ensures authorization uses canonical course IDs consistently
551+
if course_obj.id != self.course_key:
552+
raise CourseOverview.DoesNotExist
553+
except (InvalidKeyError, CourseOverview.DoesNotExist):
554+
return None
555+
556+
return course_obj
557+
558+
def exists(self) -> bool:
559+
"""Check if the course overview exists.
560+
561+
Returns:
562+
bool: True if the course overview exists, False otherwise.
563+
"""
564+
return self.get_object() is not None
565+
566+
def __str__(self):
567+
"""Human readable string representation of the course overview."""
568+
return self.course_id
569+
570+
def __repr__(self):
571+
"""Developer friendly string representation of the course overview."""
572+
return self.namespaced_key
573+
574+
465575
class SubjectMeta(type):
466576
"""Metaclass for SubjectData to handle dynamic subclass instantiation based on namespace."""
467577

openedx_authz/constants/permissions.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,12 @@
5353
action=ActionData(external_key=f"{CONTENT_LIBRARIES_NAMESPACE}.delete_library_collection"),
5454
effect="allow",
5555
)
56+
57+
# Course Permissions
58+
59+
COURSES_NAMESPACE = "courses"
60+
61+
MANAGE_ADVANCED_SETTINGS = PermissionData(
62+
action=ActionData(external_key=f"{COURSES_NAMESPACE}.manage_advanced_settings"),
63+
effect="allow",
64+
)

openedx_authz/constants/roles.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,13 @@
5656
LIBRARY_AUTHOR = RoleData(external_key="library_author", permissions=LIBRARY_AUTHOR_PERMISSIONS)
5757
LIBRARY_CONTRIBUTOR = RoleData(external_key="library_contributor", permissions=LIBRARY_CONTRIBUTOR_PERMISSIONS)
5858
LIBRARY_USER = RoleData(external_key="library_user", permissions=LIBRARY_USER_PERMISSIONS)
59+
60+
61+
# Course Roles and Permissions
62+
63+
64+
COURSE_STAFF_PERMISSIONS = [
65+
permissions.MANAGE_ADVANCED_SETTINGS,
66+
]
67+
68+
COURSE_STAFF = RoleData(external_key="course_staff", permissions=COURSE_STAFF_PERMISSIONS)

openedx_authz/engine/config/authz.policy

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,9 @@ g2, act^content_libraries.manage_library_team, act^content_libraries.view_librar
6868
g2, act^content_libraries.delete_library_collection, act^content_libraries.edit_library_collection
6969
g2, act^content_libraries.create_library_collection, act^content_libraries.edit_library_collection
7070
g2, act^content_libraries.edit_library_collection, act^content_libraries.view_library
71+
72+
73+
# Course Policies
74+
75+
# Course Staff Permissions
76+
p, role^course_staff, act^courses.manage_advanced_settings, course-v1^*, allow

openedx_authz/engine/matcher.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,24 @@
33
from django.contrib.auth import get_user_model
44
from edx_django_utils.cache import RequestCache
55

6-
from openedx_authz.api.data import ContentLibraryData, ScopeData, UserData
6+
from openedx_authz.api.data import ContentLibraryData, CourseOverviewData, ScopeData, UserData
77
from openedx_authz.rest_api.utils import get_user_by_username_or_email
88

99
User = get_user_model()
1010

1111

12+
SCOPES_WITH_ADMIN_OR_SUPERUSER_CHECK = {
13+
(ContentLibraryData.NAMESPACE, ContentLibraryData),
14+
(CourseOverviewData.NAMESPACE, CourseOverviewData),
15+
}
16+
17+
1218
def is_admin_or_superuser_check(request_user: str, request_action: str, request_scope: str) -> bool: # pylint: disable=unused-argument
1319
"""
1420
Evaluates custom, non-role-based conditions for authorization checks.
1521
1622
Checks attribute-based conditions that don't rely on role assignments.
17-
Currently handles ContentLibraryData scopes by granting access to staff
23+
Currently handles ContentLibraryData and CourseOverviewData scopes by granting access to staff
1824
and superusers.
1925
2026
Args:
@@ -24,7 +30,7 @@ def is_admin_or_superuser_check(request_user: str, request_action: str, request_
2430
2531
Returns:
2632
bool: True if the condition is satisfied (user is staff/superuser for
27-
ContentLibraryData scopes), False otherwise (including when user
33+
ContentLibraryData and CourseOverviewData scopes), False otherwise (including when user
2834
doesn't exist or scope type is not supported)
2935
"""
3036

@@ -33,8 +39,8 @@ def is_admin_or_superuser_check(request_user: str, request_action: str, request_
3339
request_cache = RequestCache("rbac_is_admin_or_superuser")
3440

3541
# TODO: This special case for superuser and staff users is currently only for
36-
# content libraries. See: https://github.com/openedx/openedx-authz/issues/87
37-
if not isinstance(scope, ContentLibraryData):
42+
# content libraries and course overviews. See: https://github.com/openedx/openedx-authz/issues/87
43+
if (scope.NAMESPACE, type(scope)) not in SCOPES_WITH_ADMIN_OR_SUPERUSER_CHECK:
3844
return False
3945

4046
cached_response = request_cache.get_cached_response(username)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Generated by Django 4.2.24 on 2026-02-06 17:19
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
dependencies = [
10+
("openedx_authz", "0006_migrate_legacy_permissions"),
11+
]
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name="CourseScope",
16+
fields=[
17+
(
18+
"scope_ptr",
19+
models.OneToOneField(
20+
auto_created=True,
21+
on_delete=django.db.models.deletion.CASCADE,
22+
parent_link=True,
23+
primary_key=True,
24+
serialize=False,
25+
to="openedx_authz.scope",
26+
),
27+
),
28+
(
29+
"course_overview",
30+
models.ForeignKey(
31+
blank=True,
32+
null=True,
33+
on_delete=django.db.models.deletion.CASCADE,
34+
related_name="authz_scopes",
35+
to=settings.OPENEDX_AUTHZ_COURSE_OVERVIEW_MODEL,
36+
),
37+
),
38+
],
39+
options={
40+
"abstract": False,
41+
},
42+
bases=("openedx_authz.scope",),
43+
),
44+
]

openedx_authz/models/scopes.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from django.apps import apps
99
from django.conf import settings
1010
from django.db import models
11+
from opaque_keys.edx.keys import CourseKey
1112
from opaque_keys.edx.locator import LibraryLocatorV2
1213

1314
from openedx_authz.models.core import Scope
@@ -31,7 +32,26 @@ def get_content_library_model():
3132
return None
3233

3334

35+
def get_course_overview_model():
36+
"""Return the CourseOverview model class specified by settings.
37+
38+
The setting `OPENEDX_AUTHZ_COURSE_OVERVIEW_MODEL` should be an
39+
app_label.ModelName string (e.g. 'course_overviews.CourseOverview').
40+
"""
41+
COURSE_OVERVIEW_MODEL = getattr(
42+
settings,
43+
"OPENEDX_AUTHZ_COURSE_OVERVIEW_MODEL",
44+
"course_overviews.CourseOverview",
45+
)
46+
try:
47+
app_label, model_name = COURSE_OVERVIEW_MODEL.split(".")
48+
return apps.get_model(app_label, model_name, require_ready=False)
49+
except LookupError:
50+
return None
51+
52+
3453
ContentLibrary = get_content_library_model()
54+
CourseOverview = get_course_overview_model()
3555

3656

3757
class ContentLibraryScope(Scope):
@@ -42,7 +62,7 @@ class ContentLibraryScope(Scope):
4262

4363
NAMESPACE = "lib"
4464

45-
# Link to the actual course or content library, if applicable. In other cases, this could be null.
65+
# Link to the actual content library, if applicable. In other cases, this could be null.
4666
# Piggybacking on the existing ContentLibrary model to keep the ExtendedCasbinRule up to date
4767
# by deleting the Scope, and thus the ExtendedCasbinRule, when the ContentLibrary is deleted.
4868
#
@@ -75,3 +95,46 @@ def get_or_create_for_external_key(cls, scope):
7595
content_library = ContentLibrary.objects.get_by_key(library_key)
7696
scope, _ = cls.objects.get_or_create(content_library=content_library)
7797
return scope
98+
99+
100+
class CourseScope(Scope):
101+
"""Scope representing a course in the authorization system.
102+
103+
.. no_pii:
104+
"""
105+
106+
NAMESPACE = "course-v1"
107+
108+
# Link to the actual course, if applicable. In other cases, this could be null.
109+
# Piggybacking on the existing CourseOverview model to keep the ExtendedCasbinRule up to date
110+
# by deleting the Scope, and thus the ExtendedCasbinRule, when the CourseOverview is deleted.
111+
#
112+
# When course_overviews IS available, the on_delete=CASCADE will still work at the
113+
# application level through Django's signal handlers.
114+
# Use a string reference to the external app's model so Django won't try
115+
# to import it at model import time. The migration already records the
116+
# dependency on `course_overviews` when the app is present.
117+
course_overview = models.ForeignKey(
118+
settings.OPENEDX_AUTHZ_COURSE_OVERVIEW_MODEL,
119+
on_delete=models.CASCADE,
120+
null=True,
121+
blank=True,
122+
related_name="authz_scopes",
123+
swappable=True,
124+
)
125+
126+
@classmethod
127+
def get_or_create_for_external_key(cls, scope):
128+
"""Get or create a CourseScope for the given external key.
129+
130+
Args:
131+
scope: ScopeData object with an external_key attribute containing
132+
a CourseKey string.
133+
134+
Returns:
135+
CourseScope: The Scope instance for the given CourseOverview
136+
"""
137+
course_key = CourseKey.from_string(scope.external_key)
138+
course_overview = CourseOverview.get_from_id(course_key)
139+
scope, _ = cls.objects.get_or_create(course_overview=course_overview)
140+
return scope

openedx_authz/settings/common.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ def plugin_settings(settings):
4646
if not hasattr(settings, "OPENEDX_AUTHZ_CONTENT_LIBRARY_MODEL"):
4747
settings.OPENEDX_AUTHZ_CONTENT_LIBRARY_MODEL = "content_libraries.ContentLibrary"
4848

49+
# Set default CourseOverview model for swappable dependency
50+
if not hasattr(settings, "OPENEDX_AUTHZ_COURSE_OVERVIEW_MODEL"):
51+
settings.OPENEDX_AUTHZ_COURSE_OVERVIEW_MODEL = "course_overviews.CourseOverview"
52+
4953
# Set default CASBIN_LOG_LEVEL if not already set.
5054
# This setting defines the logging level for the Casbin enforcer.
5155
if not hasattr(settings, "CASBIN_LOG_LEVEL"):

0 commit comments

Comments
 (0)