Skip to content

Commit c8e02fa

Browse files
committed
feat: enhance ScopeMeta to support glob patterns
1 parent c64fe18 commit c8e02fa

8 files changed

Lines changed: 578 additions & 15 deletions

File tree

openedx_authz/api/data.py

Lines changed: 299 additions & 5 deletions
Large diffs are not rendered by default.

openedx_authz/api/roles.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,9 @@ def assign_role_to_subject_in_scope(subject: SubjectData, role: RoleData, scope:
199199
200200
Returns:
201201
bool: True if the role was assigned successfully, False otherwise.
202+
203+
Raises:
204+
ValueError: If the scope string contains invalid glob patterns.
202205
"""
203206
enforcer = AuthzEnforcer.get_enforcer()
204207
adapter = AuthzEnforcer.get_adapter()

openedx_authz/api/validation.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""Validation utilities for OpenedX AuthZ API.
2+
3+
This module provides validation functions for scope strings, particularly
4+
for glob patterns used in role assignments.
5+
"""
6+
7+
from openedx_authz.api.data import (
8+
EXTERNAL_KEY_SEPARATOR,
9+
GLOBAL_SCOPE_WILDCARD,
10+
ContentLibraryData,
11+
CourseOverviewData,
12+
ScopeData,
13+
)
14+
15+
16+
def validate_scope_with_glob(scope: ScopeData) -> None:
17+
"""Validate that a scope with glob patterns follows rules.
18+
19+
This function ensures that glob patterns (*) in scope strings are only
20+
allowed at the organization level and that the referenced organization
21+
exists to prevent overly broad or invalid permissions.
22+
23+
Rules:
24+
- For course scopes: Must have exactly the format "course-v1:ORG*" where ORG exists
25+
- For library scopes: Must have exactly the format "lib:ORG*" where ORG exists
26+
- Wildcards must only appear at the end of the string
27+
- Wildcards are only allowed at organization level (not at course, run, or slug level)
28+
- Cannot have wildcards before the org identifier
29+
30+
Args:
31+
scope (ScopeData): ScopeData instance to validate (e.g. ScopeData(external_key="course-v1:OpenedX*"))
32+
33+
Examples:
34+
Valid scopes:
35+
- CourseOverviewData(external_key="course-v1:OpenedX*") # org-level wildcard
36+
- ContentLibraryData(external_key="lib:DemoX*") # org-level wildcard
37+
38+
Invalid scopes:
39+
- course-v1* - wildcard before org
40+
- course-v1:* - wildcard without org prefix
41+
- course-v1:OpenedX+Course* - wildcard at course level (NOT allowed)
42+
- lib:DemoX:Slug* - wildcard at slug level (NOT allowed)
43+
"""
44+
external_key = scope.external_key
45+
46+
if GLOBAL_SCOPE_WILDCARD not in external_key:
47+
return None
48+
49+
# Get the scope string without the trailing wildcard
50+
scope_prefix = external_key[: -len(GLOBAL_SCOPE_WILDCARD)]
51+
52+
if isinstance(scope, CourseOverviewData):
53+
return _validate_course_scope_glob(scope_prefix)
54+
if isinstance(scope, ContentLibraryData):
55+
return _validate_library_scope_glob(scope_prefix)
56+
57+
raise ValueError(f"Invalid scope: {scope}")
58+
59+
60+
def _validate_org_identifier(scope_prefix: str) -> str:
61+
"""Extract and structurally validate the organization identifier in a scope.
62+
63+
This helper only validates the structure (namespace and org position). It does
64+
not check whether the organization actually exists. That is the responsibility
65+
of the scope-type specific validators.
66+
67+
Args:
68+
scope_prefix (str): The scope without the trailing wildcard
69+
70+
Returns:
71+
str: The extracted organization identifier
72+
73+
Examples:
74+
>>> _validate_org_identifier("course-v1:OpenedX*")
75+
"OpenedX"
76+
>>> _validate_org_identifier("lib:DemoX*")
77+
"DemoX"
78+
"""
79+
parts = scope_prefix.split(EXTERNAL_KEY_SEPARATOR)
80+
81+
if len(parts) != 2 or parts[1] == "":
82+
raise ValueError("Scope glob must include exactly one organization identifier.")
83+
84+
return parts[1]
85+
86+
87+
def _course_org_exists(org: str) -> bool:
88+
"""Check if there is at least one course with the given org.
89+
90+
Args:
91+
org (str): Organization identifier extracted from the course scope
92+
93+
Returns:
94+
bool: True if there is at least one CourseOverview whose org field matches
95+
the provided identifier in a case-sensitive way, False otherwise.
96+
"""
97+
from openedx_authz.models.scopes import CourseOverview # pylint: disable=import-outside-toplevel
98+
99+
course_obj = CourseOverview.objects.filter(org=org).only("org").last()
100+
return course_obj is not None and course_obj.org == org
101+
102+
103+
def _library_org_exists(org: str) -> bool:
104+
"""Check if there is at least one content library with the given org.
105+
106+
Args:
107+
org (str): Organization identifier extracted from the library scope
108+
109+
Returns:
110+
bool: True if there is at least one ContentLibrary whose related
111+
organization's short_name matches the provided identifier in a
112+
case-sensitive way, False otherwise.
113+
"""
114+
from openedx_authz.models.scopes import ContentLibrary # pylint: disable=import-outside-toplevel
115+
116+
lib_obj = ContentLibrary.objects.filter(org__short_name=org).only("org").last()
117+
return lib_obj is not None and lib_obj.org.short_name == org
118+
119+
120+
def _validate_course_scope_glob(scope_prefix: str) -> None:
121+
"""Validate a course scope with glob pattern.
122+
123+
Course keys have format: course-v1:ORG+COURSE+RUN
124+
We only allow wildcards at the organization level (course-v1:ORG*).
125+
Wildcards at course or run level are not allowed.
126+
127+
Args:
128+
scope_prefix (str): The course scope without the trailing wildcard
129+
"""
130+
org = _validate_org_identifier(scope_prefix)
131+
132+
if not _course_org_exists(org):
133+
raise ValueError(f"Organization '{org}' does not exist for any course.")
134+
135+
136+
def _validate_library_scope_glob(scope_prefix: str) -> None:
137+
"""Validate a library scope with glob pattern.
138+
139+
Library keys have format: lib:ORG:SLUG
140+
We only allow wildcards at the organization level (lib:ORG*).
141+
Wildcards at slug level are not allowed.
142+
143+
Args:
144+
scope_prefix (str): The library scope without the trailing wildcard
145+
"""
146+
org = _validate_org_identifier(scope_prefix)
147+
148+
if not _library_org_exists(org):
149+
raise ValueError(f"Organization '{org}' does not exist for any library.")

openedx_authz/engine/enforcer.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from uuid import uuid4
2121

2222
from casbin import SyncedEnforcer
23+
from casbin.util import key_match_func
2324
from casbin.util.log import DEFAULT_LOGGING, configure_logging
2425
from casbin_adapter.enforcer import initialize_enforcer
2526
from django.conf import settings
@@ -279,5 +280,6 @@ def _initialize_enforcer(cls) -> SyncedEnforcer:
279280
adapter = ExtendedAdapter()
280281
enforcer = SyncedEnforcer(settings.CASBIN_MODEL, adapter)
281282
enforcer.add_function("is_staff_or_superuser", is_admin_or_superuser_check)
283+
enforcer.add_named_domain_matching_func("g", key_match_func)
282284

283285
return enforcer

openedx_authz/models/scopes.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from opaque_keys.edx.keys import CourseKey
1212
from opaque_keys.edx.locator import LibraryLocatorV2
1313

14+
from openedx_authz.api.data import GLOBAL_SCOPE_WILDCARD, ScopeData
1415
from openedx_authz.models.core import Scope
1516

1617

@@ -81,16 +82,22 @@ class ContentLibraryScope(Scope):
8182
)
8283

