diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7d04d07c..2426e6a4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,15 +14,23 @@ Change Log Unreleased ********** +1.17.0 - 2026-05-26 +******************* + +Added +===== + +* Add support for platform glob scopes. + 1.16.0 - 2026-05-21 ******************** Changed ======= -* (Patched onto newer changes as well as 0.20.1) Removed checks for libraries v2 when the enforcer is loaded. This was - originally add to improve performance, but a circular import on - openedx-platform caused it to always default to true. This ensures that the +* (Patched onto newer changes as well as 0.20.1) Removed checks for libraries v2 when + the enforcer is loaded. This was originally add to improve performance, but a circular + import on openedx-platform caused it to always default to true. This ensures that the enforcer continues to work even if the circular import is resolved. 1.15.0 - 2026-04-30 diff --git a/openedx_authz/__init__.py b/openedx_authz/__init__.py index 1f0839e0..6b16da84 100644 --- a/openedx_authz/__init__.py +++ b/openedx_authz/__init__.py @@ -4,6 +4,6 @@ import os -__version__ = "1.16.0" +__version__ = "1.17.0" ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__)) diff --git a/openedx_authz/api/data.py b/openedx_authz/api/data.py index 042c2e32..492f837f 100644 --- a/openedx_authz/api/data.py +++ b/openedx_authz/api/data.py @@ -37,6 +37,8 @@ "OrgGlobData", "OrgContentLibraryGlobData", "PermissionData", + "PlatformCourseOverviewGlobData", + "PlatformGlobData", "PolicyIndex", "RoleAssignmentData", "RoleData", @@ -100,18 +102,16 @@ 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"]]] = {} + org_glob_registry: ClassVar[dict[str, Type["ScopeData"]]] = {} + platform_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 = {} - if not hasattr(cls, "glob_registry"): - cls.glob_registry = {} - if cls.IS_GLOB and cls.NAMESPACE: - cls.glob_registry[cls.NAMESPACE] = cls + if cls.IS_PLATFORM_GLOB and cls.NAMESPACE: + cls.platform_glob_registry[cls.NAMESPACE] = cls + elif cls.IS_ORG_GLOB and cls.NAMESPACE: + cls.org_glob_registry[cls.NAMESPACE] = cls else: cls.scope_registry[cls.NAMESPACE] = cls @@ -152,13 +152,14 @@ def __call__(cls, *args, **kwargs): if cls is not ScopeData: return super().__call__(*args, **kwargs) - # When working with global scopes, we can't determine subclass with an external_key since - # a global scope it's not attached to a specific resource type. So we only use * as - # an external_key to mean generic scope which maps to base ScopeData class. - # The only remaining issue is that internally the namespace key used in policies will be - # The global scope namespace (global^*), so we need to handle that case here. + # The bare global wildcard '*' (which maps to 'global^*') is not a valid scope + # type. Reject it early so callers get a clear ValueError. Namespaced wildcards + # such as ScopeData(namespaced_key='lib^*') are still allowed because they carry + # a meaningful namespace prefix. if kwargs.get("external_key") == GLOBAL_SCOPE_WILDCARD: - return super().__call__(*args, **kwargs) + raise ValueError( + "Global scope wildcard '*' is not a valid scope. Use a specific scope key or a namespaced wildcard." + ) if "namespaced_key" in kwargs: scope_cls = cls.get_subclass_by_namespaced_key(kwargs["namespaced_key"]) @@ -170,6 +171,55 @@ def __call__(cls, *args, **kwargs): return super().__call__(*args, **kwargs) + @staticmethod + def _is_platform_glob(external_key: str, namespace: str) -> bool: + """Validate if the external key is a platform glob. + + Platform globs match the exact pattern: namespace:* + Examples: 'lib:*', 'course-v1:*' + + Args: + external_key (str): The external key to check. + namespace (str): The namespace prefix. + + Returns: + bool: True if the key is a platform-level glob pattern. + """ + return external_key == f"{namespace}{EXTERNAL_KEY_SEPARATOR}{GLOBAL_SCOPE_WILDCARD}" + + @staticmethod + def _is_org_glob(external_key: str, namespace: str) -> bool: + """Validate if the external key is an organization-level glob. + + Org globs match patterns of the form: namespace:ORG{separator}* + where ORG is a non-empty organization identifier and {separator} is + namespace-specific (e.g., ':' for libraries, '+' for courses). + Examples: 'lib:DemoX:*', 'course-v1:OpenedX+*' + + This method must be called after confirming the key is not a platform + glob (i.e., _is_platform_glob returned False). + + Args: + external_key (str): The external key to check. + namespace (str): The namespace prefix. + + Returns: + bool: True if the key is an organization-level glob pattern, False otherwise. + """ + prefix = f"{namespace}{EXTERNAL_KEY_SEPARATOR}" + + if not external_key.startswith(prefix): + return False + + if not external_key.endswith(GLOBAL_SCOPE_WILDCARD): + return False + # Extract the portion between the namespace prefix and the trailing wildcard. + # For 'lib:DemoX:*' → middle = 'DemoX:' + # For 'course-v1:OpenedX+*' → middle = 'OpenedX+' + middle = external_key[len(prefix) : -len(GLOBAL_SCOPE_WILDCARD)] + # Require at least 2 chars: one for the org identifier and one for the separator. + return len(middle) >= 2 + @classmethod def get_subclass_by_namespaced_key(mcs, namespaced_key: str) -> Type["ScopeData"]: """Get the appropriate ScopeData subclass from the namespaced key. @@ -181,7 +231,9 @@ def get_subclass_by_namespaced_key(mcs, namespaced_key: str) -> Type["ScopeData" 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. + The ScopeData subclass for the namespace. Namespace wildcards (e.g. + ``lib^*``, ``course-v1^*``) return ScopeData. Unregistered non-glob + namespaces raise ValueError. Examples: >>> ScopeMeta.get_subclass_by_namespaced_key('course-v1^course-v1:WGU+CS002+2025_T1') @@ -192,7 +244,9 @@ def get_subclass_by_namespaced_key(mcs, namespaced_key: str) -> Type["ScopeData" >>> ScopeMeta.get_subclass_by_namespaced_key('lib^lib:DemoX:*') - >>> ScopeMeta.get_subclass_by_namespaced_key('global^generic') + >>> ScopeMeta.get_subclass_by_namespaced_key('lib^*') + + >>> ScopeMeta.get_subclass_by_namespaced_key('course-v1^*') """ # TODO: Default separator, can't access directly from class so made it a constant @@ -205,11 +259,41 @@ def get_subclass_by_namespaced_key(mcs, namespaced_key: str) -> Type["ScopeData" 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) + # Check if this is a platform glob pattern first + if mcs._is_platform_glob(external_key, namespace): + platform_subclass = mcs.platform_glob_registry.get(namespace) + if not platform_subclass: + raise ValueError(f"Unknown platform glob scope: {namespace} for namespaced_key: {namespaced_key}") + return platform_subclass + + # Check if it's an org glob pattern next + if mcs._is_org_glob(external_key, namespace): + org_subclass = mcs.org_glob_registry.get(namespace) + if not org_subclass: + raise ValueError(f"Unknown org glob scope: {namespace} for namespaced_key: {namespaced_key}") + return org_subclass + + # The bare global wildcard has no namespace context and + # breaks permission checks, so it is rejected. + if namespace == ScopeData.NAMESPACE: + raise ValueError( + f"Global scope wildcard '{namespaced_key}' is not a valid scope. " + "Use a specific scope key or a namespaced wildcard." + ) + + # Namespace wildcards (e.g. 'lib^*', 'course-v1^*') are allowed and used internally by the policy store. + if external_key == GLOBAL_SCOPE_WILDCARD: + if namespace not in mcs.scope_registry: + raise ValueError(f"Unknown scope: {namespace} for namespaced_key: {namespaced_key}") + return ScopeData + + raise ValueError(f"Unknown glob pattern: '{namespaced_key}' is not a valid platform or org-level glob") + + # If not a glob, return the standard scope class. + scope_subclass = mcs.scope_registry.get(namespace) + if not scope_subclass: + raise ValueError(f"Unknown scope: {namespace} for namespaced_key: {namespaced_key}") + return scope_subclass @classmethod def get_subclass_by_external_key(mcs, external_key: str) -> Type["ScopeData"]: @@ -244,7 +328,7 @@ def get_subclass_by_external_key(mcs, external_key: str) -> Type["ScopeData"]: 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. + - If a wildcard is detected, the platform_glob_registry or org_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. @@ -258,21 +342,32 @@ def get_subclass_by_external_key(mcs, external_key: str) -> Type["ScopeData"]: is_glob = GLOBAL_SCOPE_WILDCARD in external_key if is_glob: - glob_subclass = mcs.glob_registry.get(namespace) + if mcs._is_platform_glob(external_key, namespace): + platform_subclass = mcs.platform_glob_registry.get(namespace) + + if not platform_subclass: + raise ValueError(f"Unknown platform glob scope: {namespace} for external_key: {external_key}") + if not platform_subclass.validate_external_key(external_key): + raise ValueError(f"Invalid external_key format for platform glob scope: {external_key}") + + return platform_subclass - if not glob_subclass: - raise ValueError(f"Unknown glob scope: {namespace} for external_key: {external_key}") + if mcs._is_org_glob(external_key, namespace): + org_subclass = mcs.org_glob_registry.get(namespace) - if not glob_subclass.validate_external_key(external_key): - raise ValueError(f"Invalid external_key format for glob scope: {external_key}") + if not org_subclass: + raise ValueError(f"Unknown org glob scope: {namespace} for external_key: {external_key}") + if not org_subclass.validate_external_key(external_key): + raise ValueError(f"Invalid external_key format for org glob scope: {external_key}") - return glob_subclass + return org_subclass + + raise ValueError(f"Invalid glob pattern: '{external_key}' is not a valid platform or org-level glob") scope_subclass = mcs.scope_registry.get(namespace) if not scope_subclass: raise ValueError(f"Unknown scope: {namespace} for external_key: {external_key}") - if not scope_subclass.validate_external_key(external_key): raise ValueError(f"Invalid external_key format: {external_key}") @@ -283,8 +378,9 @@ def get_all_namespaces(mcs) -> dict[str, Type["ScopeData"]]: """Get all registered scope namespaces. Returns: - dict[str, Type["ScopeData"]]: A dictionary of all namespace prefixes registered in the scope registry. - Each namespace corresponds to a ScopeData subclass (e.g., 'lib', 'global'). + dict[str, Type["ScopeData"]]: A dictionary of all namespace prefixes + registered in the scope registry. Each namespace corresponds to + a ScopeData subclass (e.g., 'lib', 'global'). Examples: >>> ScopeMeta.get_all_namespaces() @@ -292,6 +388,56 @@ def get_all_namespaces(mcs) -> dict[str, Type["ScopeData"]]: """ return mcs.scope_registry + @classmethod + def get_all_org_glob_namespaces(mcs) -> dict[str, Type["ScopeData"]]: + """Get all registered organization-level glob scope namespaces. + + Returns: + dict[str, Type["ScopeData"]]: A dictionary of all namespace prefixes + registered in the organization glob registry. Each namespace corresponds + to an org-level glob ScopeData subclass (e.g., 'course-v1', 'lib'). + + Examples: + >>> ScopeMeta.get_all_org_glob_namespaces() + {'course-v1': OrgCourseOverviewGlobData, 'lib': OrgContentLibraryGlobData} + """ + return mcs.org_glob_registry + + @classmethod + def get_all_platform_glob_namespaces(mcs) -> dict[str, Type["ScopeData"]]: + """Get all registered platform-level glob scope namespaces. + + Returns: + dict[str, Type["ScopeData"]]: A dictionary of all namespace prefixes + registered in the platform glob registry. Each namespace corresponds + to a platform-level glob ScopeData subclass (e.g., 'course-v1', 'lib'). + + Examples: + >>> ScopeMeta.get_all_platform_glob_namespaces() + {'course-v1': PlatformCourseOverviewGlobData} + """ + return mcs.platform_glob_registry + + @classmethod + def get_all_registered_scopes(mcs) -> list[Type["ScopeData"]]: + """Get all registered scope subclasses across all registries. + + Returns: + list[Type["ScopeData"]]: A unique list of all ScopeData subclasses registered in the standard, + organization glob, and platform glob registries. + + Examples: + >>> ScopeMeta.get_all_registered_scopes() + [ScopeData, ContentLibraryData, OrgCourseOverviewGlobData, PlatformCourseOverviewGlobData] + """ + return list( + { + *mcs.scope_registry.values(), + *mcs.org_glob_registry.values(), + *mcs.platform_glob_registry.values(), + } + ) + @classmethod def validate_external_key(mcs, external_key: str) -> bool: """Validate the external_key format for the subclass. @@ -325,12 +471,18 @@ class ScopeData(AuthZData, metaclass=ScopeMeta): """ # The 'global' namespace is used for scopes that aren't tied to a specific resource type. - # This base class supports: - # 1. Global wildcard scopes (external_key='*') that apply across all resource types - # 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. + # Custom global scopes that don't map to specific domain objects (e.g., 'global:some_scope') + # are supported. Subclasses like ContentLibraryData ('lib') and CourseOverviewData ('course-v1') + # represent concrete resource types with their own namespaces. + # Note: the bare wildcard '*' (global^*) is NOT a valid scope and will raise ValueError. NAMESPACE: ClassVar[str] = "global" - IS_GLOB: ClassVar[bool] = False + IS_ORG_GLOB: ClassVar[bool] = False + IS_PLATFORM_GLOB: ClassVar[bool] = False + + @property + def IS_GLOB(self) -> bool: + """Whether this scope represents a glob pattern (org- or platform-level).""" + return self.IS_ORG_GLOB or self.IS_PLATFORM_GLOB @classmethod def validate_external_key(cls, _: str) -> bool: @@ -680,7 +832,8 @@ class OrgGlobData(ScopeData): namespace-specific (subclasses must define it to match their key format). Attributes: - IS_GLOB (bool): Always True for organization-level glob patterns. + NAMESPACE (str): The namespace prefix for organization-level glob patterns (e.g., ``org``). + IS_ORG_GLOB (bool): Always True for organization-level glob patterns. ID_SEPARATOR (str): Separator used right before the wildcard (e.g., ``:`` or ``+``). ORG_NAME_VALID_PATTERN (re.Pattern | str): Regex used to validate the organization identifier extracted from :attr:`external_key`. @@ -690,7 +843,8 @@ class OrgGlobData(ScopeData): - ``course-v1:DemoX+*`` (all courses in org ``DemoX``) """ - IS_GLOB: ClassVar[bool] = True + NAMESPACE: ClassVar[str] = "org" + IS_ORG_GLOB: ClassVar[bool] = True ID_SEPARATOR: ClassVar[str] ORG_NAME_VALID_PATTERN: ClassVar[re.Pattern] = r"^[a-zA-Z0-9._-]*$" @@ -824,7 +978,7 @@ class OrgContentLibraryGlobData(OrgGlobData): Attributes: NAMESPACE (str): 'lib' for content library scopes. ID_SEPARATOR (str): ':' for content library scopes. - IS_GLOB (bool): True for scope data that represents a glob pattern. + IS_ORG_GLOB (bool): True for scope data that represents an organization-level glob pattern. external_key (str): The glob pattern (e.g., ``lib:DemoX:*``). namespaced_key (str): The pattern with namespace (e.g., ``lib^lib:DemoX:*``). @@ -882,7 +1036,7 @@ class OrgCourseOverviewGlobData(OrgGlobData): Attributes: NAMESPACE (str): 'course-v1' for course scopes. ID_SEPARATOR (str): '+' for course scopes. - IS_GLOB (bool): True for scope data that represents a glob pattern. + IS_ORG_GLOB (bool): True for scope data that represents an organization-level 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+*'). @@ -927,6 +1081,152 @@ def get_admin_manage_permission(cls) -> PermissionData: return COURSES_MANAGE_COURSE_TEAM +@define +class PlatformGlobData(ScopeData): + """Base class for platform-level glob scope keys. + + This represents a platform-wide pattern: it matches "all resources in the platform" + for a given namespace, rather than being limited to a specific organization or concrete + object. The pattern is stored in :attr:`external_key` and **must** consist only of + the namespace followed by the global wildcard (``*``). + + The expected shape is:: + + {NAMESPACE}{EXTERNAL_KEY_SEPARATOR}* + + where ``{NAMESPACE}`` is the resource type namespace (e.g., ``course-v1`` or ``lib``). + + Attributes: + IS_PLATFORM_GLOB (bool): Always True for platform-level glob patterns. + NAMESPACE (str): Must be defined by subclasses (e.g., 'course-v1', 'lib'). + + Examples: + - ``course-v1:*`` (all courses in the platform) + - ``lib:*`` (all libraries in the platform) + + Note: + Subclasses must override NAMESPACE and implement the required abstract methods. + """ + + NAMESPACE: ClassVar[str] = "platform" + IS_PLATFORM_GLOB: ClassVar[bool] = True + + @classmethod + def validate_external_key(cls, external_key: str) -> bool: + """Validate the external_key format for platform-level glob patterns. + + Args: + external_key (str): The external key to validate (e.g., ``course-v1:*`` or ``lib:*``). + + Returns: + bool: True if the format is valid, False otherwise. + """ + return cls.build_external_key() == external_key + + @classmethod + @abstractmethod + def get_admin_view_permission(cls) -> PermissionData: + """Get the permission required to view this scope. + + Returns: + PermissionData: The permission required to view this scope in the admin console. + """ + raise NotImplementedError("Subclasses must implement get_admin_view_permission method.") + + @classmethod + @abstractmethod + def get_admin_manage_permission(cls) -> PermissionData: + """Get the permission required to manage this scope. + + Returns: + PermissionData: The permission required to manage this scope in the admin console. + """ + raise NotImplementedError("Subclasses must implement get_admin_manage_permission method.") + + @classmethod + def build_external_key(cls) -> str: + """Build the external key for all resources in the platform. + + Returns: + str: The external key for the platform-level glob (e.g., ``course-v1:*``). + + Examples: + >>> PlatformCourseOverviewGlobData.build_external_key() + 'course-v1:*' + """ + return f"{cls.NAMESPACE}{EXTERNAL_KEY_SEPARATOR}{GLOBAL_SCOPE_WILDCARD}" + + def get_object(self) -> None: + """Platform-level glob scopes do not map to a concrete domain object. + + Returns: + None: Always returns None. + """ + return None + + def exists(self) -> bool: + """Platform-level glob scopes always exist. + + Returns: + bool: Always True. + """ + return True + + +@define +class PlatformCourseOverviewGlobData(PlatformGlobData): + """Platform-level glob pattern for courses. + + This class represents glob patterns that match all courses in the platform, + Format: ``course-v1:*`` + + The glob pattern allows granting permissions to all courses across the entire + platform without needing to specify organizations or individual courses. + + Attributes: + NAMESPACE (str): 'course-v1' for course scopes. + IS_PLATFORM_GLOB (bool): True for scope data that represents a platform-level glob pattern. + external_key (str): The glob pattern (always ``course-v1:*``). + namespaced_key (str): The pattern with namespace (``course-v1^course-v1:*``). + + Validation Rules: + - Must be exactly ``course-v1:*`` + - Applies to all existing and future courses in the platform + - Does not grant access to other resource types + + Examples: + >>> glob = PlatformCourseOverviewGlobData(external_key='course-v1:*') + >>> glob.exists() + True + >>> glob.namespaced_key + 'course-v1^course-v1:*' + + Note: + This class is automatically instantiated by the ScopeMeta metaclass when + a course scope with the platform wildcard is created. + """ + + NAMESPACE: ClassVar[str] = "course-v1" + + @classmethod + def get_admin_view_permission(cls) -> PermissionData: + """Get the permission required to view this scope. + + Returns: + PermissionData: The permission required to view this scope in the admin console. + """ + return COURSES_VIEW_COURSE_TEAM + + @classmethod + def get_admin_manage_permission(cls) -> PermissionData: + """Get the permission required to manage this scope. + + Returns: + PermissionData: The permission required to manage this scope in the admin console. + """ + return COURSES_MANAGE_COURSE_TEAM + + class CCXCourseOverviewData(CourseOverviewData): """CCX course scope for authorization in the Open edX platform. diff --git a/openedx_authz/api/users.py b/openedx_authz/api/users.py index e84bd590..b9b5e54d 100644 --- a/openedx_authz/api/users.py +++ b/openedx_authz/api/users.py @@ -9,6 +9,8 @@ (e.g., 'user^john_doe'). """ +import logging + from django.contrib.auth import get_user_model from django.db.models import Q @@ -41,6 +43,8 @@ from openedx_authz.api.utils import filter_user_assignments, get_user_assignment_map from openedx_authz.utils import get_user_by_username_or_email +log = logging.getLogger(__name__) + User = get_user_model() @@ -272,16 +276,20 @@ def _filter_allowed_assignments( ) -> list[RoleAssignmentData]: """ Filter the given role assignments to only include those that the user has permission to view. + + Assignments whose scope does not implement ``get_admin_view_permission`` + are skipped with a warning """ if not user_external_key: # If no user is specified, return all assignments return assignments allowed_assignments: list[RoleAssignmentData] = [] for assignment in assignments: - permission = None - - # Get the permission needed to view the specific scope in the admin console - permission = assignment.scope.get_admin_view_permission().identifier + try: + permission = assignment.scope.get_admin_view_permission().identifier + except NotImplementedError: + log.warning("Skipping assignment with unsupported scope %r", assignment.scope.external_key) + continue if permission and is_user_allowed( user_external_key=user_external_key, diff --git a/openedx_authz/engine/matcher.py b/openedx_authz/engine/matcher.py index b1cde5bb..4e626421 100644 --- a/openedx_authz/engine/matcher.py +++ b/openedx_authz/engine/matcher.py @@ -8,6 +8,7 @@ CourseOverviewData, OrgContentLibraryGlobData, OrgCourseOverviewGlobData, + PlatformCourseOverviewGlobData, ScopeData, UserData, ) @@ -21,6 +22,7 @@ (CourseOverviewData.NAMESPACE, CourseOverviewData), (OrgContentLibraryGlobData.NAMESPACE, OrgContentLibraryGlobData), (OrgCourseOverviewGlobData.NAMESPACE, OrgCourseOverviewGlobData), + (PlatformCourseOverviewGlobData.NAMESPACE, PlatformCourseOverviewGlobData), } diff --git a/openedx_authz/migrations/0009_roleassignmentaudit.py b/openedx_authz/migrations/0009_roleassignmentaudit.py index 6680128d..f640c3f7 100644 --- a/openedx_authz/migrations/0009_roleassignmentaudit.py +++ b/openedx_authz/migrations/0009_roleassignmentaudit.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ("openedx_authz", "0008_authzcourseauthoringmigrationrun"), ] diff --git a/openedx_authz/rest_api/v1/permissions.py b/openedx_authz/rest_api/v1/permissions.py index fa55e163..7c29c0ba 100644 --- a/openedx_authz/rest_api/v1/permissions.py +++ b/openedx_authz/rest_api/v1/permissions.py @@ -100,9 +100,7 @@ def get_scope_namespace(self, request) -> str: scopes_list = request.data.get("scopes") if scopes_list and isinstance(scopes_list, list): if not self._scopes_have_homogeneous_namespaces(scopes_list): - raise ValueError( - f"Mixed scope namespaces in bulk request are not allowed: {scopes_list}" - ) + raise ValueError(f"Mixed scope namespaces in bulk request are not allowed: {scopes_list}") scope_value = self.get_scope_value(request) if not scope_value: return self.NAMESPACE diff --git a/openedx_authz/rest_api/v1/serializers.py b/openedx_authz/rest_api/v1/serializers.py index 9a982461..0d7e83bc 100644 --- a/openedx_authz/rest_api/v1/serializers.py +++ b/openedx_authz/rest_api/v1/serializers.py @@ -6,7 +6,7 @@ from rest_framework import serializers from openedx_authz import api -from openedx_authz.api.data import UserAssignments +from openedx_authz.api.data import GLOBAL_SCOPE_WILDCARD, UserAssignments from openedx_authz.rest_api.data import ( AssignmentSortField, ScopesTypeField, @@ -87,6 +87,11 @@ def _validate_scope_and_role(self, scope_value: str, role_value: str) -> None: serializers.ValidationError: If the scope is not registered, doesn't exist, or if the role is not defined in the scope. """ + if scope_value == GLOBAL_SCOPE_WILDCARD: + raise serializers.ValidationError( + {"scope": "Global wildcard scope '*' is not accepted via the API. Use a specific scope key."} + ) + try: scope = api.ScopeData(external_key=scope_value) except ValueError as exc: @@ -215,6 +220,10 @@ def validate_scope(self, value: str) -> api.ScopeData: >>> validate_scope('lib:DemoX:CSPROB') ContentLibraryData(external_key='lib:DemoX:CSPROB') """ + if value == GLOBAL_SCOPE_WILDCARD: + raise serializers.ValidationError( + "Global wildcard scope '*' is not accepted via the API. Use a specific scope key." + ) try: return api.ScopeData(external_key=value) except ValueError as exc: @@ -398,6 +407,8 @@ def get_org(self, obj: api.RoleAssignmentData | api.SuperAdminAssignmentData) -> case api.SuperAdminAssignmentData(): return "*" case api.RoleAssignmentData(): + if obj.scope.IS_PLATFORM_GLOB: + return "*" return getattr(obj.scope, "org", "") def get_scope(self, obj: api.RoleAssignmentData | api.SuperAdminAssignmentData) -> str: diff --git a/openedx_authz/rest_api/v1/views.py b/openedx_authz/rest_api/v1/views.py index 201bd75b..3f061104 100644 --- a/openedx_authz/rest_api/v1/views.py +++ b/openedx_authz/rest_api/v1/views.py @@ -28,6 +28,7 @@ CourseOverviewData, OrgContentLibraryGlobData, OrgCourseOverviewGlobData, + PlatformGlobData, RoleAssignmentData, SuperAdminAssignmentData, UserAssignmentData, @@ -256,7 +257,9 @@ class RoleUserAPIView(APIView): **Authentication and Permissions** - Requires authenticated user. - - Requires ``manage_library_team`` permission for the scope. + + - GET: Requires ``view_library_team`` or ``view_course_team`` permission according to the scope. + - PUT and DELETE: Requires ``manage_library_team`` or ``manage_course_team`` permission according to the scope. **Example Request** @@ -784,7 +787,7 @@ def _get_allowed_scope_queryset( *, username: str, scope_cls: type, - glob_cls: type, + org_glob_cls: type, get_permission: callable, queryset_builder: callable, extract_ids: callable, @@ -801,7 +804,7 @@ def _get_allowed_scope_queryset( Args: username: The username to check permissions for. scope_cls: The concrete scope data class (e.g., CourseOverviewData). - glob_cls: The org-level glob class (e.g., OrgCourseOverviewGlobData). + org_glob_cls: The org-level glob class (e.g., OrgCourseOverviewGlobData). get_permission: Callable that returns the permission for a scope class. queryset_builder: Callable that builds the filtered queryset (e.g., _get_courses_queryset). extract_ids: Callable that extracts specific IDs from non-glob scopes. @@ -812,9 +815,14 @@ def _get_allowed_scope_queryset( QuerySet: The filtered queryset projected to the unified scope shape. """ allowed_scopes = get_scopes_for_user_and_permission(username, get_permission(scope_cls).identifier) - specific_scopes = [s for s in allowed_scopes if not isinstance(s, glob_cls)] + + has_platform_access = any(isinstance(s, PlatformGlobData) for s in allowed_scopes) + if has_platform_access: + return queryset_builder(allowed_ids=None, allowed_orgs=None, search=search, orgs=orgs) + + specific_scopes = [s for s in allowed_scopes if not s.IS_GLOB] allowed_ids = extract_ids(specific_scopes) - allowed_orgs = {s.org for s in allowed_scopes if isinstance(s, glob_cls)} + allowed_orgs = {s.org for s in allowed_scopes if isinstance(s, org_glob_cls)} return queryset_builder(allowed_ids, allowed_orgs, search=search, orgs=orgs) def _build_queryset(self, courses_qs: QuerySet | None, libraries_qs: QuerySet | None) -> QuerySet: @@ -873,7 +881,7 @@ def get_permission(scope_cls): courses_qs = self._get_allowed_scope_queryset( username=user.username, scope_cls=CourseOverviewData, - glob_cls=OrgCourseOverviewGlobData, + org_glob_cls=OrgCourseOverviewGlobData, get_permission=get_permission, queryset_builder=self._get_courses_queryset, extract_ids=lambda scopes: {s.external_key for s in scopes}, @@ -886,7 +894,7 @@ def get_permission(scope_cls): libraries_qs = self._get_allowed_scope_queryset( username=user.username, scope_cls=ContentLibraryData, - glob_cls=OrgContentLibraryGlobData, + org_glob_cls=OrgContentLibraryGlobData, get_permission=get_permission, queryset_builder=self._get_libraries_queryset, extract_ids=lambda scopes: { diff --git a/openedx_authz/tests/api/test_data.py b/openedx_authz/tests/api/test_data.py index 81d312fd..56e604cc 100644 --- a/openedx_authz/tests/api/test_data.py +++ b/openedx_authz/tests/api/test_data.py @@ -14,6 +14,7 @@ OrgContentLibraryGlobData, OrgCourseOverviewGlobData, PermissionData, + PlatformCourseOverviewGlobData, RoleAssignmentData, RoleData, ScopeData, @@ -262,10 +263,14 @@ def test_scope_data_registration(self): self.assertIs(ScopeData.scope_registry["ccx-v1"], CCXCourseOverviewData) # Glob registries for organization-level scopes - self.assertIn("lib", ScopeMeta.glob_registry) - self.assertIs(ScopeMeta.glob_registry["lib"], OrgContentLibraryGlobData) - self.assertIn("course-v1", ScopeMeta.glob_registry) - self.assertIs(ScopeMeta.glob_registry["course-v1"], OrgCourseOverviewGlobData) + self.assertIn("lib", ScopeMeta.org_glob_registry) + self.assertIs(ScopeMeta.org_glob_registry["lib"], OrgContentLibraryGlobData) + self.assertIn("course-v1", ScopeMeta.org_glob_registry) + self.assertIs(ScopeMeta.org_glob_registry["course-v1"], OrgCourseOverviewGlobData) + + # Glob registries for platform-level scopes + self.assertIn("course-v1", ScopeMeta.platform_glob_registry) + self.assertIs(ScopeMeta.platform_glob_registry["course-v1"], PlatformCourseOverviewGlobData) @data( ("ccx-v1^ccx-v1:OpenedX+DemoX+DemoCourse+ccx@1", CCXCourseOverviewData), @@ -273,6 +278,7 @@ def test_scope_data_registration(self): ("lib^lib:DemoX:CSPROB", ContentLibraryData), ("lib^lib:DemoX*", OrgContentLibraryGlobData), ("course-v1^course-v1:OpenedX*", OrgCourseOverviewGlobData), + ("course-v1^course-v1:*", PlatformCourseOverviewGlobData), ("global^generic_scope", ScopeData), ) @unpack @@ -294,8 +300,9 @@ def test_dynamic_instantiation_via_namespaced_key(self, namespaced_key, expected ("lib^lib:DemoX:CSPROB", ContentLibraryData), ("lib^lib:DemoX:*", OrgContentLibraryGlobData), ("course-v1^course-v1:OpenedX+*", OrgCourseOverviewGlobData), - ("global^generic", ScopeData), - ("unknown^something", ScopeData), + ("course-v1^course-v1:*", PlatformCourseOverviewGlobData), + ("lib^*", ScopeData), + ("course-v1^*", ScopeData), ) @unpack def test_get_subclass_by_namespaced_key(self, namespaced_key, expected_class): @@ -305,19 +312,154 @@ def test_get_subclass_by_namespaced_key(self, namespaced_key, expected_class): - 'ccx-v1^...' returns CCXCourseOverviewData - 'course-v1^...' returns CourseOverviewData - 'lib^...' returns ContentLibraryData - - 'global^...' returns ScopeData - - 'unknown^...' returns ScopeData (fallback) + - 'lib^*' / 'course-v1^*' return ScopeData (non-global namespace wildcard) + - unregistered non-glob namespaces raise ValueError """ subclass = ScopeMeta.get_subclass_by_namespaced_key(namespaced_key) self.assertIs(subclass, expected_class) + def test_get_subclass_by_namespaced_key_global_wildcard_raises(self): + """Test that 'global^*' raises ValueError. + + The global namespace wildcard has no type context and is not a valid scope. + + Expected Result: + - ScopeMeta.get_subclass_by_namespaced_key('global^*') raises ValueError + """ + with self.assertRaises(ValueError): + ScopeMeta.get_subclass_by_namespaced_key("global^*") + + @data( + # No separator at all + "nohatseparator", + # Empty string + "", + # Nothing after the separator + "lib^", + # Nothing before the separator + "^something", + ) + def test_get_subclass_by_namespaced_key_invalid_format_raises(self, invalid_key: str): + """Test that keys not matching the namespaced format raise ValueError. + + NAMESPACED_KEY_PATTERN requires at least one character on both sides of the + '^' separator. Keys that violate this contract are rejected immediately. + + Expected Result: + - ValueError with "Invalid namespaced_key format" is raised + """ + with self.assertRaisesRegex(ValueError, "Invalid namespaced_key format"): + ScopeMeta.get_subclass_by_namespaced_key(invalid_key) + + def test_get_subclass_by_namespaced_key_unknown_platform_glob_raises(self): + """Test that a platform-glob key with an unregistered namespace raises ValueError. + + A platform glob has the shape ``namespace^namespace:*``. When the namespace + prefix is not in ``platform_glob_registry`` the method raises a ValueError + instead of silently falling through to a wrong class. + + Example: 'xyz^xyz:*' — external_key 'xyz:*' passes _is_platform_glob for + namespace 'xyz', but 'xyz' has no platform glob subclass registered. + + Expected Result: + - ValueError with "Unknown platform glob scope" is raised + """ + with self.assertRaisesRegex(ValueError, "Unknown platform glob scope"): + ScopeMeta.get_subclass_by_namespaced_key("xyz^xyz:*") + + def test_get_subclass_by_namespaced_key_unknown_org_glob_raises(self): + """Test that an org-glob key with an unregistered namespace raises ValueError. + + An org glob has the shape ``namespace^namespace:ORG*`` (e.g. the ':' or + '+' separator before the trailing '*'). When the namespace is not in + ``org_glob_registry`` a ValueError is raised. + + Example: 'xyz^xyz:Org1:*' — external_key 'xyz:Org1:*' passes _is_org_glob for + namespace 'xyz', but 'xyz' has no org glob subclass registered. + + Expected Result: + - ValueError with "Unknown org glob scope" is raised + """ + with self.assertRaisesRegex(ValueError, "Unknown org glob scope"): + ScopeMeta.get_subclass_by_namespaced_key("xyz^xyz:Org1:*") + + @data( + "org^any-org", + "course^course-v1:OpenedX+DemoX+CS101", + ) + def test_get_subclass_by_namespaced_key_unknown_scope_raises(self, namespaced_key: str): + """Test that a non-glob key with an unregistered namespace raises ValueError. + + When the namespace prefix is not in ``scope_registry`` and the external key + does not contain a wildcard, the method raises instead of falling back to + ScopeData. + + Expected Result: + - ValueError with "Unknown scope" is raised + """ + with self.assertRaisesRegex(ValueError, "Unknown scope"): + ScopeMeta.get_subclass_by_namespaced_key(namespaced_key) + + @data( + "lib^*", + "course-v1^*", + "ccx-v1^*", + ) + def test_get_subclass_by_namespaced_key_registered_namespace_wildcard_returns_scope_data( + self, + namespaced_key: str, + ): + """Test that a bare namespace wildcard for a registered namespace returns ScopeData. + + A key shaped ``namespace^*`` carries a meaningful namespace prefix but no + concrete object. When the namespace is registered in ``scope_registry`` + (e.g. 'lib', 'course-v1', 'ccx-v1') the method returns the base ScopeData + class instead of raising. + + Expected Result: + - ScopeData is returned + """ + self.assertIs(ScopeMeta.get_subclass_by_namespaced_key(namespaced_key), ScopeData) + + def test_get_subclass_by_namespaced_key_unregistered_namespace_wildcard_raises(self): + """Test that a bare namespace wildcard for an unregistered namespace raises ValueError. + + ``xyz^*`` has external_key '*' (the bare wildcard) but the 'xyz' namespace is + not registered in ``scope_registry``, so it cannot be resolved to ScopeData. + + Expected Result: + - ValueError with "Unknown scope" is raised + """ + with self.assertRaisesRegex(ValueError, "Unknown scope"): + ScopeMeta.get_subclass_by_namespaced_key("xyz^*") + + @data( + # Wildcard in the middle, not a trailing org/platform glob + "lib^lib:*:CSPROB", + # Wildcard before the trailing run segment for a course key + "course-v1^course-v1:OpenedX+CS*+2025", + ) + def test_get_subclass_by_namespaced_key_invalid_glob_pattern_raises(self, namespaced_key: str): + """Test that a wildcard key that is neither a platform nor org glob raises ValueError. + + These keys contain a wildcard (so they are treated as globs) but do not match + the platform glob shape (``namespace:*``), the org glob shape + (``namespace:ORG*``), nor the bare namespace wildcard (``namespace^*``). + + Expected Result: + - ValueError with "Unknown glob pattern" is raised + """ + with self.assertRaisesRegex(ValueError, "Unknown glob pattern"): + ScopeMeta.get_subclass_by_namespaced_key(namespaced_key) + @data( ("ccx-v1:OpenedX+DemoX+DemoCourse+ccx@1", CCXCourseOverviewData), ("course-v1:WGU+CS002+2025_T1", CourseOverviewData), ("lib:DemoX:CSPROB", ContentLibraryData), ("lib:DemoX:*", OrgContentLibraryGlobData), ("course-v1:OpenedX+*", OrgCourseOverviewGlobData), + ("course-v1:*", PlatformCourseOverviewGlobData), ("lib:edX:Demo", ContentLibraryData), ("global:generic_scope", ScopeData), ) @@ -379,44 +521,54 @@ def test_get_subclass_by_external_key_invalid_format_raises_value_error(self, ex with self.assertRaises(ValueError): ScopeMeta.get_subclass_by_external_key(external_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 - - @classmethod - def get_admin_view_permission(cls): - raise NotImplementedError("Not implemented for TempScope") + @data( + ("course-v1", "course-v1:*", True), + ("course-v1", "course-v1:OpenedX+*", False), + ("course-v1", "course-v1:OpenedX+CS101+2024", False), + ("lib", "lib:*", True), + ("lib", "lib:DemoX:*", False), + ) + @unpack + def test_is_platform_glob(self, namespace, external_key, expected): + """_is_platform_glob matches only namespace:* patterns.""" + # pylint: disable=protected-access + self.assertEqual(ScopeMeta._is_platform_glob(external_key, namespace), expected) - # 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_platform_glob_registration_does_not_override_scope_registry(self): + """Platform globs register separately; concrete scopes keep scope_registry entries.""" + self.assertIs(ScopeData.scope_registry["course-v1"], CourseOverviewData) + self.assertIs(ScopeMeta.platform_glob_registry["course-v1"], PlatformCourseOverviewGlobData) + self.assertNotIn(PlatformCourseOverviewGlobData, ScopeData.scope_registry.values()) + + def test_platform_glob_resolves_before_org_glob_for_course_namespace(self): + """course-v1:* is a platform glob; course-v1:Org+* remains an org glob.""" + self.assertIs(ScopeMeta.get_subclass_by_external_key("course-v1:*"), PlatformCourseOverviewGlobData) + self.assertIs(ScopeMeta.get_subclass_by_external_key("course-v1:OpenedX+*"), OrgCourseOverviewGlobData) + self.assertIs(ScopeMeta.get_subclass_by_namespaced_key("course-v1^course-v1:*"), PlatformCourseOverviewGlobData) + self.assertIs( + ScopeMeta.get_subclass_by_namespaced_key("course-v1^course-v1:OpenedX+*"), OrgCourseOverviewGlobData + ) + + def test_dynamic_instantiation_via_external_key_for_platform_glob(self): + """ScopeData(external_key='course-v1:*') instantiates PlatformCourseOverviewGlobData.""" + scope = ScopeData(external_key="course-v1:*") + + self.assertIsInstance(scope, PlatformCourseOverviewGlobData) + self.assertEqual(scope.external_key, "course-v1:*") + self.assertEqual(scope.namespaced_key, "course-v1^course-v1:*") + + def test_get_subclass_by_external_key_unknown_platform_glob_raises_value_error(self): + """Namespace:* without a registered platform glob subclass raises ValueError.""" + with self.assertRaisesRegex(ValueError, "Unknown platform glob scope"): + ScopeMeta.get_subclass_by_external_key("lib:*") + + def test_get_all_registered_scopes_includes_platform_glob(self): + """get_all_registered_scopes returns platform glob subclasses.""" + registered = ScopeMeta.get_all_registered_scopes() + + self.assertIn(PlatformCourseOverviewGlobData, registered) + self.assertIn(OrgCourseOverviewGlobData, registered) + self.assertIn(CourseOverviewData, registered) def test_direct_subclass_instantiation_bypasses_metaclass(self): """Test that direct subclass instantiation doesn't trigger metaclass logic. @@ -463,26 +615,49 @@ def test_empty_external_key_raises_value_error(self): with self.assertRaises(ValueError): SubjectData(external_key="") - def test_scope_data_with_wildcard_external_key(self): - """Test that ScopeData instantiated with wildcard (*) returns base ScopeData. + def test_scope_data_with_wildcard_external_key_raises(self): + """Test that ScopeData instantiated with bare wildcard '*' raises ValueError. - When using the global scope wildcard '*', the metaclass should return a base - ScopeData instance rather than attempting subclass determination. + The global scope wildcard '*' (which maps to 'global^*') has no namespace + context and breaks permission checks, so it is rejected at construction time. Expected Result: - - ScopeData(external_key='*') creates base ScopeData instance - - namespaced_key is 'global^*' - - No subclass determination occurs + - ScopeData(external_key='*') raises ValueError """ - scope = ScopeData(external_key="*") + with self.assertRaises(ValueError): + ScopeData(external_key="*") - expected_namespaced = f"{ScopeData.NAMESPACE}{ScopeData.SEPARATOR}*" + def test_scope_data_with_global_namespaced_wildcard_raises(self): + """Test that ScopeData(namespaced_key='global^*') raises ValueError. - self.assertIsInstance(scope, ScopeData) - # Ensure it's exactly ScopeData, not a subclass - self.assertEqual(type(scope), ScopeData) - self.assertEqual(scope.external_key, "*") - self.assertEqual(scope.namespaced_key, expected_namespaced) + 'global^*' is the bare global wildcard expressed as a namespaced key. + It is rejected for the same reasons as external_key='*'. + + Expected Result: + - ScopeData(namespaced_key='global^*') raises ValueError + """ + with self.assertRaises(ValueError): + ScopeData(namespaced_key=f"{ScopeData.NAMESPACE}{ScopeData.SEPARATOR}*") + + def test_scope_data_with_namespaced_wildcard_allowed(self): + """Test that namespaced wildcards for non-global namespaces are still allowed. + + 'lib^*' and 'course-v1^*' carry a meaningful namespace prefix and are used + internally by the policy store as untyped wildcards. + + Expected Result: + - ScopeData(namespaced_key='lib^*') succeeds and external_key is '*' + - ScopeData(namespaced_key='course-v1^*') succeeds and external_key is '*' + """ + lib_scope = ScopeData(namespaced_key="lib^*") + self.assertIsInstance(lib_scope, ScopeData) + self.assertEqual(lib_scope.external_key, "*") + self.assertEqual(lib_scope.namespaced_key, "lib^*") + + course_scope = ScopeData(namespaced_key="course-v1^*") + self.assertIsInstance(course_scope, ScopeData) + self.assertEqual(course_scope.external_key, "*") + self.assertEqual(course_scope.namespaced_key, "course-v1^*") @ddt @@ -906,3 +1081,79 @@ def test_exists_false_when_org_cannot_be_parsed(self): self.assertIsNone(scope.org) self.assertFalse(scope.exists()) + + +@ddt +class TestPlatformCourseOverviewGlobData(TestCase): + """Tests for the PlatformCourseOverviewGlobData scope.""" + + PLATFORM_GLOB_EXTERNAL_KEY = "course-v1:*" + PLATFORM_GLOB_NAMESPACED_KEY = "course-v1^course-v1:*" + + def test_build_external_key(self): + """build_external_key returns the platform-wide course glob pattern.""" + self.assertEqual(PlatformCourseOverviewGlobData.build_external_key(), self.PLATFORM_GLOB_EXTERNAL_KEY) + + @data( + ("course-v1:*", True), + ("course-v1:OpenedX+*", False), + ("course-v1:OpenedX*", False), + ("course-v1:OpenedX", False), + ("course-v1:*:*", False), + ("other:*", False), + ("lib:*", False), + ) + @unpack + def test_validate_external_key(self, external_key, expected_valid): + """Validate platform-level course glob external keys.""" + self.assertEqual(PlatformCourseOverviewGlobData.validate_external_key(external_key), expected_valid) + + def test_exists_always_true(self): + """exists() returns True without checking the database.""" + scope = PlatformCourseOverviewGlobData(external_key=self.PLATFORM_GLOB_EXTERNAL_KEY) + + self.assertTrue(scope.exists()) + + def test_get_object_returns_none(self): + """Platform glob scopes do not map to a concrete domain object.""" + scope = PlatformCourseOverviewGlobData(external_key=self.PLATFORM_GLOB_EXTERNAL_KEY) + + self.assertIsNone(scope.get_object()) + + def test_namespaced_key(self): + """namespaced_key includes namespace prefix and external key.""" + scope = PlatformCourseOverviewGlobData(external_key=self.PLATFORM_GLOB_EXTERNAL_KEY) + + self.assertEqual(scope.namespaced_key, self.PLATFORM_GLOB_NAMESPACED_KEY) + + def test_dynamic_instantiation_via_scope_data(self): + """ScopeData resolves course-v1:* to PlatformCourseOverviewGlobData.""" + scope = ScopeData(external_key=self.PLATFORM_GLOB_EXTERNAL_KEY) + + self.assertIsInstance(scope, PlatformCourseOverviewGlobData) + self.assertEqual(scope.external_key, self.PLATFORM_GLOB_EXTERNAL_KEY) + + def test_get_admin_view_permission(self): + """View permission matches course team view permission.""" + self.assertEqual( + PlatformCourseOverviewGlobData.get_admin_view_permission(), permissions.COURSES_VIEW_COURSE_TEAM + ) + + def test_get_admin_manage_permission(self): + """Manage permission matches course team manage permission.""" + self.assertEqual( + PlatformCourseOverviewGlobData.get_admin_manage_permission(), + permissions.COURSES_MANAGE_COURSE_TEAM, + ) + + def test_is_platform_glob(self): + """Platform course glob is flagged as a platform-level glob scope.""" + self.assertTrue(PlatformCourseOverviewGlobData.IS_PLATFORM_GLOB) + self.assertFalse(PlatformCourseOverviewGlobData.IS_ORG_GLOB) + + def test_get_all_platform_glob_namespaces(self): + """Platform glob namespace is registered in ScopeMeta.""" + platform_globs = ScopeMeta.get_all_platform_glob_namespaces() + + self.assertIn("course-v1", platform_globs) + self.assertIs(platform_globs["course-v1"], PlatformCourseOverviewGlobData) diff --git a/openedx_authz/tests/api/test_roles.py b/openedx_authz/tests/api/test_roles.py index 52dc8775..edcdf9a3 100644 --- a/openedx_authz/tests/api/test_roles.py +++ b/openedx_authz/tests/api/test_roles.py @@ -15,7 +15,6 @@ from openedx_authz.api.data import ( ActionData, - ContentLibraryData, PermissionData, RoleAssignmentData, RoleData, @@ -43,6 +42,7 @@ ) from openedx_authz.constants import permissions, roles from openedx_authz.constants.roles import ( + COURSE_AUDITOR_PERMISSIONS, LIBRARY_ADMIN_PERMISSIONS, LIBRARY_AUTHOR_PERMISSIONS, LIBRARY_CONTRIBUTOR_PERMISSIONS, @@ -496,33 +496,56 @@ def test_get_permissions_for_active_role_in_specific_scope(self, role_name, scop @ddt_data( ( - "*", + "lib^*", { roles.LIBRARY_ADMIN.external_key, roles.LIBRARY_AUTHOR.external_key, roles.LIBRARY_CONTRIBUTOR.external_key, roles.LIBRARY_USER.external_key, }, + roles.LIBRARY_ADMIN.external_key, + LIBRARY_ADMIN_PERMISSIONS, + ), + ( + "course-v1^*", + { + roles.COURSE_AUDITOR.external_key, + roles.COURSE_EDITOR.external_key, + roles.COURSE_STAFF.external_key, + roles.COURSE_ADMIN.external_key, + roles.COURSE_LIMITED_STAFF.external_key, + roles.COURSE_DATA_RESEARCHER.external_key, + roles.COURSE_BETA_TESTER.external_key, + }, + roles.COURSE_AUDITOR.external_key, + COURSE_AUDITOR_PERMISSIONS, ), ) @unpack - def test_get_roles_in_scope(self, scope_name, expected_roles): - """Test retrieving roles definitions in a specific scope. - - Currently, this function returns all roles defined in the system because - we're using only lib:* scope (which maps to lib^* internally). This should - be updated when we have more (template) scopes in the policy file. + def test_get_role_definitions_in_scope( + self, + namespaced_scope, + expected_roles, + sample_role, + expected_permissions, + ): + """Test retrieving role definitions from policy for a namespace wildcard scope. Expected result: - - Roles in the given scope are correctly retrieved. + - Role definitions defined in authz.policy for the scope are returned. + - Each role includes the permissions declared in its policy rules. """ - # TODO: cheat and use ContentLibraryData until we have more scope types - roles_in_scope = get_role_definitions_in_scope( - ContentLibraryData(external_key=scope_name), - ) + roles_in_scope = get_role_definitions_in_scope(ScopeData(namespaced_key=namespaced_scope)) + roles_by_key = {role.external_key: role for role in roles_in_scope} - role_names = {role.external_key for role in roles_in_scope} - self.assertEqual(role_names, expected_roles) + self.assertEqual(set(roles_by_key.keys()), expected_roles) + self.assertEqual(roles_by_key[sample_role].permissions, expected_permissions) + + def test_get_role_definitions_in_scope_returns_empty_for_concrete_scope(self): + """Concrete scopes do not match namespace wildcard policy definitions.""" + roles_in_scope = get_role_definitions_in_scope(ScopeData(external_key="lib:Org1:math_101")) + + self.assertEqual(roles_in_scope, []) @ddt_data( ("alice", "lib:Org1:math_101", {roles.LIBRARY_ADMIN.external_key}), diff --git a/openedx_authz/tests/api/test_users.py b/openedx_authz/tests/api/test_users.py index da0b3b75..a15fd30b 100644 --- a/openedx_authz/tests/api/test_users.py +++ b/openedx_authz/tests/api/test_users.py @@ -1,12 +1,13 @@ """Test suite for user-role assignment API functions.""" -from unittest.mock import patch +from unittest.mock import Mock, patch from ddt import data, ddt, unpack from django.contrib.auth import get_user_model from openedx_authz.api.data import ContentLibraryData, RoleAssignmentData, RoleData, UserData from openedx_authz.api.users import ( + _filter_allowed_assignments, assign_role_to_user_in_scope, batch_assign_role_to_users_in_scope, batch_unassign_role_from_users, @@ -616,3 +617,39 @@ def test_mixed_active_inactive_subjects_in_assignments(self): self.assertGreater(len(eve_assignments), 0) self.assertEqual(grace_assignments, []) + + @patch("openedx_authz.api.users.is_user_allowed", return_value=True) + def test_skips_assignments_with_unsupported_scope(self, mock_is_user_allowed): + """Assignments whose scope lacks get_admin_view_permission are skipped with a warning.""" + unsupported_scope = Mock() + unsupported_scope.external_key = "unsupported:scope" + unsupported_scope.get_admin_view_permission.side_effect = NotImplementedError + + supported_scope = ContentLibraryData(external_key="lib:Org1:math_101") + unsupported_assignment = RoleAssignmentData( + subject=UserData(external_key="john_doe"), + roles=[RoleData(external_key=roles.LIBRARY_ADMIN.external_key)], + scope=unsupported_scope, + ) + supported_assignment = RoleAssignmentData( + subject=UserData(external_key="john_doe"), + roles=[RoleData(external_key=roles.LIBRARY_ADMIN.external_key)], + scope=supported_scope, + ) + + with self.assertLogs("openedx_authz.api.users", level="WARNING") as log_context: + filtered = _filter_allowed_assignments( + assignments=[unsupported_assignment, supported_assignment], + user_external_key="alice", + ) + + self.assertEqual(filtered, [supported_assignment]) + self.assertEqual( + log_context.output, + ["WARNING:openedx_authz.api.users:Skipping assignment with unsupported scope 'unsupported:scope'"], + ) + mock_is_user_allowed.assert_called_once_with( + user_external_key="alice", + action_external_key=supported_scope.get_admin_view_permission().identifier, + scope_external_key=supported_scope.external_key, + ) diff --git a/openedx_authz/tests/rest_api/test_views.py b/openedx_authz/tests/rest_api/test_views.py index 65c29f0e..f552c80d 100644 --- a/openedx_authz/tests/rest_api/test_views.py +++ b/openedx_authz/tests/rest_api/test_views.py @@ -20,6 +20,7 @@ CourseOverviewData, OrgContentLibraryGlobData, OrgCourseOverviewGlobData, + PlatformCourseOverviewGlobData, ) from openedx_authz.api.users import assign_role_to_user_in_scope from openedx_authz.constants import permissions, roles @@ -150,9 +151,7 @@ def create_course_users(cls): """Create course users (plain, non-staff).""" users = ["course_admin", "course_editor", "course_auditor", "course_admin_org"] for username in users: - User.objects.get_or_create( - username=username, defaults={"email": f"{username}@example.com"} - ) + User.objects.get_or_create(username=username, defaults={"email": f"{username}@example.com"}) @classmethod def setUpTestData(cls): @@ -330,21 +329,21 @@ class TestRoleUserAPIView(ViewTestMixin): """Test suite for RoleUserAPIView.""" _COURSE_ASSIGNMENTS = [ - { - "subject_name": "course_admin", - "role_name": roles.COURSE_ADMIN.external_key, - "scope_name": COURSE_SCOPE_ORG1, - }, - { - "subject_name": "course_editor", - "role_name": roles.COURSE_EDITOR.external_key, - "scope_name": COURSE_SCOPE_ORG1, - }, - { - "subject_name": "course_auditor", - "role_name": roles.COURSE_AUDITOR.external_key, - "scope_name": COURSE_SCOPE_ORG1, - }, + { + "subject_name": "course_admin", + "role_name": roles.COURSE_ADMIN.external_key, + "scope_name": COURSE_SCOPE_ORG1, + }, + { + "subject_name": "course_editor", + "role_name": roles.COURSE_EDITOR.external_key, + "scope_name": COURSE_SCOPE_ORG1, + }, + { + "subject_name": "course_auditor", + "role_name": roles.COURSE_AUDITOR.external_key, + "scope_name": COURSE_SCOPE_ORG1, + }, ] @classmethod @@ -955,6 +954,8 @@ def setUp(self): self.url = reverse("openedx_authz:role-user-list") @data( + # Bare global wildcard — not accepted via the API + "*", # Course: globs only after full org segment (ORG+*), not course-v1:ORG* or mid-key globs "course-v1:OpenedX*", "course-v1:OpenedX**", @@ -990,6 +991,8 @@ def test_put_rejects_malformed_or_overbroad_scope_strings(self, invalid_scope: s self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @data( + # Bare global wildcard — not accepted via the API + "*", "course-v1:OpenedX*", "course-v1:OpenedX+CS101+*", "lib:DemoX*", @@ -1536,6 +1539,25 @@ def test_org_glob_scope_returns_all_org_courses(self): self.assertIn(self.COURSE_ORG1, external_keys) self.assertNotIn(self.COURSE_ORG2, external_keys) + def test_platform_glob_scope_returns_all_courses(self): + """A user with platform-level glob (course-v1:*) sees all courses across orgs.""" + user = User.objects.get(username="regular_9") + self.client.force_authenticate(user=user) + self.build_qs_patcher.stop() + + platform_scope = PlatformCourseOverviewGlobData(external_key="course-v1:*") + with patch( + "openedx_authz.rest_api.v1.views.get_scopes_for_user_and_permission", + return_value=[platform_scope], + ): + response = self.client.get(self.url, {"scope_type": "course"}) + + self.build_qs_patcher.start() + self.assertEqual(response.status_code, status.HTTP_200_OK) + external_keys = [item["external_key"] for item in response.data["results"]] + self.assertIn(self.COURSE_ORG1, external_keys) + self.assertIn(self.COURSE_ORG2, external_keys) + def test_manage_permission_only_uses_manage_permission(self): """management_permission_only=true calls get_admin_manage_permission, not get_admin_view_permission.""" user = User.objects.get(username="regular_1") @@ -2606,6 +2628,28 @@ def test_pagination(self, query_params: dict, expected_page_count: int, has_next else: self.assertIsNone(response.data["next"]) + def test_platform_glob_assignment_serializes_wildcard_org(self): + """User with platform glob role assignment returns org '*' in the API response. + + regular_10 is assigned course_staff on course-v1:* (all courses on the platform). + regular_9 is assigned course_admin on the same scope so they can view team + assignments for that platform-level glob. + """ + PLATFORM_COURSE_GLOB = "course-v1:*" + assign_role_to_user_in_scope("regular_10", roles.COURSE_STAFF.external_key, PLATFORM_COURSE_GLOB) + assign_role_to_user_in_scope("regular_9", roles.COURSE_ADMIN.external_key, PLATFORM_COURSE_GLOB) + + self.client.force_authenticate(user=User.objects.get(username="regular_9")) + response = self.client.get(self._url("regular_10")) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 1) + assignment = response.data["results"][0] + self.assertFalse(assignment["is_superadmin"]) + self.assertEqual(assignment["org"], "*") + self.assertEqual(assignment["scope"], PLATFORM_COURSE_GLOB) + self.assertEqual(assignment["role"], roles.COURSE_STAFF.external_key) + # ------------------------------------------------------------------ # # Response shape # # ------------------------------------------------------------------ # @@ -2649,21 +2693,21 @@ class TestRoleListView(ViewTestMixin): """Test suite for RoleListView.""" _COURSE_ASSIGNMENTS = [ - { - "subject_name": "course_admin", - "role_name": roles.COURSE_ADMIN.external_key, - "scope_name": COURSE_SCOPE_ORG1, - }, - { - "subject_name": "course_editor", - "role_name": roles.COURSE_EDITOR.external_key, - "scope_name": COURSE_SCOPE_ORG1, - }, - { - "subject_name": "course_auditor", - "role_name": roles.COURSE_AUDITOR.external_key, - "scope_name": COURSE_SCOPE_ORG1, - }, + { + "subject_name": "course_admin", + "role_name": roles.COURSE_ADMIN.external_key, + "scope_name": COURSE_SCOPE_ORG1, + }, + { + "subject_name": "course_editor", + "role_name": roles.COURSE_EDITOR.external_key, + "scope_name": COURSE_SCOPE_ORG1, + }, + { + "subject_name": "course_auditor", + "role_name": roles.COURSE_AUDITOR.external_key, + "scope_name": COURSE_SCOPE_ORG1, + }, ] @classmethod @@ -2730,6 +2774,7 @@ def test_get_roles_scope_is_missing(self, query_params: dict): ({"scope": ""}, "blank"), ({"scope": "a" * 256}, "max_length"), ({"scope": "invalid"}, "invalid"), + ({"scope": "*"}, "invalid"), ) @unpack def test_get_roles_scope_is_invalid(self, query_params: dict, error_code: str): @@ -4045,16 +4090,16 @@ class TestBulkPutScopesAllLogic(ViewTestMixin): ANOTHER_COURSE_SCOPE = "course-v1:Org2+COURSE2+2024" _COURSE_ASSIGNMENTS = [ - { - "subject_name": "course_admin", - "role_name": roles.COURSE_ADMIN.external_key, - "scope_name": COURSE_SCOPE_ORG1, - }, - { - "subject_name": "course_admin_org", - "role_name": roles.COURSE_ADMIN.external_key, - "scope_name": COURSE_ORG1_GLOB, - }, + { + "subject_name": "course_admin", + "role_name": roles.COURSE_ADMIN.external_key, + "scope_name": COURSE_SCOPE_ORG1, + }, + { + "subject_name": "course_admin_org", + "role_name": roles.COURSE_ADMIN.external_key, + "scope_name": COURSE_ORG1_GLOB, + }, ] def setUp(self): @@ -4065,12 +4110,22 @@ def setUp(self): self._assign_roles_to_users(assignments=self._COURSE_ASSIGNMENTS) def _put_course(self, scopes): + """Send a bulk PUT assigning COURSE_ADMIN to regular_2 for the given course scopes. + + Patches scope existence checks so validation does not depend on the database. + """ request_data = {"role": roles.COURSE_ADMIN.external_key, "scopes": scopes, "users": ["regular_2"]} - with patch.object(api.CourseOverviewData, "exists", return_value=True), \ - patch.object(api.OrgCourseOverviewGlobData, "exists", return_value=True): + with ( + patch.object(api.CourseOverviewData, "exists", return_value=True), + patch.object(api.OrgCourseOverviewGlobData, "exists", return_value=True), + ): return self.client.put(self.url, data=request_data, format="json") def _put_lib(self, scopes): + """Send a bulk PUT assigning LIBRARY_ADMIN to regular_2 for the given library scopes. + + Patches ContentLibraryData.exists so validation does not depend on the database. + """ request_data = {"role": roles.LIBRARY_ADMIN.external_key, "scopes": scopes, "users": ["regular_2"]} with patch.object(api.ContentLibraryData, "exists", return_value=True): return self.client.put(self.url, data=request_data, format="json") diff --git a/openedx_authz/tests/test_enforcement.py b/openedx_authz/tests/test_enforcement.py index a79d3487..7b461cbf 100644 --- a/openedx_authz/tests/test_enforcement.py +++ b/openedx_authz/tests/test_enforcement.py @@ -20,8 +20,6 @@ GLOBAL_SCOPE_WILDCARD, ContentLibraryData, CourseOverviewData, - OrgContentLibraryGlobData, - OrgCourseOverviewGlobData, ) from openedx_authz.constants import roles from openedx_authz.constants.permissions import ( @@ -43,6 +41,7 @@ make_role_key, make_scope_key, make_user_key, + make_wildcard_key, ) User = get_user_model() @@ -156,19 +155,7 @@ class SystemWideRoleTests(CasbinEnforcementTestCase): { "subject": make_user_key("user-1"), "action": make_action_key("manage"), - "scope": make_scope_key("global", GLOBAL_SCOPE_WILDCARD), - "expected_result": True, - }, - { - "subject": make_user_key("user-1"), - "action": make_action_key("manage"), - "scope": make_scope_key("org", "any-org"), - "expected_result": True, - }, - { - "subject": make_user_key("user-1"), - "action": make_action_key("manage"), - "scope": make_scope_key("course", "course-v1:any-org+any-course+any-course-run"), + "scope": make_course_key("course-v1:any-org+any-course+any-course-run"), "expected_result": True, }, { @@ -195,19 +182,21 @@ class ActionGroupingTests(CasbinEnforcementTestCase): (like 'edit', 'read', 'write', 'delete') through the g2 grouping mechanism. """ + ORG_COURSE_SCOPE = make_course_key("course-v1:any-org+*") + POLICY = [ [ "p", make_role_key("role-1"), make_action_key("manage"), - make_scope_key("org", GLOBAL_SCOPE_WILDCARD), + make_scope_key("course-v1", GLOBAL_SCOPE_WILDCARD), "allow", ], [ "g", make_user_key("user-1"), make_role_key("role-1"), - make_scope_key("org", "any-org"), + ORG_COURSE_SCOPE, ], ] + COMMON_ACTION_GROUPING @@ -215,25 +204,25 @@ class ActionGroupingTests(CasbinEnforcementTestCase): { "subject": make_user_key("user-1"), "action": make_action_key("edit"), - "scope": make_scope_key("org", "any-org"), + "scope": ORG_COURSE_SCOPE, "expected_result": True, }, { "subject": make_user_key("user-1"), "action": make_action_key("read"), - "scope": make_scope_key("org", "any-org"), + "scope": ORG_COURSE_SCOPE, "expected_result": True, }, { "subject": make_user_key("user-1"), "action": make_action_key("write"), - "scope": make_scope_key("org", "any-org"), + "scope": ORG_COURSE_SCOPE, "expected_result": True, }, { "subject": make_user_key("user-1"), "action": make_action_key("delete"), - "scope": make_scope_key("org", "any-org"), + "scope": ORG_COURSE_SCOPE, "expected_result": True, }, ] @@ -253,6 +242,9 @@ class RoleAssignmentTests(CasbinEnforcementTestCase): within their assigned scopes. """ + ORG_COURSE_SCOPE = make_course_key("course-v1:any-org+*") + COURSE_SCOPE = make_course_key("course-v1:any-org+any-course+any-course-run") + POLICY = [ # Policies ["p", make_role_key("platform_admin"), make_action_key("manage"), GLOBAL_SCOPE_WILDCARD, "allow"], @@ -260,28 +252,28 @@ class RoleAssignmentTests(CasbinEnforcementTestCase): "p", make_role_key("org_admin"), make_action_key("manage"), - make_scope_key("org", GLOBAL_SCOPE_WILDCARD), + make_scope_key("course-v1", GLOBAL_SCOPE_WILDCARD), "allow", ], [ "p", make_role_key("org_editor"), make_action_key("edit"), - make_scope_key("org", GLOBAL_SCOPE_WILDCARD), + make_scope_key("course-v1", GLOBAL_SCOPE_WILDCARD), "allow", ], [ "p", make_role_key("org_author"), make_action_key("write"), - make_scope_key("org", GLOBAL_SCOPE_WILDCARD), + make_scope_key("course-v1", GLOBAL_SCOPE_WILDCARD), "allow", ], [ "p", make_role_key("course_admin"), make_action_key("manage"), - make_scope_key("course", GLOBAL_SCOPE_WILDCARD), + make_scope_key("course-v1", GLOBAL_SCOPE_WILDCARD), "allow", ], [ @@ -318,25 +310,25 @@ class RoleAssignmentTests(CasbinEnforcementTestCase): "g", make_user_key("user-2"), make_role_key("org_admin"), - make_scope_key("org", "any-org"), + ORG_COURSE_SCOPE, ], [ "g", make_user_key("user-3"), make_role_key("org_editor"), - make_scope_key("org", "any-org"), + ORG_COURSE_SCOPE, ], [ "g", make_user_key("user-4"), make_role_key("org_author"), - make_scope_key("org", "any-org"), + ORG_COURSE_SCOPE, ], [ "g", make_user_key("user-5"), make_role_key("course_admin"), - make_scope_key("course", "course-v1:any-org+any-course+any-course-run"), + COURSE_SCOPE, ], [ "g", @@ -368,31 +360,31 @@ class RoleAssignmentTests(CasbinEnforcementTestCase): { "subject": make_user_key("user-1"), "action": make_action_key("manage"), - "scope": make_scope_key("org", "any-org"), + "scope": ORG_COURSE_SCOPE, "expected_result": True, }, { "subject": make_user_key("user-2"), "action": make_action_key("manage"), - "scope": make_scope_key("org", "any-org"), + "scope": ORG_COURSE_SCOPE, "expected_result": True, }, { "subject": make_user_key("user-3"), "action": make_action_key("edit"), - "scope": make_scope_key("org", "any-org"), + "scope": ORG_COURSE_SCOPE, "expected_result": True, }, { "subject": make_user_key("user-4"), "action": make_action_key("write"), - "scope": make_scope_key("org", "any-org"), + "scope": ORG_COURSE_SCOPE, "expected_result": True, }, { "subject": make_user_key("user-5"), "action": make_action_key("manage"), - "scope": make_scope_key("course", "course-v1:any-org+any-course+any-course-run"), + "scope": COURSE_SCOPE, "expected_result": True, }, { @@ -435,13 +427,16 @@ class DeniedAccessTests(CasbinEnforcementTestCase): when explicit deny rules override allow rules. """ + ALLOWED_ORG_SCOPE = make_course_key("course-v1:allowed-org+*") + RESTRICTED_ORG_SCOPE = make_course_key("course-v1:restricted-org+*") + POLICY = [ ["p", make_role_key("platform_admin"), make_action_key("manage"), GLOBAL_SCOPE_WILDCARD, "allow"], [ "p", make_role_key("platform_admin"), make_action_key("manage"), - make_scope_key("org", "restricted-org"), + RESTRICTED_ORG_SCOPE, "deny", ], ["g", make_user_key("user-1"), make_role_key("platform_admin"), GLOBAL_SCOPE_WILDCARD], @@ -451,37 +446,37 @@ class DeniedAccessTests(CasbinEnforcementTestCase): { "subject": make_user_key("user-1"), "action": make_action_key("manage"), - "scope": make_scope_key("org", "allowed-org"), + "scope": ALLOWED_ORG_SCOPE, "expected_result": True, }, { "subject": make_user_key("user-1"), "action": make_action_key("manage"), - "scope": make_scope_key("org", "restricted-org"), + "scope": RESTRICTED_ORG_SCOPE, "expected_result": False, }, { "subject": make_user_key("user-1"), "action": make_action_key("edit"), - "scope": make_scope_key("org", "restricted-org"), + "scope": RESTRICTED_ORG_SCOPE, "expected_result": False, }, { "subject": make_user_key("user-1"), "action": make_action_key("read"), - "scope": make_scope_key("org", "restricted-org"), + "scope": RESTRICTED_ORG_SCOPE, "expected_result": False, }, { "subject": make_user_key("user-1"), "action": make_action_key("write"), - "scope": make_scope_key("org", "restricted-org"), + "scope": RESTRICTED_ORG_SCOPE, "expected_result": False, }, { "subject": make_user_key("user-1"), "action": make_action_key("delete"), - "scope": make_scope_key("org", "restricted-org"), + "scope": RESTRICTED_ORG_SCOPE, "expected_result": False, }, ] @@ -494,91 +489,44 @@ def test_denied_access(self, request: AuthRequest): @ddt class WildcardScopeTests(CasbinEnforcementTestCase): - """Tests for wildcard scope authorization patterns. - - Verifies that users with roles assigned to wildcard scopes (like "*" for global access - or "org^*" for organization-wide access) can properly access resources within their - authorized scope boundaries. + """Tests for namespace wildcard scope authorization patterns. - TODO: this needs to be updated with the latest changes in the model. + Verifies that users assigned to roles at global scope (``*``) can only access + resources whose scope matches the namespace wildcard defined in the policy + (e.g., ``course-v1^*`` for courses, ``lib^*`` for libraries). Access to scopes + outside the role's namespace is denied. """ POLICY = [ # Policies - ["p", make_role_key("platform_admin"), make_action_key("manage"), GLOBAL_SCOPE_WILDCARD, "allow"], [ "p", - make_role_key("org_admin"), - make_action_key("manage"), - make_scope_key("org", GLOBAL_SCOPE_WILDCARD), - "allow", - ], - [ - "p", - make_role_key("course_admin"), + make_role_key(roles.COURSE_ADMIN.external_key), make_action_key("manage"), - make_scope_key("course", GLOBAL_SCOPE_WILDCARD), + make_wildcard_key(CourseOverviewData.NAMESPACE), "allow", ], [ "p", make_role_key(roles.LIBRARY_ADMIN.external_key), make_action_key("manage"), - make_scope_key("lib", GLOBAL_SCOPE_WILDCARD), + make_wildcard_key(ContentLibraryData.NAMESPACE), "allow", ], # Role assignments - ["g", make_user_key("user-1"), make_role_key("platform_admin"), GLOBAL_SCOPE_WILDCARD], - ["g", make_user_key("user-2"), make_role_key("org_admin"), GLOBAL_SCOPE_WILDCARD], - ["g", make_user_key("user-3"), make_role_key("course_admin"), GLOBAL_SCOPE_WILDCARD], - ["g", make_user_key("user-4"), make_role_key(roles.LIBRARY_ADMIN.external_key), GLOBAL_SCOPE_WILDCARD], + ["g", make_user_key("user-1"), make_role_key(roles.COURSE_ADMIN.external_key), GLOBAL_SCOPE_WILDCARD], + ["g", make_user_key("user-2"), make_role_key(roles.LIBRARY_ADMIN.external_key), GLOBAL_SCOPE_WILDCARD], ] + COMMON_ACTION_GROUPING @data( - (make_scope_key("global", GLOBAL_SCOPE_WILDCARD), True), - (make_scope_key("org", "MIT"), True), - (make_scope_key("course", "course-v1:OpenedX+DemoX+CS101"), True), - (make_library_key("lib:OpenedX:math-basics"), True), - ) - @unpack - def test_wildcard_global_access(self, scope: str, expected_result: bool): - """Test that users have access through wildcard global scope.""" - request = { - "subject": make_user_key("user-1"), - "action": make_action_key("manage"), - "scope": scope, - "expected_result": expected_result, - } - self._test_enforcement(self.POLICY, request) - - @data( - (make_scope_key("global", GLOBAL_SCOPE_WILDCARD), False), - (make_scope_key("org", "MIT"), True), - (make_scope_key("course", "course-v1:OpenedX+DemoX+CS101"), False), - (make_library_key("lib:OpenedX:math-basics"), False), - ) - @unpack - def test_wildcard_org_access(self, scope: str, expected_result: bool): - """Test that users have access through wildcard org scope.""" - request = { - "subject": make_user_key("user-2"), - "action": make_action_key("manage"), - "scope": scope, - "expected_result": expected_result, - } - self._test_enforcement(self.POLICY, request) - - @data( - (make_scope_key("global", GLOBAL_SCOPE_WILDCARD), False), - (make_scope_key("org", "MIT"), False), - (make_scope_key("course", "course-v1:OpenedX+DemoX+CS101"), True), + (make_course_key("course-v1:OpenedX+DemoX+CS101"), True), (make_library_key("lib:OpenedX:math-basics"), False), ) @unpack def test_wildcard_course_access(self, scope: str, expected_result: bool): """Test that users have access through wildcard course scope.""" request = { - "subject": make_user_key("user-3"), + "subject": make_user_key("user-1"), "action": make_action_key("manage"), "scope": scope, "expected_result": expected_result, @@ -586,16 +534,14 @@ def test_wildcard_course_access(self, scope: str, expected_result: bool): self._test_enforcement(self.POLICY, request) @data( - (make_scope_key("global", GLOBAL_SCOPE_WILDCARD), False), - (make_scope_key("org", "MIT"), False), - (make_scope_key("course", "course-v1:OpenedX+DemoX+CS101"), False), + (make_course_key("course-v1:OpenedX+DemoX+CS101"), False), (make_library_key("lib:OpenedX:math-basics"), True), ) @unpack def test_wildcard_library_access(self, scope: str, expected_result: bool): """Test that users have access through wildcard library scope.""" request = { - "subject": make_user_key("user-4"), + "subject": make_user_key("user-2"), "action": make_action_key("manage"), "scope": scope, "expected_result": expected_result, @@ -675,14 +621,39 @@ def test_org_level_glob_enforcement(self, request: AuthRequest): self._test_enforcement(self.POLICIES + self.ASSIGNMENTS, request) -def make_org_library_glob_key(key: str) -> str: - """Create a namespaced org-level library glob key (e.g., 'lib^lib:DemoX:*').""" - return f"{OrgContentLibraryGlobData.NAMESPACE}{OrgContentLibraryGlobData.SEPARATOR}{key}" +@ddt +class PlatformGlobCourseEnforcementTests(CasbinEnforcementTestCase): + """ + Tests for platform-level glob patterns in course scopes. + This test class verifies that policies defined with platform-level glob patterns + (e.g., ``course-v1:*``) are correctly enforced for concrete course scopes across + all organizations on the platform. + """ -def make_org_course_glob_key(key: str) -> str: - """Create a namespaced org-level course glob key (e.g., 'course-v1^course-v1:DemoX+*').""" - return f"{OrgCourseOverviewGlobData.NAMESPACE}{OrgCourseOverviewGlobData.SEPARATOR}{key}" + POLICIES = [ + make_policy(roles.COURSE_STAFF.external_key, COURSES_VIEW_COURSE.identifier, CourseOverviewData.NAMESPACE) + ] + + ASSIGNMENTS = [ + make_course_assignment("user1", roles.COURSE_STAFF.external_key, "course-v1:*"), + ] + + CASES = [ + # Permission granted across organizations + make_course_case("user1", COURSES_VIEW_COURSE.identifier, "course-v1:OpenedX+Python+2026", True), + make_course_case("user1", COURSES_VIEW_COURSE.identifier, "course-v1:OtherOrg+Course+2025", True), + make_course_case("user1", COURSES_VIEW_COURSE.identifier, "course-v1:InexistentOrg+Demo+2026", True), + make_course_case("user1", COURSES_VIEW_COURSE.identifier, "course-v1:OpenedXv2+Demo+2026", True), + # Permission denied + make_course_case("user1", COURSES_CREATE_FILES.identifier, "course-v1:OpenedX+Python+2026", False), + make_course_case("user2", COURSES_VIEW_COURSE.identifier, "course-v1:OpenedX+Demo+2026", False), + ] + + @data(*CASES) + def test_platform_level_glob_enforcement(self, request: AuthRequest): + """Test that platform-level glob patterns in course scopes are enforced correctly.""" + self._test_enforcement(self.POLICIES + self.ASSIGNMENTS, request) @pytest.mark.django_db @@ -835,43 +806,59 @@ def setUp(self) -> None: ( make_user_key("staff_user"), make_action_key("content_libraries.view_library"), - make_org_library_glob_key("lib:TestOrg:*"), + make_library_key("lib:TestOrg:*"), True, ), ( make_user_key("superuser"), make_action_key("content_libraries.view_library"), - make_org_library_glob_key("lib:TestOrg:*"), + make_library_key("lib:TestOrg:*"), True, ), ( make_user_key("regular_user"), make_action_key("content_libraries.view_library"), - make_org_library_glob_key("lib:TestOrg:*"), + make_library_key("lib:TestOrg:*"), False, ), # OrgCourseOverviewGlobData scope ( make_user_key("staff_user"), make_action_key("courses.view_course"), - make_org_course_glob_key("course-v1:TestOrg+*"), + make_course_key("course-v1:TestOrg+*"), + True, + ), + ( + make_user_key("superuser"), + make_action_key("courses.view_course"), + make_course_key("course-v1:TestOrg+*"), + True, + ), + ( + make_user_key("regular_user"), + make_action_key("courses.view_course"), + make_course_key("course-v1:TestOrg+*"), + False, + ), + # PlatformCourseOverviewGlobData scope + ( + make_user_key("staff_user"), + make_action_key("courses.view_course"), + make_course_key("course-v1:*"), True, ), ( make_user_key("superuser"), make_action_key("courses.view_course"), - make_org_course_glob_key("course-v1:TestOrg+*"), + make_course_key("course-v1:*"), True, ), ( make_user_key("regular_user"), make_action_key("courses.view_course"), - make_org_course_glob_key("course-v1:TestOrg+*"), + make_course_key("course-v1:*"), False, ), - # Unsupported scope type - no one is granted access via this matcher - (make_user_key("staff_user"), make_action_key("manage"), make_scope_key("org", "TestOrg"), False), - (make_user_key("superuser"), make_action_key("manage"), make_scope_key("org", "TestOrg"), False), ) @unpack def test_is_admin_or_superuser_check( @@ -885,13 +872,14 @@ def test_is_admin_or_superuser_check( Verifies that: - Staff users are always allowed for ContentLibraryData, CourseOverviewData, - OrgContentLibraryGlobData, and OrgCourseOverviewGlobData scopes. + OrgContentLibraryGlobData, OrgCourseOverviewGlobData, and + PlatformCourseOverviewGlobData scopes. - Superusers are always allowed for the same scopes. - Regular users are denied when they have no role assignments. - Unsupported scope types (e.g., org) are denied even for staff/superusers. Expected result: - - staff_user and superuser: True for all four supported scope types. + - staff_user and superuser: True for all supported scope types. - regular_user: False for all scope types (no role assignments). - staff_user and superuser: False for unsupported scope types. """