diff --git a/openedx_authz/api/data.py b/openedx_authz/api/data.py index 859e8e31..25a93dc0 100644 --- a/openedx_authz/api/data.py +++ b/openedx_authz/api/data.py @@ -12,15 +12,10 @@ from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import LibraryLocatorV2 -try: - from openedx.core.djangoapps.content_libraries.models import ContentLibrary -except ImportError: - ContentLibrary = None +from openedx_authz.models.scopes import get_content_library_model, get_course_overview_model -try: - from openedx.core.djangoapps.content.course_overviews.models import CourseOverview -except ImportError: - CourseOverview = None +ContentLibrary = get_content_library_model() +CourseOverview = get_course_overview_model() __all__ = [ "UserData", @@ -33,6 +28,9 @@ "ScopeData", "SubjectData", "ContentLibraryData", + "CourseOverviewData", + "OrgLibraryGlobData", + "OrgCourseGlobData", ] AUTHZ_POLICY_ATTRIBUTES_SEPARATOR = "^" @@ -40,6 +38,11 @@ GLOBAL_SCOPE_WILDCARD = "*" NAMESPACED_KEY_PATTERN = rf"^.+{re.escape(AUTHZ_POLICY_ATTRIBUTES_SEPARATOR)}.+$" +# Pattern for allowed characters in organization identifiers (from opaque-keys library) +# Matches: word characters (letters, digits, underscore), hyphens, tildes, periods, colons +# Reference: opaque_keys.edx.locator.Locator.ALLOWED_ID_CHARS +ALLOWED_CHARS_PATTERN = re.compile(r"^[\w\-~.:]+$", re.UNICODE) + class GroupingPolicyIndex(Enum): """Index positions for fields in a Casbin grouping policy (g or g2). @@ -148,13 +151,21 @@ class ScopeMeta(type): """Metaclass for ScopeData to handle dynamic subclass instantiation based on namespace.""" scope_registry: ClassVar[dict[str, Type["ScopeData"]]] = {} + glob_registry: ClassVar[dict[str, Type["ScopeData"]]] = {} def __init__(cls, name, bases, attrs): """Initialize the metaclass and register subclasses.""" super().__init__(name, bases, attrs) if not hasattr(cls, "scope_registry"): cls.scope_registry = {} - cls.scope_registry[cls.NAMESPACE] = cls + if not hasattr(cls, "glob_registry"): + cls.glob_registry = {} + + # Register glob classes (they have 'Glob' in their name) + if "Glob" in name and cls.NAMESPACE: + cls.glob_registry[cls.NAMESPACE] = cls + else: + cls.scope_registry[cls.NAMESPACE] = cls def __call__(cls, *args, **kwargs): """Instantiate the appropriate ScopeData subclass dynamically. @@ -166,9 +177,11 @@ def __call__(cls, *args, **kwargs): 1. external_key: Determines subclass from the key format. The namespace prefix before the first ':' is used to look up the appropriate subclass. Example: ScopeData(external_key='lib:DemoX:CSPROB') → ContentLibraryData + Example: ScopeData(external_key='lib:DemoX:*') → OrgLibraryGlobData 2. namespaced_key: Determines subclass from the namespace prefix before '^'. Example: ScopeData(namespaced_key='lib^lib:DemoX:CSPROB') → ContentLibraryData + Example: ScopeData(namespaced_key='lib^lib:DemoX:*') → OrgLibraryGlobData Usage patterns: - namespaced_key: Used when retrieving objects from the policy store @@ -179,6 +192,10 @@ def __call__(cls, *args, **kwargs): >>> scope = ScopeData(external_key='lib:DemoX:CSPROB') >>> isinstance(scope, ContentLibraryData) True + >>> # From glob external key + >>> scope = ScopeData(external_key='lib:DemoX:*') + >>> isinstance(scope, OrgLibraryGlobData) + True >>> # From namespaced key (e.g., policy store) >>> scope = ScopeData(namespaced_key='lib^lib:DemoX:CSPROB') >>> isinstance(scope, ContentLibraryData) @@ -210,18 +227,23 @@ def get_subclass_by_namespaced_key(mcs, namespaced_key: str) -> Type["ScopeData" """Get the appropriate ScopeData subclass from the namespaced key. Extracts the namespace prefix (before '^') and returns the registered subclass. + If the key contains a wildcard, returns the appropriate glob subclass. Args: - namespaced_key: The namespaced key (e.g., 'lib^lib:DemoX:CSPROB', 'global^generic'). + namespaced_key: The namespaced key (e.g., 'lib^lib:DemoX:CSPROB', 'lib^lib:DemoX:*', 'global^generic'). Returns: The ScopeData subclass for the namespace, or ScopeData if namespace not recognized. Examples: - >>> ScopeMeta.get_subclass_by_namespaced_key('course-v1^course-v1:WGU+CS002+2025_T1') + >>> ScopeMeta.get_subclass_by_namespaced_key('course-v1^course-v1:WGU+CS002+2025_T1') >>> ScopeMeta.get_subclass_by_namespaced_key('lib^lib:DemoX:CSPROB') + >>> ScopeMeta.get_subclass_by_namespaced_key('course-v1^course-v1:WGU+*') + + >>> ScopeMeta.get_subclass_by_namespaced_key('lib^lib:DemoX:*') + >>> ScopeMeta.get_subclass_by_namespaced_key('global^generic') """ @@ -229,7 +251,16 @@ def get_subclass_by_namespaced_key(mcs, namespaced_key: str) -> Type["ScopeData" if not re.match(NAMESPACED_KEY_PATTERN, namespaced_key): raise ValueError(f"Invalid namespaced_key format: {namespaced_key}") - namespace = namespaced_key.split(AUTHZ_POLICY_ATTRIBUTES_SEPARATOR, 1)[0] + namespace, external_key = namespaced_key.split(AUTHZ_POLICY_ATTRIBUTES_SEPARATOR, 1) + + # Check if this is a glob pattern (contains wildcard) + is_glob = GLOBAL_SCOPE_WILDCARD in external_key + + if is_glob: + # Try to get glob-specific class first + return mcs.glob_registry.get(namespace, ScopeData) + + # Fall back to standard scope class return mcs.scope_registry.get(namespace, ScopeData) @classmethod @@ -239,11 +270,15 @@ def get_subclass_by_external_key(mcs, external_key: str) -> Type["ScopeData"]: Extracts the namespace from the external key (before the first ':') and validates the key format using the subclass's validate_external_key method. + This method now also handles glob patterns by detecting wildcards and returning + the appropriate glob subclass instead of the standard scope class. + Args: - external_key: The external key (e.g., 'lib:DemoX:CSPROB', 'global:generic'). + external_key: The external key (e.g., 'lib:DemoX:CSPROB', 'lib:DemoX:*', 'global:generic'). Returns: The ScopeData subclass corresponding to the namespace. + If the key contains a wildcard, returns the corresponding glob subclass. Raises: ValueError: If the external_key format is invalid or namespace is not recognized. @@ -251,10 +286,17 @@ def get_subclass_by_external_key(mcs, external_key: str) -> Type["ScopeData"]: Examples: >>> ScopeMeta.get_subclass_by_external_key('lib:DemoX:CSPROB') + >>> ScopeMeta.get_subclass_by_external_key('course-v1:OpenedX+CS101+2024') + + >>> ScopeMeta.get_subclass_by_external_key('lib:DemoX:*') + + >>> ScopeMeta.get_subclass_by_external_key('course-v1:OpenedX+*') + Notes: - The external_key format should be 'namespace:some-identifier' (e.g., 'lib:DemoX:CSPROB'). - The namespace prefix before ':' is used to determine the subclass. + - If a wildcard is detected, the glob_registry is consulted first. - Each subclass must implement validate_external_key() to verify the full key format. - This won't work for org scopes that don't have explicit namespace prefixes. TODO: Handle org scopes differently. @@ -263,6 +305,17 @@ def get_subclass_by_external_key(mcs, external_key: str) -> Type["ScopeData"]: raise ValueError(f"Invalid external_key format: {external_key}") namespace = external_key.split(EXTERNAL_KEY_SEPARATOR, 1)[0] + + # Check if this is a glob pattern (contains wildcard) + is_glob = GLOBAL_SCOPE_WILDCARD in external_key + + if is_glob: + # Try to get glob-specific class first + glob_subclass = mcs.glob_registry.get(namespace) + if glob_subclass and glob_subclass.validate_external_key(external_key): + return glob_subclass + + # Fall back to standard scope class scope_subclass = mcs.scope_registry.get(namespace) if not scope_subclass: @@ -325,6 +378,7 @@ class ScopeData(AuthZData, metaclass=ScopeMeta): # 2. Custom global scopes that don't map to specific domain objects (e.g., 'global:some_scope') # Subclasses like ContentLibraryData ('lib') represent concrete resource types with their own namespaces. NAMESPACE: ClassVar[str] = "global" + IS_GLOB: ClassVar[bool] = False @classmethod def validate_external_key(cls, _: str) -> bool: @@ -373,11 +427,12 @@ class ContentLibraryData(ScopeData): Content libraries use the LibraryLocatorV2 format for identification. Attributes: - NAMESPACE: 'lib' for content library scopes. - external_key: The content library identifier (e.g., 'lib:DemoX:CSPROB'). + NAMESPACE (str): 'lib' for content library scopes. + ID_SEPARATOR (str): ':' for content library scopes. + external_key (str): The content library identifier (e.g., 'lib:DemoX:CSPROB'). Must be a valid LibraryLocatorV2 format. - namespaced_key: The library identifier with namespace (e.g., 'lib^lib:DemoX:CSPROB'). - library_id: Property alias for external_key. + namespaced_key (str): The library identifier with namespace (e.g., 'lib^lib:DemoX:CSPROB'). + library_id (str): Property alias for external_key. Examples: >>> library = ContentLibraryData(external_key='lib:DemoX:CSPROB') @@ -391,6 +446,7 @@ class ContentLibraryData(ScopeData): """ NAMESPACE: ClassVar[str] = "lib" + ID_SEPARATOR: ClassVar[str] = ":" @property def library_id(self) -> str: @@ -470,6 +526,135 @@ def __repr__(self): return self.namespaced_key +@define +class OrgLibraryGlobData(ContentLibraryData): + """Organization-level glob pattern for content libraries. + + This class represents glob patterns that match multiple libraries within an organization. + Format: ``lib:ORG:*`` where ORG is a valid organization identifier. + + The glob pattern allows granting permissions to all libraries within a specific organization + without needing to specify each library individually. + + Attributes: + NAMESPACE (str): Inherited 'lib' from ContentLibraryData. + ID_SEPARATOR (str): ':' for content library scopes. + IS_GLOB (bool): True for scope data that represents a glob pattern. + external_key (str): The glob pattern (e.g., ``lib:DemoX:*``). + namespaced_key (str): The pattern with namespace (e.g., ``lib^lib:DemoX:*``). + + Validation Rules: + - Must end with GLOBAL_SCOPE_WILDCARD (``*``) + - Must have format ``lib:ORG:*`` (exactly one organization identifier) + - The organization must exist in at least one ContentLibrary + - Wildcard can only appear at the end after org identifier + - Cannot have wildcards at slug level (``lib:ORG:SLUG*`` is invalid) + + Examples: + >>> glob = OrgLibraryGlobData(external_key='lib:DemoX:*') + >>> glob.org + 'DemoX' + + Note: + This class is automatically instantiated by the ScopeMeta metaclass when + a library scope with a wildcard is created. + """ + + IS_GLOB: ClassVar[bool] = True + + @property + def org(self) -> str | None: + """Get the organization identifier from the glob pattern. + + Returns: + str: The organization identifier (e.g., ``DemoX`` from ``lib:DemoX:*``), None otherwise. + """ + return self.get_org(self.external_key) + + @classmethod + def validate_external_key(cls, external_key: str) -> bool: + """Validate the external_key format for organization-level library globs. + + Args: + external_key (str): The external key to validate (e.g., ``lib:DemoX:*``). + + Returns: + bool: True if the format is valid, False otherwise. + """ + if not external_key.startswith(cls.NAMESPACE + EXTERNAL_KEY_SEPARATOR): + return False + + # Enforce explicit org-level separator: 'lib:ORG:*' + suffix = cls.ID_SEPARATOR + GLOBAL_SCOPE_WILDCARD + if not external_key.endswith(suffix): + return False + + org = cls.get_org(external_key) + if org is None: + return False + + if not ALLOWED_CHARS_PATTERN.match(org): + return False + + return True + + @classmethod + def get_org(cls, external_key: str) -> str | None: + """Extract the organization identifier from the glob pattern. + + Args: + external_key (str): The external key to extract the organization identifier from. + + Returns: + str: The organization identifier (e.g., ``DemoX`` from ``lib:DemoX:*``), None otherwise. + """ + suffix = cls.ID_SEPARATOR + GLOBAL_SCOPE_WILDCARD + if not external_key.endswith(suffix): + return None + + scope_prefix = external_key[: -len(suffix)] + parts = scope_prefix.split(EXTERNAL_KEY_SEPARATOR) + + if len(parts) != 2 or not parts[1]: + return None + + return parts[1] + + @classmethod + def org_exists(cls, org: str) -> bool: + """Check if at least one content library exists with the given organization. + + Args: + org (str): Organization identifier to check. + + Returns: + bool: True if at least one library with this org exists, False otherwise. + """ + lib_obj = ContentLibrary.objects.filter(org__short_name=org).only("org").last() + return lib_obj is not None and lib_obj.org.short_name == org + + def get_object(self) -> None: + """Glob patterns don't represent a single object. + + Returns: + None: Glob patterns match multiple objects, not a single one. + """ + return None + + def exists(self) -> bool: + """Check if the glob pattern is valid. + + For glob patterns, existence means the organization exists, + not that a specific library exists. + + Returns: + bool: True if the organization exists, False otherwise. + """ + if self.org is None: + return False + return self.org_exists(self.org) + + @define class CourseOverviewData(ScopeData): """A course scope for authorization in the Open edX platform. @@ -477,11 +662,13 @@ class CourseOverviewData(ScopeData): Courses uses the CourseKey format for identification. Attributes: - NAMESPACE: 'course-v1' for course scopes. - external_key: The course identifier (e.g., 'course-v1:TestOrg+TestCourse+2024_T1'). + NAMESPACE (str): 'course-v1' for course scopes. + ID_SEPARATOR (str): '+' for course scopes. + external_key (str): The course identifier (e.g., 'course-v1:TestOrg+TestCourse+2024_T1'). Must be a valid CourseKey format. - namespaced_key: The course identifier with namespace (e.g., 'course-v1^course-v1:TestOrg+TestCourse+2024_T1'). - course_id: Property alias for external_key. + namespaced_key (str): The course identifier with namespace + (e.g., 'course-v1^course-v1:TestOrg+TestCourse+2024_T1'). + course_id (str): Property alias for external_key. Examples: >>> course = CourseOverviewData(external_key='course-v1:TestOrg+TestCourse+2024_T1') @@ -489,10 +676,10 @@ class CourseOverviewData(ScopeData): 'course-v1^course-v1:TestOrg+TestCourse+2024_T1' >>> course.course_id 'course-v1:TestOrg+TestCourse+2024_T1' - """ NAMESPACE: ClassVar[str] = "course-v1" + ID_SEPARATOR: ClassVar[str] = "+" @property def course_id(self) -> str: @@ -572,6 +759,135 @@ def __repr__(self): return self.namespaced_key +@define +class OrgCourseGlobData(CourseOverviewData): + """Organization-level glob pattern for courses. + + This class represents glob patterns that match multiple courses within an organization. + Format: 'course-v1:ORG+*' where ORG is a valid organization identifier. + + The glob pattern allows granting permissions to all courses within a specific organization + without needing to specify each course individually. + + Attributes: + NAMESPACE (str): Inherited 'course-v1' from CourseOverviewData. + ID_SEPARATOR (str): '+' for course scopes. + IS_GLOB (bool): True for scope data that represents a glob pattern. + external_key (str): The glob pattern (e.g., 'course-v1:OpenedX+*'). + namespaced_key (str): The pattern with namespace (e.g., 'course-v1^course-v1:OpenedX+*'). + + Validation Rules: + - Must end with GLOBAL_SCOPE_WILDCARD (``*``) + - Must have format 'course-v1:ORG+*' (exactly one organization identifier) + - The organization must exist in at least one CourseOverview + - Wildcard can only appear at the end after org identifier + - Cannot have wildcards at course or run level (course-v1:ORG+COURSE* is invalid) + + Examples: + >>> glob = OrgCourseGlobData(external_key='course-v1:OpenedX+*') + >>> glob.org + 'OpenedX' + + Note: + This class is automatically instantiated by the ScopeMeta metaclass when + a course scope with a wildcard is created. + """ + + IS_GLOB: ClassVar[bool] = True + + @property + def org(self) -> str | None: + """Get the organization identifier from the glob pattern. + + Returns: + str | None: The organization identifier (e.g., 'OpenedX' from 'course-v1:OpenedX+*'), None otherwise. + """ + return self.get_org(self.external_key) + + @classmethod + def validate_external_key(cls, external_key: str) -> bool: + """Validate the external_key format for organization-level course globs. + + Args: + external_key (str): The external key to validate (e.g., 'course-v1:OpenedX+*'). + + Returns: + bool: True if the format is valid, False otherwise. + """ + if not external_key.startswith(cls.NAMESPACE + EXTERNAL_KEY_SEPARATOR): + return False + + # Enforce explicit org-level separator: 'course-v1:ORG+*' + suffix = cls.ID_SEPARATOR + GLOBAL_SCOPE_WILDCARD + if not external_key.endswith(suffix): + return False + + org = cls.get_org(external_key) + if org is None: + return False + + if not ALLOWED_CHARS_PATTERN.match(org): + return False + + return True + + @classmethod + def get_org(cls, external_key: str) -> str | None: + """Extract the organization identifier from the glob pattern. + + Args: + external_key (str): The external key to extract the organization identifier from. + + Returns: + str | None: The organization identifier (e.g., 'OpenedX' from 'course-v1:OpenedX+*'), None otherwise. + """ + suffix = cls.ID_SEPARATOR + GLOBAL_SCOPE_WILDCARD + if not external_key.endswith(suffix): + return None + + scope_prefix = external_key[: -len(suffix)] + parts = scope_prefix.split(EXTERNAL_KEY_SEPARATOR) + + if len(parts) != 2 or not parts[1]: + return None + + return parts[1] + + @classmethod + def org_exists(cls, org: str) -> bool: + """Check if at least one course exists with the given organization. + + Args: + org (str): Organization identifier to check. + + Returns: + bool: True if at least one course with this org exists, False otherwise. + """ + course_obj = CourseOverview.objects.filter(org=org).only("org").last() + return course_obj is not None and course_obj.org == org + + def get_object(self) -> None: + """Glob patterns don't represent a single object. + + Returns: + None: Glob patterns match multiple objects, not a single one. + """ + return None + + def exists(self) -> bool: + """Check if the glob pattern is valid. + + For glob patterns, existence means the organization exists, + not that a specific course exists. + + Returns: + bool: True if the organization exists, False otherwise. + """ + if self.org is None: + return False + return self.org_exists(self.org) + + class SubjectMeta(type): """Metaclass for SubjectData to handle dynamic subclass instantiation based on namespace.""" diff --git a/openedx_authz/engine/enforcer.py b/openedx_authz/engine/enforcer.py index 123b1d75..c8f7ace9 100644 --- a/openedx_authz/engine/enforcer.py +++ b/openedx_authz/engine/enforcer.py @@ -20,6 +20,7 @@ from uuid import uuid4 from casbin import SyncedEnforcer +from casbin.util import key_match_func from casbin.util.log import DEFAULT_LOGGING, configure_logging from casbin_adapter.enforcer import initialize_enforcer from django.conf import settings @@ -279,5 +280,6 @@ def _initialize_enforcer(cls) -> SyncedEnforcer: adapter = ExtendedAdapter() enforcer = SyncedEnforcer(settings.CASBIN_MODEL, adapter) enforcer.add_function("is_staff_or_superuser", is_admin_or_superuser_check) + enforcer.add_named_domain_matching_func("g", key_match_func) return enforcer diff --git a/openedx_authz/models/scopes.py b/openedx_authz/models/scopes.py index 24b01556..b9e7d951 100644 --- a/openedx_authz/models/scopes.py +++ b/openedx_authz/models/scopes.py @@ -81,7 +81,7 @@ class ContentLibraryScope(Scope): ) @classmethod - def get_or_create_for_external_key(cls, scope): + def get_or_create_for_external_key(cls, scope) -> "ContentLibraryScope": """Get or create a ContentLibraryScope for the given external key. Args: @@ -89,8 +89,14 @@ def get_or_create_for_external_key(cls, scope): a LibraryLocatorV2-compatible string. Returns: - ContentLibraryScope: The Scope instance for the given ContentLibrary + ContentLibraryScope: The Scope instance for the given ContentLibrary, + or None if the scope is a glob pattern (contains wildcard). """ + # For glob scopes we don't create a Scope object since + # they don't represent a specific content library + if scope.IS_GLOB: + return None + library_key = LibraryLocatorV2.from_string(scope.external_key) content_library = ContentLibrary.objects.get_by_key(library_key) scope, _ = cls.objects.get_or_create(content_library=content_library) @@ -124,7 +130,7 @@ class CourseScope(Scope): ) @classmethod - def get_or_create_for_external_key(cls, scope): + def get_or_create_for_external_key(cls, scope) -> "CourseScope": """Get or create a CourseScope for the given external key. Args: @@ -132,8 +138,14 @@ def get_or_create_for_external_key(cls, scope): a CourseKey string. Returns: - CourseScope: The Scope instance for the given CourseOverview + CourseScope: The Scope instance for the given CourseOverview, + or None if the scope is a glob pattern (contains wildcard). """ + # For glob scopes we don't create a Scope object + # since they don't represent a specific course + if scope.IS_GLOB: + return None + course_key = CourseKey.from_string(scope.external_key) course_overview = CourseOverview.get_from_id(course_key) scope, _ = cls.objects.get_or_create(course_overview=course_overview) diff --git a/openedx_authz/tests/api/test_data.py b/openedx_authz/tests/api/test_data.py index 9807f4e9..2dd57a70 100644 --- a/openedx_authz/tests/api/test_data.py +++ b/openedx_authz/tests/api/test_data.py @@ -3,13 +3,15 @@ from unittest.mock import Mock, patch from ddt import data, ddt, unpack -from django.test import TestCase +from django.test import TestCase, override_settings from opaque_keys.edx.locator import LibraryLocatorV2 from openedx_authz.api.data import ( ActionData, ContentLibraryData, CourseOverviewData, + OrgCourseGlobData, + OrgLibraryGlobData, PermissionData, RoleAssignmentData, RoleData, @@ -19,6 +21,7 @@ UserData, ) from openedx_authz.constants import permissions, roles +from openedx_authz.tests.stubs.models import ContentLibrary, CourseOverview, Organization @ddt @@ -233,9 +236,17 @@ def test_scope_data_registration(self): self.assertIn("course-v1", ScopeData.scope_registry) self.assertIs(ScopeData.scope_registry["course-v1"], CourseOverviewData) + # Glob registries for organization-level scopes + self.assertIn("lib", ScopeMeta.glob_registry) + self.assertIs(ScopeMeta.glob_registry["lib"], OrgLibraryGlobData) + self.assertIn("course-v1", ScopeMeta.glob_registry) + self.assertIs(ScopeMeta.glob_registry["course-v1"], OrgCourseGlobData) + @data( ("course-v1^course-v1:WGU+CS002+2025_T1", CourseOverviewData), ("lib^lib:DemoX:CSPROB", ContentLibraryData), + ("lib^lib:DemoX*", OrgLibraryGlobData), + ("course-v1^course-v1:OpenedX*", OrgCourseGlobData), ("global^generic_scope", ScopeData), ) @unpack @@ -254,6 +265,8 @@ def test_dynamic_instantiation_via_namespaced_key(self, namespaced_key, expected @data( ("course-v1^course-v1:WGU+CS002+2025_T1", CourseOverviewData), ("lib^lib:DemoX:CSPROB", ContentLibraryData), + ("lib^lib:DemoX:*", OrgLibraryGlobData), + ("course-v1^course-v1:OpenedX+*", OrgCourseGlobData), ("global^generic", ScopeData), ("unknown^something", ScopeData), ) @@ -273,6 +286,8 @@ def test_get_subclass_by_namespaced_key(self, namespaced_key, expected_class): @data( ("course-v1:WGU+CS002+2025_T1", CourseOverviewData), ("lib:DemoX:CSPROB", ContentLibraryData), + ("lib:DemoX:*", OrgLibraryGlobData), + ("course-v1:OpenedX+*", OrgCourseGlobData), ("lib:edX:Demo", ContentLibraryData), ("global:generic_scope", ScopeData), ) @@ -310,6 +325,50 @@ def test_scope_validate_external_key(self, external_key, expected_valid, expecte self.assertEqual(result, expected_valid) + def test_get_subclass_by_external_key_unknown_scope_raises_value_error(self): + """Unknown namespace should raise ValueError in get_subclass_by_external_key.""" + with self.assertRaises(ValueError): + ScopeMeta.get_subclass_by_external_key("unknown:DemoX") + + def test_get_subclass_by_external_key_invalid_format_raises_value_error(self): + """Invalid format (fails subclass.validate_external_key) should raise ValueError.""" + with self.assertRaises(ValueError): + ScopeMeta.get_subclass_by_external_key("lib:invalid_library_key") + + def test_scope_meta_initializes_registries_when_missing(self): + """ScopeMeta should create registries if they don't exist on initialization. + + This validates the defensive branch in ScopeMeta.__init__ that initializes + scope_registry and glob_registry when they are not present on the class. + """ + original_scope_registry = ScopeMeta.scope_registry + original_glob_registry = ScopeMeta.glob_registry + + try: + # Simulate an environment where the registries are not yet defined + del ScopeMeta.scope_registry + del ScopeMeta.glob_registry + + class TempScope(ScopeData): + """Temporary scope class for testing.""" + NAMESPACE = "temp" + + def get_object(self): + return None + + def exists(self) -> bool: + return False + + # Metaclass should have recreated the registries on the class + self.assertTrue(hasattr(TempScope, "scope_registry")) + self.assertTrue(hasattr(TempScope, "glob_registry")) + # And the new scope should be registered under its namespace + self.assertIs(TempScope.scope_registry.get("temp"), TempScope) + finally: + # Restore original registries to avoid side effects on other tests + ScopeMeta.scope_registry = original_scope_registry + ScopeMeta.glob_registry = original_glob_registry + def test_direct_subclass_instantiation_bypasses_metaclass(self): """Test that direct subclass instantiation doesn't trigger metaclass logic. @@ -654,3 +713,158 @@ def test_exists_returns_false_when_library_does_not_exist(self, mock_content_lib result = library_scope.exists() self.assertFalse(result) + + +@ddt +@override_settings(OPENEDX_AUTHZ_CONTENT_LIBRARY_MODEL="content_libraries.ContentLibrary") +class TestOrgLibraryGlobData(TestCase): + """Tests for the OrgLibraryGlobData scope.""" + + @data( + ("lib:DemoX:*", True), + ("lib:Org-123:*", True), + ("lib:Org.with.dots:*", True), + ("lib:Org With Space:*", False), + ("lib:Org/With/Slash:*", False), + ("lib:Org\\With\\Backslash:*", False), + ("lib:Org,With,Comma:*", False), + ("lib:Org;With;Semicolon:*", False), + ("lib:Org@WithAt:*", False), + ("lib:Org#WithHash:*", False), + ("lib:Org$WithDollar:*", False), + ("lib:Org&WithAmp:*", False), + ("lib:Org+WithPlus:*", False), + ("lib:(Org):*", False), + ("lib:Org", False), + ("other:DemoX:*", False), + ("lib:DemoX:*:*", False), + ) + @unpack + def test_validate_external_key(self, external_key, expected_valid): + """Validate organization-level library glob external keys.""" + self.assertEqual(OrgLibraryGlobData.validate_external_key(external_key), expected_valid) + + @data( + ("lib:DemoX:*", "DemoX"), + ("lib:Org-123:*", "Org-123"), + ("lib:Org.with.dots:*", "Org.with.dots"), + ("lib:Org:With:Colon:*", None), + ("lib:*", None), + ("lib:DemoX", None), + ("lib:DemoX:*:*", None), + ) + @unpack + def test_get_org(self, external_key, expected_org): + """Test organization extraction from library glob pattern.""" + self.assertEqual(OrgLibraryGlobData.get_org(external_key), expected_org) + + def test_exists_true_when_org_has_libraries_in_db(self): + """exists() returns True when at least one library with the org exists in the DB.""" + org_name = "DemoX" + organization = Organization.objects.create(short_name=org_name) + ContentLibrary.objects.create(org=organization, slug="testlib", title="Test Library") + + result = OrgLibraryGlobData(external_key=f"lib:{org_name}:*").exists() + + self.assertTrue(result) + + def test_exists_false_when_org_does_not_exist_in_db(self): + """exists() returns False when the org does not exist in the DB.""" + org_name = "DemoX" + + result = OrgLibraryGlobData(external_key=f"lib:{org_name}:*").exists() + + self.assertFalse(result) + + def test_exists_false_when_org_exists_but_no_libraries_in_db(self): + """exists() returns False when the org exists but no libraries exist in the DB.""" + org_name = "DemoX" + Organization.objects.create(short_name=org_name) + + result = OrgLibraryGlobData(external_key=f"lib:{org_name}:*").exists() + + self.assertFalse(result) + + def test_exists_false_when_org_cannot_be_parsed(self): + """exists() returns False when org property is None (invalid pattern).""" + scope = OrgLibraryGlobData(external_key="lib:Org:With:Colon:*") + + self.assertIsNone(scope.org) + self.assertFalse(scope.exists()) + + +@ddt +@override_settings(OPENEDX_AUTHZ_COURSE_OVERVIEW_MODEL="course_overviews.CourseOverview") +class TestOrgCourseGlobData(TestCase): + """Tests for the OrgCourseGlobData scope.""" + + @data( + ("course-v1:OpenedX+*", True), + ("course-v1:My-Org_1+*", True), + ("course-v1:Org.with.dots+*", True), + ("course-v1:Org With Space+*", False), + ("course-v1:Org/With/Slash+*", False), + ("course-v1:Org\\With\\Backslash+*", False), + ("course-v1:Org,With,Comma+*", False), + ("course-v1:Org;With;Semicolon+*", False), + ("course-v1:Org@WithAt+*", False), + ("course-v1:Org#WithHash+*", False), + ("course-v1:Org$WithDollar+*", False), + ("course-v1:Org&WithAmp+*", False), + ("course-v1:Org+WithPlus+*", False), + ("course-v1:(Org)+*", False), + ("course-v1:Org:With:Plus+*", False), + ("course-v1:OpenedX", False), + ("other:OpenedX+*", False), + ("course-v1:OpenedX**", False), + ) + @unpack + def test_validate_external_key(self, external_key, expected_valid): + """Validate organization-level course glob external keys.""" + self.assertEqual(OrgCourseGlobData.validate_external_key(external_key), expected_valid) + + @data( + ("course-v1:OpenedX+*", "OpenedX"), + ("course-v1:My-Org_1+*", "My-Org_1"), + ("course-v1:Org.with.dots+*", "Org.with.dots"), + ("course-v1:Org:With:Plus+*", None), + ("course-v1:*", None), + ) + @unpack + def test_get_org(self, external_key, expected_org): + """Test organization extraction from course glob pattern.""" + self.assertEqual(OrgCourseGlobData.get_org(external_key), expected_org) + + def test_exists_true_when_org_has_courses(self): + """exists() returns True when at least one course with the org exists.""" + org_name = "OpenedX" + Organization.objects.create(short_name=org_name) + CourseOverview.objects.create(org=org_name, display_name="Test Course") + + result = OrgCourseGlobData(external_key=f"course-v1:{org_name}+*").exists() + + self.assertTrue(result) + + def test_exists_false_when_org_does_not_exist(self): + """exists() returns False when the org does not exist.""" + org_name = "OpenedX" + + result = OrgCourseGlobData(external_key=f"course-v1:{org_name}+*").exists() + + self.assertFalse(result) + + def test_exists_false_when_org_exists_but_no_courses(self): + """exists() returns False when the org exists but no courses exist.""" + org_name = "OpenedX" + Organization.objects.create(short_name=org_name) + + result = OrgCourseGlobData(external_key=f"course-v1:{org_name}+*").exists() + + self.assertFalse(result) + + def test_exists_false_when_org_cannot_be_parsed(self): + """exists() returns False when org property is None (invalid pattern).""" + scope = OrgCourseGlobData(external_key="course-v1:Org:With:Colon+*") + + self.assertIsNone(scope.org) + self.assertFalse(scope.exists()) diff --git a/openedx_authz/tests/test_enforcement.py b/openedx_authz/tests/test_enforcement.py index d227f800..d7a378ed 100644 --- a/openedx_authz/tests/test_enforcement.py +++ b/openedx_authz/tests/test_enforcement.py @@ -11,19 +11,22 @@ import casbin import pytest +from casbin.util import key_match_func from ddt import data, ddt, unpack from django.contrib.auth import get_user_model from openedx_authz import ROOT_DIRECTORY -from openedx_authz.api.data import GLOBAL_SCOPE_WILDCARD -from openedx_authz.constants import roles +from openedx_authz.api.data import GLOBAL_SCOPE_WILDCARD, ContentLibraryData, CourseOverviewData +from openedx_authz.constants import permissions, roles from openedx_authz.engine.matcher import is_admin_or_superuser_check from openedx_authz.tests.test_utils import ( make_action_key, + make_course_key, make_library_key, make_role_key, make_scope_key, make_user_key, + make_wildcard_key, ) User = get_user_model() @@ -73,6 +76,7 @@ def setUpClass(cls) -> None: cls.enforcer = casbin.Enforcer(model_file) cls.enforcer.add_function("is_staff_or_superuser", is_admin_or_superuser_check) + cls.enforcer.add_named_domain_matching_func("g", key_match_func) def _load_policy(self, policy: list[str]) -> None: """ @@ -583,6 +587,82 @@ def test_wildcard_library_access(self, scope: str, expected_result: bool): self._test_enforcement(self.POLICY, request) +@ddt +class OrgGlobEnforcementTests(CasbinEnforcementTestCase): + """ + Tests for organization-level glob patterns in course and library scopes. + + This test class verifies that policies defined with org-level glob patterns + (e.g., "course-v1:OpenedX*" or "lib:DemoX*") are correctly enforced for + concrete course and library scopes that belong to those organizations. + """ + + POLICY = [ + # Policies + [ + "p", + make_role_key(roles.COURSE_STAFF.external_key), + make_action_key("courses.view_course"), + make_wildcard_key(CourseOverviewData.NAMESPACE), + "allow", + ], + [ + "p", + make_role_key(roles.LIBRARY_ADMIN.external_key), + make_action_key("content_libraries.view_library"), + make_wildcard_key(ContentLibraryData.NAMESPACE), + "allow", + ], + # Role assignments + [ + "g", + make_user_key("user-1"), + make_role_key(roles.COURSE_STAFF.external_key), + make_course_key("course-v1:OpenedX*"), + ], + [ + "g", + make_user_key("user-2"), + make_role_key(roles.LIBRARY_ADMIN.external_key), + make_library_key("lib:DemoX*"), + ], + ] + + CASES = [ + # Permission granted + { + "subject": make_user_key("user-1"), + "action": make_action_key(permissions.COURSES_VIEW_COURSE.action.external_key), + "scope": make_course_key("course-v1:OpenedX+DemoCourse+2026_T1"), + "expected_result": True, + }, + { + "subject": make_user_key("user-2"), + "action": make_action_key(permissions.VIEW_LIBRARY.action.external_key), + "scope": make_library_key("lib:DemoX:OrgGlobLib"), + "expected_result": True, + }, + # Permission denied + { + "subject": make_user_key("user-1"), + "action": make_action_key(permissions.COURSES_VIEW_COURSE.action.external_key), + "scope": make_course_key("course-v1:InexistentOrg+DemoCourse+2026_T1"), + "expected_result": False, + }, + { + "subject": make_user_key("user-2"), + "action": make_action_key(permissions.VIEW_LIBRARY.action.external_key), + "scope": make_library_key("lib:InexistentOrg:OrgGlobLib"), + "expected_result": False, + }, + ] + + @data(*CASES) + def test_org_level_glob_enforcement(self, request: AuthRequest): + """Test that org-level glob patterns in scopes are enforced correctly.""" + self._test_enforcement(self.POLICY, request) + + @pytest.mark.django_db @ddt class StaffSuperuserAccessTests(CasbinEnforcementTestCase): diff --git a/openedx_authz/tests/test_utils.py b/openedx_authz/tests/test_utils.py index b6b04ad1..52c29a3c 100644 --- a/openedx_authz/tests/test_utils.py +++ b/openedx_authz/tests/test_utils.py @@ -1,6 +1,14 @@ """Test utilities for creating namespaced keys using class constants.""" -from openedx_authz.api.data import GLOBAL_SCOPE_WILDCARD, ActionData, ContentLibraryData, RoleData, ScopeData, UserData +from openedx_authz.api.data import ( + GLOBAL_SCOPE_WILDCARD, + ActionData, + ContentLibraryData, + CourseOverviewData, + RoleData, + ScopeData, + UserData, +) def make_user_key(key: str) -> str: @@ -51,6 +59,18 @@ def make_library_key(key: str) -> str: return f"{ContentLibraryData.NAMESPACE}{ContentLibraryData.SEPARATOR}{key}" +def make_course_key(key: str) -> str: + """Create a namespaced course key. + + Args: + key: The course identifier (e.g., 'course-v1:DemoX+DemoCourse+2026_T1') + + Returns: + str: Namespaced course key (e.g., 'course-v1^course-v1:DemoX+DemoCourse+2026_T1') + """ + return f"{CourseOverviewData.NAMESPACE}{CourseOverviewData.SEPARATOR}{key}" + + def make_scope_key(namespace: str, key: str) -> str: """Create a namespaced scope key with custom namespace.