8384
@classmethod
84-
def get_or_create_for_external_key(cls, scope):
85+
def get_or_create_for_external_key(cls, scope: ScopeData) -> "ContentLibraryScope":
8586
"""Get or create a ContentLibraryScope for the given external key.
8687
8788
Args:
8889
scope: ScopeData object with an external_key attribute containing
8990
a LibraryLocatorV2-compatible string.
9091
9192
Returns:
92-
ContentLibraryScope: The Scope instance for the given ContentLibrary
93+
ContentLibraryScope: The Scope instance for the given ContentLibrary,
94+
or None if the scope is a glob pattern (contains wildcard).
9395
"""
96+
# For glob scopes we don't create a Scope object since
97+
# they don't represent a specific content library
98+
if GLOBAL_SCOPE_WILDCARD in scope.external_key:
99+
return None
100+
94101
library_key = LibraryLocatorV2.from_string(scope.external_key)
95102
content_library = ContentLibrary.objects.get_by_key(library_key)
96103
scope, _ = cls.objects.get_or_create(content_library=content_library)
@@ -124,16 +131,22 @@ class CourseScope(Scope):
124131
)
125132

126133
@classmethod
127-
def get_or_create_for_external_key(cls, scope):
134+
def get_or_create_for_external_key(cls, scope: ScopeData) -> "CourseScope":
128135
"""Get or create a CourseScope for the given external key.
129136
130137
Args:
131138
scope: ScopeData object with an external_key attribute containing
132139
a CourseKey string.
133140
134141
Returns:
135-
CourseScope: The Scope instance for the given CourseOverview
142+
CourseScope: The Scope instance for the given CourseOverview,
143+
or None if the scope is a glob pattern (contains wildcard).
136144
"""
145+
# For glob scopes we don't create a Scope object
146+
# since they don't represent a specific course
147+
if GLOBAL_SCOPE_WILDCARD in scope.external_key:
148+
return None
149+
137150
course_key = CourseKey.from_string(scope.external_key)
138151
course_overview = CourseOverview.get_from_id(course_key)
139152
scope, _ = cls.objects.get_or_create(course_overview=course_overview)

openedx_authz/rest_api/v1/serializers.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from rest_framework import serializers
55

66
from openedx_authz import api
7+
from openedx_authz.api.data import GLOBAL_SCOPE_WILDCARD
78
from openedx_authz.rest_api.data import SortField, SortOrder
89
from openedx_authz.rest_api.utils import get_generic_scope
910
from openedx_authz.rest_api.v1.fields import CommaSeparatedListField, LowercaseCharField
@@ -46,9 +47,10 @@ def validate(self, attrs) -> dict:
4647
"""Validate that the specified role and scope are valid and that the role exists in the scope.
4748
4849
This method performs the following validations:
49-
1. Validates that the scope is registered in the scope registry
50-
2. Validates that the scope exists in the system
51-
3. Validates that the role is defined into the roles assigned to the scope
50+
1. Validates that glob patterns in scope follow security rules
51+
2. Validates that the scope is registered in the scope registry
52+
3. Validates that the scope exists in the system (if not using glob)
53+
4. Validates that the role is defined into the roles assigned to the scope
5254
5355
Args:
5456
attrs: Dictionary containing 'role' and 'scope' keys with their string values.

openedx_authz/tests/test_enforcement.py

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,22 @@
1111

1212
import casbin
1313
import pytest
14+
from casbin.util import key_match_func
1415
from ddt import data, ddt, unpack
1516
from django.contrib.auth import get_user_model
1617

1718
from openedx_authz import ROOT_DIRECTORY
18-
from openedx_authz.api.data import GLOBAL_SCOPE_WILDCARD
19-
from openedx_authz.constants import roles
19+
from openedx_authz.api.data import GLOBAL_SCOPE_WILDCARD, ContentLibraryData, CourseOverviewData
20+
from openedx_authz.constants import permissions, roles
2021
from openedx_authz.engine.matcher import is_admin_or_superuser_check
2122
from openedx_authz.tests.test_utils import (
2223
make_action_key,
24+
make_course_key,
2325
make_library_key,
2426
make_role_key,
2527
make_scope_key,
2628
make_user_key,
29+
make_wildcard_key,
2730
)
2831

2932
User = get_user_model()
@@ -73,6 +76,7 @@ def setUpClass(cls) -> None:
7376

7477
cls.enforcer = casbin.Enforcer(model_file)
7578
cls.enforcer.add_function("is_staff_or_superuser", is_admin_or_superuser_check)
79+
cls.enforcer.add_named_domain_matching_func("g", key_match_func)
7680

7781
def _load_policy(self, policy: list[str]) -> None:
7882
"""
@@ -583,6 +587,82 @@ def test_wildcard_library_access(self, scope: str, expected_result: bool):
583587
self._test_enforcement(self.POLICY, request)
584588

585589

590+
@ddt
591+
class OrgGlobEnforcementTests(CasbinEnforcementTestCase):
592+
"""
593+
Tests for organization-level glob patterns in course and library scopes.
594+
595+
This test class verifies that policies defined with org-level glob patterns
596+
(e.g., "course-v1:OpenedX*" or "lib:DemoX*") are correctly enforced for
597+
concrete course and library scopes that belong to those organizations.
598+
"""
599+
600+
POLICY = [
601+
# Policies
602+
[
603+
"p",
604+
make_role_key(roles.COURSE_STAFF.external_key),
605+
make_action_key("courses.view_course"),
606+
make_wildcard_key(CourseOverviewData.NAMESPACE),
607+
"allow",
608+
],
609+
[
610+
"p",
611+
make_role_key(roles.LIBRARY_ADMIN.external_key),
612+
make_action_key("content_libraries.view_library"),
613+
make_wildcard_key(ContentLibraryData.NAMESPACE),
614+
"allow",
615+
],
616+
# Role assignments
617+
[
618+
"g",
619+
make_user_key("user-1"),
620+
make_role_key(roles.COURSE_STAFF.external_key),
621+
make_course_key("course-v1:OpenedX*"),
622+
],
623+
[
624+
"g",
625+
make_user_key("user-2"),
626+
make_role_key(roles.LIBRARY_ADMIN.external_key),
627+
make_library_key("lib:DemoX*"),
628+
],
629+
]
630+
631+
CASES = [
632+
# Permission granted
633+
{
634+
"subject": make_user_key("user-1"),
635+
"action": make_action_key(permissions.COURSES_VIEW_COURSE.action.external_key),
636+
"scope": make_course_key("course-v1:OpenedX+DemoCourse+2026_T1"),
637+
"expected_result": True,
638+
},
639+
{
640+
"subject": make_user_key("user-2"),
641+
"action": make_action_key(permissions.VIEW_LIBRARY.action.external_key),
642+
"scope": make_library_key("lib:DemoX:OrgLevelGlobLib"),
643+
"expected_result": True,
644+
},
645+
# Permission denied
646+
{
647+
"subject": make_user_key("user-1"),
648+
"action": make_action_key(permissions.COURSES_VIEW_COURSE.action.external_key),
649+
"scope": make_course_key("course-v1:InexistentOrg+DemoCourse+2026_T1"),
650+
"expected_result": False,
651+
},
652+
{
653+
"subject": make_user_key("user-2"),
654+
"action": make_action_key(permissions.VIEW_LIBRARY.action.external_key),
655+
"scope": make_library_key("lib:InexistentOrg:OrgLevelGlobLib"),
656+
"expected_result": False,
657+
},
658+
]
659+
660+
@data(*CASES)
661+
def test_org_level_glob_enforcement(self, request: AuthRequest):
662+
"""Test that org-level glob patterns in scopes are enforced correctly."""
663+
self._test_enforcement(self.POLICY, request)
664+
665+
586666
@pytest.mark.django_db
587667
@ddt
588668
class StaffSuperuserAccessTests(CasbinEnforcementTestCase):

0 commit comments

Comments
 (0)