diff --git a/.gitignore b/.gitignore index 3eea6ae5..99ce4cbb 100644 --- a/.gitignore +++ b/.gitignore @@ -64,5 +64,6 @@ docs/openedx_authz.*.rst requirements/private.in requirements/private.txt -# Sqlite Database +# Persistent database files +*.sqlite3 *.db diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4b65411d..ad418646 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -22,4 +22,14 @@ Unreleased Added ===== -* First release on PyPI. +* Basic repo structure and initial setup. + +0.2.0 - 2025-10-10 +****************** + +Added +===== + +* ADRs for key design decisions. +* Casbin model (CONF) and engine layer for authorization. +* Implementation of public API for roles and permissions management. diff --git a/docs/conf.py b/docs/conf.py index 1e1cde8d..c64d714c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -47,7 +47,11 @@ def get_version(*file_paths): # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -# + +autodoc_mock_imports = [ + "openedx_authz.api", +] + # import os # import sys # sys.path.insert(0, os.path.abspath('.')) @@ -72,6 +76,7 @@ def get_version(*file_paths): # A list of warning types to suppress arbitrary warning messages. suppress_warnings = [ "image.nonlocal_uri", + "autodoc.mocked_object" ] # Add any paths that contain templates here, relative to this directory. diff --git a/openedx_authz/api/__init__.py b/openedx_authz/api/__init__.py new file mode 100644 index 00000000..981a42ed --- /dev/null +++ b/openedx_authz/api/__init__.py @@ -0,0 +1,11 @@ +"""Public API for the Open edX AuthZ framework. + +This module provides a public API as part of the Open edX AuthZ framework. This +is part of the Open edX Layer used to abstract the authorization engine and +provide a simpler interface for other services in the Open edX ecosystem. +""" + +from openedx_authz.api.data import * +from openedx_authz.api.permissions import * +from openedx_authz.api.roles import * +from openedx_authz.api.users import * diff --git a/openedx_authz/api/data.py b/openedx_authz/api/data.py new file mode 100644 index 00000000..17d09804 --- /dev/null +++ b/openedx_authz/api/data.py @@ -0,0 +1,660 @@ +"""Data classes and enums for representing roles, permissions, and policies.""" + +import re +from enum import Enum +from typing import ClassVar, Literal, Type + +from attrs import define +from opaque_keys import InvalidKeyError +from opaque_keys.edx.locator import LibraryLocatorV2 + +__all__ = [ + "UserData", + "PermissionData", + "GroupingPolicyIndex", + "PolicyIndex", + "ActionData", + "RoleAssignmentData", + "RoleData", + "ScopeData", + "SubjectData", +] + +AUTHZ_POLICY_ATTRIBUTES_SEPARATOR = "^" +EXTERNAL_KEY_SEPARATOR = ":" +NAMESPACED_KEY_PATTERN = rf"^.+{re.escape(AUTHZ_POLICY_ATTRIBUTES_SEPARATOR)}.+$" + + +class GroupingPolicyIndex(Enum): + """Index positions for fields in a Casbin grouping policy (g or g2). + + Grouping policies represent role assignments that link subjects to roles within scopes. + Format: [subject, role, scope, ...] + + Attributes: + SUBJECT: Position 0 - The subject identifier (e.g., 'user^john_doe'). + ROLE: Position 1 - The role identifier (e.g., 'role^instructor'). + SCOPE: Position 2 - The scope identifier (e.g., 'lib^lib:DemoX:CSPROB'). + + Note: + Additional fields beyond position 2 are optional and currently ignored. + """ + + SUBJECT = 0 + ROLE = 1 + SCOPE = 2 + # The rest of the fields are optional and can be ignored for now + + +class PolicyIndex(Enum): + """Index positions for fields in a Casbin policy (p). + + Policies define permissions by linking roles to actions within scopes with an effect. + Format: [role, action, scope, effect, ...] + + Attributes: + ROLE: Position 0 - The role identifier (e.g., 'role^instructor'). + ACT: Position 1 - The action identifier (e.g., 'act^read'). + SCOPE: Position 2 - The scope identifier (e.g., 'lib^lib:DemoX:CSPROB'). + EFFECT: Position 3 - The effect, either 'allow' or 'deny'. + + Note: + Additional fields beyond position 3 are optional and currently ignored. + """ + + ROLE = 0 + ACT = 1 + SCOPE = 2 + EFFECT = 3 + # The rest of the fields are optional and can be ignored for now + + +class AuthzBaseClass: + """Base class for all authz classes. + + Attributes: + SEPARATOR: The separator between the namespace and the identifier (default: '^'). + NAMESPACE: The namespace prefix for the data type (e.g., 'user', 'role', 'act', 'lib'). + """ + + SEPARATOR: ClassVar[str] = AUTHZ_POLICY_ATTRIBUTES_SEPARATOR + NAMESPACE: ClassVar[str] = None + + +@define +class AuthZData(AuthzBaseClass): + """Base class for all authz data classes. + + Attributes: + NAMESPACE: The namespace prefix for the data type (e.g., 'user', 'role', 'act', 'lib'). + SEPARATOR: The separator between the namespace and the identifier (default: '^'). + external_key: The ID for the object outside of the authz system (e.g., 'john_doe' for a user, + 'instructor' for a role, 'lib:DemoX:CSPROB' for a content library). + namespaced_key: The ID for the object within the authz system, combining namespace and external_key + (e.g., 'user^john_doe', 'role^instructor', 'lib^lib:DemoX:CSPROB'). + + Examples: + >>> user = UserData(external_key='john_doe') + >>> user.namespaced_key + 'user^john_doe' + >>> role = RoleData(namespaced_key='role^instructor') + >>> role.external_key + 'instructor' + """ + + external_key: str = "" + namespaced_key: str = "" + + def __attrs_post_init__(self): + """Post-initialization processing for attributes. + + This method ensures that either external_key or namespaced_key is provided, + and derives the other attribute based on the NAMESPACE and SEPARATOR. + """ + if not self.NAMESPACE: + # No namespace defined, nothing to do + return + + if not self.external_key and not self.namespaced_key: + raise ValueError("Either external_key or namespaced_key must be provided.") + + # Case 1: Initialized with external_key only, derive namespaced_key + if not self.namespaced_key: + self.namespaced_key = f"{self.NAMESPACE}{self.SEPARATOR}{self.external_key}" + + # Case 2: Initialized with namespaced_key only, derive external_key. Assume valid format for + # namespaced_key at this point. + if not self.external_key: + self.external_key = self.namespaced_key.split(self.SEPARATOR, 1)[1] + + +class ScopeMeta(type): + """Metaclass for ScopeData to handle dynamic subclass instantiation based on namespace.""" + + scope_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 + + def __call__(cls, *args, **kwargs): + """Instantiate the appropriate ScopeData subclass dynamically. + + This metaclass enables polymorphic instantiation based on either the external_key + format or the namespaced_key prefix, automatically returning the correct subclass. + + Instantiation modes: + 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 + + 2. namespaced_key: Determines subclass from the namespace prefix before '^'. + Example: ScopeData(namespaced_key='lib^lib:DemoX:CSPROB') → ContentLibraryData + + Usage patterns: + - namespaced_key: Used when retrieving objects from the policy store + - external_key: Used when initializing from user input or API calls + + Examples: + >>> # From external key (e.g., API input) + >>> scope = ScopeData(external_key='lib:DemoX:CSPROB') + >>> isinstance(scope, ContentLibraryData) + True + >>> # From namespaced key (e.g., policy store) + >>> scope = ScopeData(namespaced_key='lib^lib:DemoX:CSPROB') + >>> isinstance(scope, ContentLibraryData) + True + """ + if cls is not ScopeData: + return super().__call__(*args, **kwargs) + + if "namespaced_key" in kwargs: + scope_cls = cls.get_subclass_by_namespaced_key(kwargs["namespaced_key"]) + return super(ScopeMeta, scope_cls).__call__(*args, **kwargs) + + if "external_key" in kwargs: + scope_cls = cls.get_subclass_by_external_key(kwargs["external_key"]) + return super(ScopeMeta, scope_cls).__call__(*args, **kwargs) + + return super().__call__(*args, **kwargs) + + @classmethod + 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. + + Args: + namespaced_key: The namespaced key (e.g., 'lib^lib:DemoX:CSPROB', 'sc^generic'). + + Returns: + The ScopeData subclass for the namespace, or ScopeData if namespace not recognized. + + Examples: + >>> ScopeMeta.get_subclass_by_namespaced_key('lib^lib:DemoX:CSPROB') + + >>> ScopeMeta.get_subclass_by_namespaced_key('sc^generic') + + """ + # TODO: Default separator, can't access directly from class so made it a constant + 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] + return mcs.scope_registry.get(namespace, ScopeData) + + @classmethod + def get_subclass_by_external_key(mcs, external_key: str) -> Type["ScopeData"]: + """Get the appropriate ScopeData subclass from the external key format. + + Extracts the namespace from the external key (before the first ':') and validates + the key format using the subclass's validate_external_key method. + + Args: + external_key: The external key (e.g., 'lib:DemoX:CSPROB', 'sc:generic'). + + Returns: + The ScopeData subclass corresponding to the namespace. + + Raises: + ValueError: If the external_key format is invalid or namespace is not recognized. + + Examples: + >>> ScopeMeta.get_subclass_by_external_key('lib:DemoX:CSPROB') + + + 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. + - 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. + """ + if EXTERNAL_KEY_SEPARATOR not in external_key: + raise ValueError(f"Invalid external_key format: {external_key}") + + namespace = external_key.split(EXTERNAL_KEY_SEPARATOR, 1)[0] + 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}") + + return scope_subclass + + @classmethod + def validate_external_key(mcs, external_key: str) -> bool: + """Validate the external_key format for the subclass. + + Args: + external_key: The external key to validate. + + Returns: + bool: True if valid, False otherwise. + """ + raise NotImplementedError( + "Subclasses must implement validate_external_key method." + ) + + +@define +class ScopeData(AuthZData, metaclass=ScopeMeta): + """A scope is a context in which roles and permissions are assigned. + + This is the base class for scope types. Specific scope types (like ContentLibraryData) + are subclasses with their own namespace prefixes. This class is supposed to be generic + and not tied to any specific scope type, holding attributes common to all scopes. + + Attributes: + NAMESPACE: 'sc' for generic scopes. + external_key: The scope identifier without namespace (e.g., 'generic_scope'). + namespaced_key: The scope identifier with namespace (e.g., 'sc^generic_scope'). + + Examples: + >>> scope = ScopeData(external_key='generic_scope') + >>> scope.namespaced_key + 'sc^generic_scope' + """ + + NAMESPACE: ClassVar[str] = "sc" + + @classmethod + def validate_external_key(cls, _: str) -> bool: + """Validate the external_key format for ScopeData. + + For the base ScopeData class, we accept any external_key works. This + is only implemented for the sake of completeness. Subclasses should + implement their own validation logic. + + Args: + external_key: The external key to validate. + + Returns: + bool: True if valid, False otherwise. + """ + return True + + +@define +class ContentLibraryData(ScopeData): + """A content library scope for authorization in the Open edX platform. + + 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'). + 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. + + Examples: + >>> library = ContentLibraryData(external_key='lib:DemoX:CSPROB') + >>> library.namespaced_key + 'lib^lib:DemoX:CSPROB' + >>> library.library_id + 'lib:DemoX:CSPROB' + + Note: + TODO: this class should live alongside library definitions and not here. + """ + + NAMESPACE: ClassVar[str] = "lib" + + @property + def library_id(self) -> str: + """The library identifier as used in Open edX (e.g., 'lib:DemoX:CSPROB'). + + This is an alias for external_key that represents the library ID without the namespace prefix. + + Returns: + str: The library identifier without namespace. + """ + return self.external_key + + @classmethod + def validate_external_key(cls, external_key: str) -> bool: + """Validate the external_key format for ContentLibraryData. + + Args: + external_key: The external key to validate. + + Returns: + bool: True if valid, False otherwise. + """ + try: + LibraryLocatorV2.from_string(external_key) + return True + except InvalidKeyError: + return False + + def __str__(self): + """Human readable string representation of the content library.""" + return self.library_id + + def __repr__(self): + """Developer friendly string representation of the content library.""" + return self.namespaced_key + + +class SubjectMeta(type): + """Metaclass for SubjectData to handle dynamic subclass instantiation based on namespace.""" + + subject_registry: ClassVar[dict[str, Type["SubjectData"]]] = {} + + def __init__(cls, name, bases, attrs): + """Initialize the metaclass and register subclasses.""" + super().__init__(name, bases, attrs) + if not hasattr(cls, "subject_registry"): + cls.subject_registry = {} + cls.subject_registry[cls.NAMESPACE] = cls + + def __call__(cls, *args, **kwargs): + """Instantiate the appropriate SubjectData subclass dynamically. + + This metaclass enables polymorphic instantiation based on the namespaced_key prefix, + automatically returning the correct subclass. + + Instantiation mode: + - namespaced_key: Determines subclass from the namespace prefix before '^'. + Example: SubjectData(namespaced_key='user^john_doe') → UserData + + Examples: + >>> subject = SubjectData(namespaced_key='user^alice') + >>> isinstance(subject, UserData) + True + >>> subject = SubjectData(namespaced_key='sub^generic') + >>> isinstance(subject, SubjectData) + True + + Note: + Currently, we cannot instantiate by external_key alone because we don't have + a way to determine the subclass from the external_key format. Use the specific + subclass directly (e.g., UserData(external_key='alice')) when needed. + """ + if cls is SubjectData and "namespaced_key" in kwargs: + subject_cls = cls.get_subclass_by_namespaced_key(kwargs["namespaced_key"]) + return super(SubjectMeta, subject_cls).__call__(*args, **kwargs) + + return super().__call__(*args, **kwargs) + + @classmethod + def get_subclass_by_namespaced_key(mcs, namespaced_key: str) -> Type["SubjectData"]: + """Get the appropriate SubjectData subclass from the namespaced key. + + Extracts the namespace prefix (before '^') and returns the registered subclass. + + Args: + namespaced_key: The namespaced key (e.g., 'user^alice', 'sub^generic'). + + Returns: + The SubjectData subclass for the namespace, or SubjectData if namespace not recognized. + + Examples: + >>> SubjectMeta.get_subclass_by_namespaced_key('user^alice') + + >>> SubjectMeta.get_subclass_by_namespaced_key('sub^generic') + + """ + namespace = namespaced_key.split(AUTHZ_POLICY_ATTRIBUTES_SEPARATOR, 1)[0] + return mcs.subject_registry.get(namespace, SubjectData) + + +@define +class SubjectData(AuthZData, metaclass=SubjectMeta): + """A subject is an entity that can be assigned roles and permissions. + + This is the base class for subject types. Specific subject types (like UserData) + are subclasses with their own namespace prefixes. + + Attributes: + NAMESPACE: 'sub' for generic subjects. + external_key: The subject identifier without namespace (e.g., 'generic'). + namespaced_key: The subject identifier with namespace (e.g., 'sub^generic'). + + Examples: + >>> subject = SubjectData(external_key='generic') + >>> subject.namespaced_key + 'sub^generic' + """ + + NAMESPACE: ClassVar[str] = "sub" + + +@define +class UserData(SubjectData): + """A user subject for authorization in the Open edX platform. + + This class represents individual users who can be assigned roles and permissions. + Can be initialized with either external_key or namespaced_key parameter. + + Attributes: + NAMESPACE: 'user' for user subjects. + external_key: The username (e.g., 'john_doe'). + namespaced_key: The username with namespace prefix (e.g., 'user^john_doe'). + username: Property alias for external_key. + + Examples: + >>> user = UserData(external_key='john_doe') + >>> user.namespaced_key + 'user^john_doe' + >>> user.username + 'john_doe' + >>> user2 = UserData(namespaced_key='user^jane_smith') + >>> user2.username + 'jane_smith' + """ + + NAMESPACE: ClassVar[str] = "user" + + @property + def username(self) -> str: + """The username for the user (e.g., 'john_doe'). + + This is an alias for external_key that represents the username without the namespace prefix. + + Returns: + str: The username without namespace. + """ + return self.external_key + + def __str__(self): + """Human readable string representation of the user.""" + return self.username + + def __repr__(self): + """Developer friendly string representation of the user.""" + return self.namespaced_key + + +@define +class ActionData(AuthZData): + """An action represents an operation that can be performed in the authorization system. + + Actions are the operations that can be allowed or denied in authorization policies. + + Attributes: + NAMESPACE: 'act' for actions. + external_key: The action identifier (e.g., 'read', 'write', 'delete_library'). + namespaced_key: The action identifier with namespace (e.g., 'act^read', 'act^delete_library'). + name: Property that returns a human-readable action name (e.g., 'Read', 'Delete Library'). + + Examples: + >>> action = ActionData(external_key='delete_library') + >>> action.namespaced_key + 'act^delete_library' + >>> action.name + 'Delete Library' + """ + + NAMESPACE: ClassVar[str] = "act" + + @property + def name(self) -> str: + """The human-readable name of the action (e.g., 'Delete Library', 'Edit Content'). + + This property transforms the external_key into a human-readable display name + by replacing underscores with spaces and capitalizing each word. + + Returns: + str: The human-readable action name (e.g., 'Delete Library'). + """ + return self.external_key.replace("_", " ").title() + + def __str__(self): + """Human readable string representation of the action.""" + return self.name + + def __repr__(self): + """Developer friendly string representation of the action.""" + return self.namespaced_key + + +@define +class PermissionData: + """A permission combines an action with an effect (allow or deny). + + Permissions define whether a specific action should be allowed or denied. + They are typically associated with roles in the authorization system. + + Attributes: + action: The action being permitted or denied (ActionData instance). + effect: The effect of the permission, either 'allow' or 'deny' (default: 'allow'). + + Examples: + >>> read_action = ActionData(external_key='read') + >>> permission = PermissionData(action=read_action, effect='allow') + >>> str(permission) + 'Read - allow' + >>> write_action = ActionData(external_key='write') + >>> deny_perm = PermissionData(action=write_action, effect='deny') + >>> str(deny_perm) + 'Write - deny' + """ + + action: ActionData = None + effect: Literal["allow", "deny"] = "allow" + + def __str__(self): + """Human readable string representation of the permission and its effect.""" + return f"{self.action} - {self.effect}" + + def __repr__(self): + """Developer friendly string representation of the permission.""" + return f"{self.action.namespaced_key} => {self.effect}" + + +@define +class RoleData(AuthZData): + """A role is a named collection of permissions that can be assigned to subjects. + + Roles group related permissions together for easier authorization management. + + Attributes: + NAMESPACE: 'role' for roles. + external_key: The role identifier (e.g., 'instructor', 'library_admin'). + namespaced_key: The role identifier with namespace (e.g., 'role^instructor'). + permissions: A list of PermissionData instances associated with this role. + name: Property that returns a human-readable role name (e.g., 'Instructor', 'Library Admin'). + + Examples: + >>> role = RoleData(external_key='instructor') + >>> role.namespaced_key + 'role^instructor' + >>> role.name + 'Instructor' + >>> action = ActionData(external_key='read') + >>> perm = PermissionData(action=action, effect='allow') + >>> role_with_perms = RoleData(external_key='instructor', permissions=[perm]) + >>> str(role_with_perms) + 'Instructor: Read - allow' + """ + + NAMESPACE: ClassVar[str] = "role" + permissions: list[PermissionData] = [] + + @property + def name(self) -> str: + """The human-readable name of the role (e.g., 'Library Admin', 'Course Instructor'). + + This property transforms the external_key into a human-readable display name + by replacing underscores with spaces and capitalizing each word. + + Returns: + str: The human-readable role name (e.g., 'Library Admin'). + """ + return self.external_key.replace("_", " ").title() + + def __str__(self): + """Human readable string representation of the role and its permissions.""" + return f"{self.name}: {', '.join(str(p) for p in self.permissions)}" + + def __repr__(self): + """Developer friendly string representation of the role.""" + return self.namespaced_key + + +@define +class RoleAssignmentData: + """A role assignment links a subject, roles, and a scope together. + + Role assignments represent the authorization grants in the system. They specify + that a particular subject (e.g., a user) has certain roles within a specific scope + (e.g., a content library). + + Attributes: + subject: The subject (e.g., UserData) to whom roles are assigned. + roles: A list of RoleData instances being assigned to the subject. + scope: The scope (e.g., ContentLibraryData) in which the roles apply. + + Examples: + >>> user = UserData(external_key='john_doe') + >>> role = RoleData(external_key='instructor') + >>> library = ContentLibraryData(external_key='lib:DemoX:CSPROB') + >>> assignment = RoleAssignmentData(subject=user, roles=[role], scope=library) + >>> str(assignment) + 'john_doe => Instructor @ lib:DemoX:CSPROB' + >>> repr(assignment) + 'user^john_doe => [role^instructor] @ lib^lib:DemoX:CSPROB' + """ + + subject: SubjectData = None # Needs defaults to avoid value error from attrs + roles: list[RoleData] = [] + scope: ScopeData = None + + def __str__(self): + """Human readable string representation of the role assignment.""" + role_names = ", ".join(role.name for role in self.roles) + return f"{self.subject} => {role_names} @ {self.scope}" + + def __repr__(self): + """Developer friendly string representation of the role assignment.""" + role_keys = ", ".join(role.namespaced_key for role in self.roles) + return f"{self.subject.namespaced_key} => [{role_keys}] @ {self.scope.namespaced_key}" diff --git a/openedx_authz/api/permissions.py b/openedx_authz/api/permissions.py new file mode 100644 index 00000000..e538d2c1 --- /dev/null +++ b/openedx_authz/api/permissions.py @@ -0,0 +1,68 @@ +"""Public API for permissions management. + +A permission is the authorization granted by a policy. It represents the +allowed actions(s) a subject can perform on an object. In Casbin, permissions +are not explicitly defined, but are inferred from the policy rules. +""" + +from openedx_authz.api.data import ActionData, PermissionData, PolicyIndex, ScopeData, SubjectData +from openedx_authz.engine.enforcer import enforcer + +__all__ = [ + "get_permission_from_policy", + "get_all_permissions_in_scope", + "is_subject_allowed", +] + + +def get_permission_from_policy(policy: list[str]) -> PermissionData: + """Convert a Casbin policy list to a PermissionData object. + + Args: + policy: A list representing a Casbin policy. + + Returns: + PermissionData: The corresponding PermissionData object or an empty PermissionData if the policy is invalid. + """ + if len(policy) < 4: # Do not count ptype + raise ValueError("Invalid policy format. Expected at least 4 elements.") + + return PermissionData( + action=ActionData(namespaced_key=policy[PolicyIndex.ACT.value]), + effect=policy[PolicyIndex.EFFECT.value], + ) + + +def get_all_permissions_in_scope(scope: ScopeData) -> list[PermissionData]: + """Retrieve all permissions associated with a specific scope. + + Args: + scope: The scope to filter permissions by. + + Returns: + list of PermissionData: A list of PermissionData objects associated with the given scope. + """ + actions = enforcer.get_filtered_policy( + PolicyIndex.SCOPE.value, scope.namespaced_key + ) + return [get_permission_from_policy(action) for action in actions] + + +def is_subject_allowed( + subject: SubjectData, + action: ActionData, + scope: ScopeData, +) -> bool: + """Check if a subject has a specific permission in a given scope. + + Args: + subject: The subject to check (e.g., user or service). + action: The action to check (e.g., 'view_course'). + scope: The scope in which to check the permission (e.g., 'course-v1:edX+DemoX+2021_T1'). + + Returns: + bool: True if the subject has the specified permission in the scope, False otherwise. + """ + return enforcer.enforce( + subject.namespaced_key, action.namespaced_key, scope.namespaced_key + ) diff --git a/openedx_authz/api/roles.py b/openedx_authz/api/roles.py new file mode 100644 index 00000000..26f4461e --- /dev/null +++ b/openedx_authz/api/roles.py @@ -0,0 +1,380 @@ +"""Public API for roles management. + +A role is named group of permissions (actions). Instead of assigning permissions to each +subject, permissions can be assigned to a role, and subjects inherit the role's +permissions. + +We'll interact with roles through this API, which will use the enforcer +internally to manage the underlying policies and role assignments. +""" + +from collections import defaultdict + +from openedx_authz.api.data import ( + GroupingPolicyIndex, + PermissionData, + PolicyIndex, + RoleAssignmentData, + RoleData, + ScopeData, + SubjectData, +) +from openedx_authz.api.permissions import get_permission_from_policy +from openedx_authz.engine.enforcer import enforcer + +__all__ = [ + "get_permissions_for_single_role", + "get_permissions_for_roles", + "get_all_roles_names", + "get_all_roles_in_scope", + "get_permissions_for_active_roles_in_scope", + "get_role_definitions_in_scope", + "assign_role_to_subject_in_scope", + "batch_assign_role_to_subjects_in_scope", + "unassign_role_from_subject_in_scope", + "batch_unassign_role_from_subjects_in_scope", + "get_subject_role_assignments_in_scope", + "get_subject_role_assignments_for_role_in_scope", + "get_all_subject_role_assignments_in_scope", + "get_subject_role_assignments", +] + +# TODO: these are the concerns we still have to address: +# 1. should we dependency inject the enforcer to the API functions? +# For now, we create a global enforcer instance for testing purposes +# 2. Where should we call load_filtered_policy? It makes sense to preload +# it based on the scope for enforcement time? What about these API functions? +# I believe they assume the enforcer is already loaded with the relevant policies +# in this case, ALL the policies, but that might not be the case + + +def get_permissions_for_single_role( + role: RoleData, +) -> list[PermissionData]: + """Get the permissions (actions) for a single role. + + Args: + role: A RoleData object representing the role. + + Returns: + list[PermissionData]: A list of PermissionData objects associated with the given role. + """ + policies = enforcer.get_implicit_permissions_for_user(role.namespaced_key) + return [get_permission_from_policy(policy) for policy in policies] + + +def get_permissions_for_roles( + roles: list[RoleData], +) -> dict[str, dict[str, list[PermissionData | str]]]: + """Get the permissions (actions) for a list of roles. + + Args: + role_names: A list of role names or a single role name. + + Returns: + dict[str, list[PermissionData]]: A dictionary mapping role names to their permissions and scopes. + """ + permissions_by_role = {} + + for role in roles: + permissions_by_role[role.external_key] = { + "permissions": get_permissions_for_single_role(role) + } + + return permissions_by_role + + +def get_permissions_for_active_roles_in_scope( + scope: ScopeData, role: RoleData | None = None +) -> dict[str, dict[str, list[PermissionData | str]]]: + """Retrieve all permissions granted by the specified roles within the given scope. + + This function operates on the principle that roles defined in policies are templates + that become active only when assigned to subjects with specific scopes. + + Role Definition vs Role Assignment: + + - Policy roles define potential permissions with namespace patterns (e.g., 'lib^*') + - Actual permissions are granted only when roles are assigned to subjects with + concrete scopes (e.g., 'lib^lib:DemoX:CSPROB') + - The namespace pattern in the policy ('lib^*') indicates the role is designed + for resources in that namespace, but doesn't grant blanket access + - The specific scope at assignment time ('lib^lib:DemoX:CSPROB') determines the exact + resource the permissions apply to + + Behavior: + + - Returns permissions only for roles that have been assigned to subjects + - Unassigned roles (those defined in policy but not given to any subject) + contribute no permissions to the result + - Scope filtering ensures permissions are returned only for the specified + resource scope, not for the broader namespace pattern + + Returns: + dict[str, list[PermissionData]]: A dictionary mapping the role external_key to its + permissions and scopes. + """ + filtered_policy = enforcer.get_filtered_grouping_policy( + GroupingPolicyIndex.SCOPE.value, scope.namespaced_key + ) + + if role: + filtered_policy = [ + policy + for policy in filtered_policy + if policy[GroupingPolicyIndex.ROLE.value] == role.namespaced_key + ] + + return get_permissions_for_roles( + [ + RoleData(namespaced_key=policy[GroupingPolicyIndex.ROLE.value]) + for policy in filtered_policy + ] + ) + + +def get_role_definitions_in_scope(scope: ScopeData) -> list[RoleData]: + """Get all role definitions available in a specific scope. + + See `get_permissions_for_active_roles_in_scope` for explanation of role + definitions vs assignments. + + Args: + scope: The scope to filter roles (e.g., 'lib^*' or '*' for global). + + Returns: + list[Role]: A list of roles. + """ + policy_filtered = enforcer.get_filtered_policy( + PolicyIndex.SCOPE.value, scope.namespaced_key + ) + + permissions_per_role = defaultdict( + lambda: { + "permissions": [], + "scopes": [], + } + ) + for policy in policy_filtered: + permissions_per_role[policy[PolicyIndex.ROLE.value]]["scopes"].append( + ScopeData(namespaced_key=policy[PolicyIndex.SCOPE.value]) + ) # TODO: I don't think this actually gets used anywhere + permissions_per_role[policy[PolicyIndex.ROLE.value]]["permissions"].append( + get_permission_from_policy(policy) + ) + + return [ + RoleData( + namespaced_key=role, + permissions=permissions["permissions"], + ) + for role, permissions in permissions_per_role.items() + ] + + +def get_all_roles_names() -> list[str]: + """Get all the available roles names in the current environment. + + Returns: + list[str]: A list of role names. + """ + return enforcer.get_all_subjects() + + +def get_all_roles_in_scope(scope: ScopeData) -> list[list[str]]: + """Get all the available role grouping policies in a specific scope. + + Args: + scope: The scope to filter roles (e.g., 'lib^*' or '*' for global). + + Returns: + list[list[str]]: A list of policies in the specified scope. + """ + return enforcer.get_filtered_grouping_policy( + GroupingPolicyIndex.SCOPE.value, scope.namespaced_key + ) + + +def assign_role_to_subject_in_scope( + subject: SubjectData, role: RoleData, scope: ScopeData +) -> None: + """Assign a role to a subject. + + Args: + subject: The ID of the subject. + role: The role to assign. + """ + enforcer.add_role_for_user_in_domain( + subject.namespaced_key, + role.namespaced_key, + scope.namespaced_key, + ) + + +def batch_assign_role_to_subjects_in_scope( + subjects: list[SubjectData], role: RoleData, scope: ScopeData +) -> None: + """Assign a role to a list of subjects. + + Args: + subjects: A list of subject IDs. + role: The role to assign. + """ + for subject in subjects: + assign_role_to_subject_in_scope(subject, role, scope) + + +def unassign_role_from_subject_in_scope( + subject: SubjectData, role: RoleData, scope: ScopeData +) -> None: + """Unassign a role from a subject. + + Args: + subject: The ID of the subject. + role: The role to unassign. + scope: The scope from which to unassign the role. + """ + enforcer.delete_roles_for_user_in_domain( + subject.namespaced_key, role.namespaced_key, scope.namespaced_key + ) + + +def batch_unassign_role_from_subjects_in_scope( + subjects: list[SubjectData], role: RoleData, scope: ScopeData +) -> None: + """Unassign a role from a list of subjects. + + Args: + subjects: A list of subject IDs. + role_name: The external_key of the role. + scope: The scope from which to unassign the role. + """ + for subject in subjects: + unassign_role_from_subject_in_scope(subject, role, scope) + + +def get_subject_role_assignments(subject: SubjectData) -> list[RoleAssignmentData]: + """Get all the roles for a subject across all scopes. + + Args: + subject: The SubjectData object representing the subject (e.g., SubjectData(external_key='john_doe')). + + Returns: + list[RoleAssignmentData]: A list of role assignments for the subject. + """ + role_assignments = [] + for policy in enforcer.get_filtered_grouping_policy( + GroupingPolicyIndex.SUBJECT.value, subject.namespaced_key + ): + role = RoleData(namespaced_key=policy[GroupingPolicyIndex.ROLE.value]) + role.permissions = get_permissions_for_single_role(role) + + role_assignments.append( + RoleAssignmentData( + subject=subject, + roles=[role], + scope=ScopeData(namespaced_key=policy[GroupingPolicyIndex.SCOPE.value]), + ) + ) + return role_assignments + + +def get_subject_role_assignments_in_scope( + subject: SubjectData, scope: ScopeData +) -> list[RoleAssignmentData]: + """Get the roles for a subject in a specific scope. + + Args: + subject: The SubjectData object representing the subject (e.g., SubjectData(external_key='john_doe')). + scope: The ScopeData object representing the scope (e.g., ScopeData(external_key='lib:DemoX:CSPROB')). + + Returns: + list[RoleAssignmentData]: A list of role assignments for the subject in the scope. + """ + # TODO: we still need to get the remaining data for the role like email, etc + role_assignments = [] + for namespaced_key in enforcer.get_roles_for_user_in_domain( + subject.namespaced_key, scope.namespaced_key + ): + role = RoleData(namespaced_key=namespaced_key) + role_assignments.append( + RoleAssignmentData( + subject=subject, + roles=[ + RoleData( + namespaced_key=namespaced_key, + permissions=get_permissions_for_single_role(role), + ) + ], + scope=scope, + ) + ) + return role_assignments + + +def get_subject_role_assignments_for_role_in_scope( + role: RoleData, scope: ScopeData +) -> list[RoleAssignmentData]: + """Get the subjects assigned to a specific role in a specific scope. + + Args: + role: The RoleData object representing the role (e.g., RoleData(external_key='library_admin')). + scope: The ScopeData object representing the scope (e.g., ScopeData(external_key='lib:DemoX:CSPROB')). + + Returns: + list[RoleAssignmentData]: A list of subjects assigned to the specified role in the specified scope. + """ + role_assignments = [] + for subject in enforcer.get_users_for_role_in_domain( + role.namespaced_key, scope.namespaced_key + ): + if subject.startswith(f"{RoleData.NAMESPACE}{RoleData.SEPARATOR}"): + # Skip roles that are also subjects + continue + + role_assignments.append( + RoleAssignmentData( + subject=SubjectData(namespaced_key=subject), + roles=[ + RoleData( + namespaced_key=role.namespaced_key, + permissions=get_permissions_for_single_role(role), + ) + ], + scope=scope, + ) + ) + + return role_assignments + + +def get_all_subject_role_assignments_in_scope( + scope: ScopeData, +) -> list[RoleAssignmentData]: + """Get all the subjects assigned to any role in a specific scope. + + Args: + scope: The ScopeData object representing the scope (e.g., ScopeData(external_key='lib:DemoX:CSPROB')). + + Returns: + list[RoleAssignmentData]: A list of role assignments for all subjects in the specified scope. + """ + role_assignments_per_subject = {} + roles_in_scope = get_all_roles_in_scope(scope) + + for policy in roles_in_scope: + subject = SubjectData(namespaced_key=policy[GroupingPolicyIndex.SUBJECT.value]) + role = RoleData(namespaced_key=policy[GroupingPolicyIndex.ROLE.value]) + role.permissions = get_permissions_for_single_role(role) + + if subject.external_key in role_assignments_per_subject: + role_assignments_per_subject[subject.external_key].roles.append(role) + continue + + role_assignments_per_subject[subject.external_key] = RoleAssignmentData( + subject=subject, + roles=[role], + scope=scope, + ) + + return list(role_assignments_per_subject.values()) diff --git a/openedx_authz/api/users.py b/openedx_authz/api/users.py new file mode 100644 index 00000000..14587ca8 --- /dev/null +++ b/openedx_authz/api/users.py @@ -0,0 +1,191 @@ +"""User-related API methods for role assignments and retrievals. + +This module provides user-related API methods for assigning roles to users, +unassigning roles from users, and retrieving roles assigned to users within +the Open edX AuthZ framework. + +These methods internally namespace user identifiers to ensure consistency +with the role management system, which uses namespaced subjects +(e.g., 'user^john_doe'). +""" + +from openedx_authz.api.data import ActionData, RoleAssignmentData, RoleData, ScopeData, UserData +from openedx_authz.api.permissions import is_subject_allowed +from openedx_authz.api.roles import ( + assign_role_to_subject_in_scope, + batch_assign_role_to_subjects_in_scope, + batch_unassign_role_from_subjects_in_scope, + get_all_subject_role_assignments_in_scope, + get_subject_role_assignments, + get_subject_role_assignments_for_role_in_scope, + get_subject_role_assignments_in_scope, + unassign_role_from_subject_in_scope, +) + +__all__ = [ + "assign_role_to_user_in_scope", + "batch_assign_role_to_users_in_scope", + "unassign_role_from_user", + "batch_unassign_role_from_users", + "get_user_role_assignments", + "get_user_role_assignments_in_scope", + "get_user_role_assignments_for_role_in_scope", + "get_all_user_role_assignments_in_scope", + "is_user_allowed", +] + + +def assign_role_to_user_in_scope( + user_external_key: str, role_external_key: str, scope_external_key: str +) -> bool: + """Assign a role to a user in a specific scope. + + Args: + user (str): ID of the user (e.g., 'john_doe'). + role_external_key (str): Name of the role to assign. + scope (str): Scope in which to assign the role. + """ + assign_role_to_subject_in_scope( + UserData(external_key=user_external_key), + RoleData(external_key=role_external_key), + ScopeData(external_key=scope_external_key), + ) + + +def batch_assign_role_to_users_in_scope( + users: list[str], role_external_key: str, scope_external_key: str +): + """Assign a role to multiple users in a specific scope. + + Args: + users (list of str): List of user IDs (e.g., ['john_doe', 'jane_smith']). + role_external_key (str): Name of the role to assign. + scope (str): Scope in which to assign the role. + """ + namespaced_users = [UserData(external_key=username) for username in users] + batch_assign_role_to_subjects_in_scope( + namespaced_users, + RoleData(external_key=role_external_key), + ScopeData(external_key=scope_external_key), + ) + + +def unassign_role_from_user( + user_external_key: str, role_external_key: str, scope_external_key: str +): + """Unassign a role from a user in a specific scope. + + Args: + user_external_key (str): ID of the user (e.g., 'john_doe'). + role_external_key (str): Name of the role to unassign. + scope_external_key (str): Scope in which to unassign the role. + """ + unassign_role_from_subject_in_scope( + UserData(external_key=user_external_key), + RoleData(external_key=role_external_key), + ScopeData(external_key=scope_external_key), + ) + + +def batch_unassign_role_from_users( + users: list[str], role_external_key: str, scope_external_key: str +): + """Unassign a role from multiple users in a specific scope. + + Args: + users (list of str): List of user IDs (e.g., ['john_doe', 'jane_smith']). + role_external_key (str): Name of the role to unassign. + scope (str): Scope in which to unassign the role. + """ + namespaced_users = [UserData(external_key=user) for user in users] + batch_unassign_role_from_subjects_in_scope( + namespaced_users, + RoleData(external_key=role_external_key), + ScopeData(external_key=scope_external_key), + ) + + +def get_user_role_assignments(user_external_key: str) -> list[RoleAssignmentData]: + """Get all roles for a user across all scopes. + + Args: + user_external_key (str): ID of the user (e.g., 'john_doe'). + + Returns: + list[RoleAssignmentData]: A list of role assignments and all their metadata assigned to the user. + """ + return get_subject_role_assignments(UserData(external_key=user_external_key)) + + +def get_user_role_assignments_in_scope( + user_external_key: str, scope_external_key: str +) -> list[RoleAssignmentData]: + """Get the roles assigned to a user in a specific scope. + + Args: + user (str): ID of the user (e.g., 'john_doe'). + scope (str): Scope in which to retrieve the roles. + + Returns: + list[RoleAssignmentData]: A list of role assignments assigned to the user in the specified scope. + """ + return get_subject_role_assignments_in_scope( + UserData(external_key=user_external_key), + ScopeData(external_key=scope_external_key), + ) + + +def get_user_role_assignments_for_role_in_scope( + role_external_key: str, scope_external_key: str +) -> list[RoleAssignmentData]: + """Get all users assigned to a specific role across all scopes. + + Args: + role_external_key (str): Name of the role (e.g., 'instructor'). + scope (str): Scope in which to retrieve the role assignments. + + Returns: + list[RoleAssignmentData]: List of users assigned to the specified role in the given scope. + """ + return get_subject_role_assignments_for_role_in_scope( + RoleData(external_key=role_external_key), + ScopeData(external_key=scope_external_key), + ) + + +def get_all_user_role_assignments_in_scope( + scope_external_key: str, +) -> list[RoleAssignmentData]: + """Get all user role assignments in a specific scope. + + Args: + scope (str): Scope in which to retrieve the user role assignments. + + Returns: + list[RoleAssignmentData]: A list of user role assignments and all their metadata in the specified scope. + """ + return get_all_subject_role_assignments_in_scope( + ScopeData(external_key=scope_external_key) + ) + + +def is_user_allowed( + user_external_key: str, + action_external_key: str, + scope_external_key: str, +) -> bool: + """Check if a user has a specific permission in a given scope. + + Args: + user_external_key (str): ID of the user (e.g., 'john_doe'). + action_external_key (str): The action to check (e.g., 'view_course'). + scope_external_key (str): The scope in which to check the permission (e.g., 'course-v1:edX+DemoX+2021_T1'). + + Returns: + bool: True if the user has the specified permission in the scope, False otherwise. + """ + return is_subject_allowed( + UserData(external_key=user_external_key), + ActionData(external_key=action_external_key), + ScopeData(external_key=scope_external_key), + ) diff --git a/openedx_authz/engine/config/authz.policy b/openedx_authz/engine/config/authz.policy index 2bae77ed..6c2c6759 100644 --- a/openedx_authz/engine/config/authz.policy +++ b/openedx_authz/engine/config/authz.policy @@ -1,11 +1,60 @@ -# ===== ACTION GROUPING (g2) ===== +############################################ +# Open edX AuthZ — Casbin Policy Configuration +# +# This file defines policies that work with the model configuration. +# Uses namespaced subjects, actions, and scopes for maximum flexibility. +############################################ -# manage implies edit, delete, read, write -g2, act:manage, act:edit -g2, act:manage, act:delete -g2, act:edit, act:read -g2, act:edit, act:write +# Policy definitions - format: p = subject(role), action, scope, effect +# For role definitions use: lib^*, course^*, org^* to specify the scope of the role -# edit implies read, write -g2, act:edit, act:read -g2, act:edit, act:write +# Library Admin Role Policies +p, role^library_admin, act^delete_library, lib^*, allow +p, role^library_admin, act^publish_library, lib^*, allow +p, role^library_admin, act^manage_library_team, lib^*, allow +p, role^library_admin, act^manage_library_tags, lib^*, allow +p, role^library_admin, act^delete_library_content, lib^*, allow +p, role^library_admin, act^publish_library_content, lib^*, allow +p, role^library_admin, act^delete_library_collection, lib^*, allow +p, role^library_admin, act^create_library, lib^*, allow +p, role^library_admin, act^create_library_collection, lib^*, allow + +# Library Author Role Policies +p, role^library_author, act^delete_library_content, lib^*, allow +p, role^library_author, act^publish_library_content, lib^*, allow +p, role^library_author, act^edit_library, lib^*, allow +p, role^library_author, act^manage_library_tags, lib^*, allow +p, role^library_author, act^create_library_collection, lib^*, allow +p, role^library_author, act^edit_library_collection, lib^*, allow +p, role^library_author, act^delete_library_collection, lib^*, allow + +# Library Collaborator Role Policies +p, role^library_collaborator, act^edit_library, lib^*, allow +p, role^library_collaborator, act^delete_library_content, lib^*, allow +p, role^library_collaborator, act^manage_library_tags, lib^*, allow +p, role^library_collaborator, act^create_library_collection, lib^*, allow +p, role^library_collaborator, act^edit_library_collection, lib^*, allow +p, role^library_collaborator, act^delete_library_collection, lib^*, allow + +# Library User Role Policies +p, role^library_user, act^view_library, lib^*, allow +p, role^library_user, act^view_library_team, lib^*, allow +p, role^library_user, act^reuse_library_content, lib^*, allow + +# Action Inheritance (g2) - format: g2 = granted_action, implied_action +# Higher-level permissions automatically grant lower-level permissions +# If a user has the granted_action, they also have the implied_action +# Example: g2, act^delete_library, act^view_library means delete permission includes view permission +g2, act^delete_library, act^view_library +g2, act^edit_library, act^view_library +g2, act^create_library, act^view_library +g2, act^publish_library, act^view_library +g2, act^manage_library_team, act^view_library_team +g2, act^manage_library_tags, act^view_library_tags +g2, act^delete_library_collection, act^edit_library_collection +g2, act^edit_library_collection, act^view_library_collection +g2, act^create_library_collection, act^edit_library_collection +g2, act^edit_library_content, act^view_library_content +g2, act^delete_library_content, act^edit_library_content +g2, act^publish_library_content, act^view_library_content +g2, act^reuse_library_content, act^view_library_content diff --git a/openedx_authz/engine/config/model.conf b/openedx_authz/engine/config/model.conf index e3e2ae9c..89bb2bd8 100644 --- a/openedx_authz/engine/config/model.conf +++ b/openedx_authz/engine/config/model.conf @@ -6,33 +6,36 @@ # - Action grouping (manage → read/write/edit/delete to reduce duplication) # - System-wide roles (global scope "*" applies everywhere) # - Negative rules (deny overrides allow for exceptions) -# - Namespace support (course:*, lib:*, org:*, etc.) +# - Namespace support (user^, role^, act^, lib^, org^, course^, etc.) # - Extensibility (new resource types just need new namespaces) +# - Separator: ^ for AuthZ policy attributes, : for external keys ############################################ [request_definition] # Request format: subject (user), action, scope (specific resource being accessed) # -# sub = subject/principal with namespace (e.g., "user:alice", "service:lms") -# act = action with namespace (e.g., "act:read", "act:manage", "act:edit-courses") -# scope = authorization scope context (e.g., "org:OpenedX", "course-v1:...", "*" for global) +# sub = subject/principal with namespace (e.g., "user^alice", "service^lms") +# act = action with namespace (e.g., "act^read", "act^manage", "act^edit_courses") +# scope = authorization scope context (e.g., "org^OpenedX", "lib^lib:...", "*" for global) # # SCOPE SEMANTICS: # Scope determines the authorization context and which role assignments apply -# - "*" = global scope (system-wide roles apply everywhere) -# - "org:..." = organization-scoped roles (apply within specific organization) -# - "course-v1:..." = course-scoped roles (apply within specific course) -# - "lib:..." = library-scoped roles (apply within specific library) +# - "*" = global scope (system-wide roles apply everywhere) +# - "org^..." = organization-scoped roles (namespaced external org key) +# - "course^course-v1:..." = course-scoped roles (namespaced external course key) +# - "lib^lib:..." = library-scoped roles (namespaced external library key) # +# Note: AuthZ policy attributes use ^ separator for namespace prefix (e.g., user^alice, role^admin), +# while external keys (course-v1:..., lib:...) retain their original : separator format. # Application must provide appropriate scope based on business logic. r = sub, act, scope [policy_definition] # Policy format: subject (role), action, scope (pattern), effect # -# sub = role or user with namespace (e.g., "role:org_admin", "user:bob") -# act = action identifier (e.g., "act:manage", "act:read", "act:edit-courses") -# scope = scope where policy applies (e.g., "*", "org:*", "course-v1:*", "lib:*") +# sub = role or user with namespace (e.g., "role^org_admin", "user^bob") +# act = action identifier (e.g., "act^manage", "act^read", "act^edit_courses") +# scope = scope where policy applies (e.g., "*", "org^*", "lib^*") # eft = "allow" or "deny" (deny overrides allow for exceptions) p = sub, act, scope, eft @@ -41,22 +44,22 @@ p = sub, act, scope, eft # Format: user/subject, role, scope # # Examples: -# g, user:alice, role:org_admin, org:OpenedX # Alice is org admin for OpenedX -# g, user:bob, role:course_instructor, course-v1:... # Bob is instructor for specific course -# g, user:carol, role:library_admin, * # Carol is global library admin +# g, user^alice, role^org_admin, org^OpenedX # Alice is org admin for OpenedX +# g, user^bob, role^course_instructor, course^course-v1:... # Bob is instructor for specific course +# g, user^carol, role^library_admin, * # Carol is global library admin # # Role hierarchy (optional): -# g, role:org_admin, role:org_editor, org:OpenedX # org_admin inherits org_editor permissions +# g, role^org_admin, role^org_editor, org^OpenedX # org_admin inherits org_editor permissions g = _, _, _ # g2: Action grouping and implications # Maps high-level actions to specific actions to reduce policy duplication # # Examples: -# g2, act:manage, act:edit # manage implies edit -# g2, act:manage, act:delete # manage implies delete -# g2, act:edit-courses, act:read # edit-courses implies read (for resource access) -# g2, act:edit-courses, act:write # edit-courses implies write (for resource modification) +# g2, act^manage, act^edit # manage implies edit +# g2, act^manage, act^delete # manage implies delete +# g2, act^edit_courses, act^read # edit_courses implies read (for resource access) +# g2, act^edit_courses, act^write # edit_courses implies write (for resource modification) g2 = _, _ [policy_effect] diff --git a/openedx_authz/engine/filter.py b/openedx_authz/engine/filter.py index 417ea5de..fd05953a 100644 --- a/openedx_authz/engine/filter.py +++ b/openedx_authz/engine/filter.py @@ -43,24 +43,24 @@ class Filter: v0: Optional[list[str]] = attr.field(factory=list) """v0 (Optional[list[str]]): First policy value filter. - - For ``p`` → Subject (e.g., ``role:org_admin``, ``user:alice``). - - For ``g`` → User (e.g., ``user:alice``). - - For ``g2`` → Parent action (e.g., ``act:manage``). + - For ``p`` → Subject (e.g., ``role^org_admin``, ``user^alice``). + - For ``g`` → User (e.g., ``user^alice``). + - For ``g2`` → Parent action (e.g., ``act^manage``). """ v1: Optional[list[str]] = attr.field(factory=list) """v1 (Optional[list[str]]): Second policy value filter. - - For ``p`` → Action (e.g., ``act:manage``, ``act:edit``). - - For ``g`` → Role (e.g., ``role:org_admin``). - - For ``g2`` → Child action (e.g., ``act:edit``). + - For ``p`` → Action (e.g., ``act^manage``, ``act^edit``). + - For ``g`` → Role (e.g., ``role^org_admin``). + - For ``g2`` → Child action (e.g., ``act^edit``). """ v2: Optional[list[str]] = attr.field(factory=list) """v2 (Optional[list[str]]): Third policy value filter. - - For ``p`` → Object or resource (e.g., ``lib:*``, ``org:MIT``). - - For ``g`` → Scope or resource (e.g., ``org:MIT``). + - For ``p`` → Object or resource (e.g., ``lib^*``, ``org^MIT``). + - For ``g`` → Scope or resource (e.g., ``org^MIT``). - For ``g2`` → Not used. """ diff --git a/openedx_authz/engine/utils.py b/openedx_authz/engine/utils.py new file mode 100644 index 00000000..f346cf40 --- /dev/null +++ b/openedx_authz/engine/utils.py @@ -0,0 +1,55 @@ +"""Policy loader module. + +This module provides functionality to load and manage policy definitions +for the Open edX AuthZ system using Casbin. +""" + +import logging + +from casbin import Enforcer + +logger = logging.getLogger(__name__) + +GROUPING_POLICY_PTYPES = ["g", "g2", "g3", "g4", "g5", "g6"] + + +def migrate_policy_between_enforcers( + source_enforcer: Enforcer, + target_enforcer: Enforcer, +) -> None: + """Load policies from a Casbin policy file into the Django database model. + + Args: + source_enforcer (Enforcer): The Casbin enforcer instance to migrate policies from (e.g., file-based). + target_enforcer (Enforcer): The Casbin enforcer instance to migrate policies to (e.g.,database). + """ + try: + # Load latest policies from the source enforcer + source_enforcer.load_policy() + policies = source_enforcer.get_policy() + for policy in policies: + if not target_enforcer.has_policy(*policy): + target_enforcer.add_policy(*policy) + + for grouping_policy_ptype in GROUPING_POLICY_PTYPES: + try: + grouping_policies = source_enforcer.get_named_grouping_policy( + grouping_policy_ptype + ) + for grouping in grouping_policies: + if not target_enforcer.has_named_grouping_policy( + grouping_policy_ptype, *grouping + ): + target_enforcer.add_named_grouping_policy( + grouping_policy_ptype, *grouping + ) + except KeyError as e: + logger.debug( + f"Skipping {grouping_policy_ptype} policies: {e} not found in source enforcer." + ) + logger.info( + f"Successfully loaded policies from {source_enforcer.get_model()} into the database." + ) + except Exception as e: + logger.error(f"Error loading policies from file: {e}") + raise diff --git a/openedx_authz/engine/watcher.py b/openedx_authz/engine/watcher.py index 5cc945b3..c8d2665c 100644 --- a/openedx_authz/engine/watcher.py +++ b/openedx_authz/engine/watcher.py @@ -48,9 +48,9 @@ def create_watcher(): watcher = new_watcher(watcher_options) logger.info("Redis watcher created successfully") return watcher - except Exception as e: + except Exception as e: # pylint: disable=broad-exception-caught logger.error(f"Failed to create Redis watcher: {e}") - raise + return None if settings.CASBIN_WATCHER_ENABLED: diff --git a/openedx_authz/management/commands/enforcement.py b/openedx_authz/management/commands/enforcement.py index 32719f29..31f7f902 100644 --- a/openedx_authz/management/commands/enforcement.py +++ b/openedx_authz/management/commands/enforcement.py @@ -18,7 +18,7 @@ python manage.py enforcement --policy-file-path /path/to/authz.policy --model-file-path /path/to/model.conf Example test input: - user:alice act:read org:OpenedX + user^alice act^read org^OpenedX """ import argparse @@ -78,7 +78,9 @@ def handle(self, *args, **options): Raises: CommandError: If model or policy files are not found or enforcer creation fails. """ - model_file_path = self._get_file_path("model.conf") or options["model_file_path"] + model_file_path = ( + self._get_file_path("model.conf") or options["model_file_path"] + ) policy_file_path = options["policy_file_path"] if not os.path.isfile(model_file_path): @@ -93,7 +95,9 @@ def handle(self, *args, **options): try: enforcer = casbin.Enforcer(model_file_path, policy_file_path) - self.stdout.write(self.style.SUCCESS("Casbin enforcer created successfully")) + self.stdout.write( + self.style.SUCCESS("Casbin enforcer created successfully") + ) policies = enforcer.get_policy() roles = enforcer.get_grouping_policy() @@ -138,7 +142,7 @@ def _run_interactive_mode(self, enforcer: casbin.Enforcer) -> None: self.stdout.write("Enter 'quit', 'exit', or 'q' to exit the interactive mode.") self.stdout.write("") self.stdout.write("Format: subject action scope") - self.stdout.write("Example: user:alice act:read org:OpenedX") + self.stdout.write("Example: user^alice act^read org^OpenedX") self.stdout.write("") while True: @@ -156,7 +160,9 @@ def _run_interactive_mode(self, enforcer: casbin.Enforcer) -> None: self.stdout.write(self.style.ERROR("Exiting interactive mode...")) break - def _test_interactive_request(self, enforcer: casbin.Enforcer, user_input: str) -> None: + def _test_interactive_request( + self, enforcer: casbin.Enforcer, user_input: str + ) -> None: """Process and test a single enforcement request from user input. Parses the input string, validates the format, executes the enforcement @@ -167,25 +173,33 @@ def _test_interactive_request(self, enforcer: casbin.Enforcer, user_input: str) user_input (str): The user's input string in format 'subject action scope'. Expected format: - subject: The requesting entity (e.g., 'user:alice') - action: The requested action (e.g., 'act:read') - scope: The authorization context (e.g., 'org:OpenedX') + subject: The requesting entity (e.g., 'user^alice') + action: The requested action (e.g., 'act^read') + scope: The authorization context (e.g., 'org^OpenedX') """ try: parts = [part.strip() for part in user_input.split()] if len(parts) != 3: - self.stdout.write(self.style.ERROR(f"✗ Invalid format. Expected 3 parts, got {len(parts)}")) + self.stdout.write( + self.style.ERROR( + f"✗ Invalid format. Expected 3 parts, got {len(parts)}" + ) + ) self.stdout.write("Format: subject action scope") - self.stdout.write("Example: user:alice act:read org:OpenedX") + self.stdout.write("Example: user^alice act^read org^OpenedX") return subject, action, scope = parts result = enforcer.enforce(subject, action, scope) if result: - self.stdout.write(self.style.SUCCESS(f"✓ ALLOWED: {subject} {action} {scope}")) + self.stdout.write( + self.style.SUCCESS(f"✓ ALLOWED: {subject} {action} {scope}") + ) else: - self.stdout.write(self.style.ERROR(f"✗ DENIED: {subject} {action} {scope}")) + self.stdout.write( + self.style.ERROR(f"✗ DENIED: {subject} {action} {scope}") + ) except (ValueError, IndexError, TypeError) as e: self.stdout.write(self.style.ERROR(f"✗ Error processing request: {str(e)}")) diff --git a/openedx_authz/management/commands/load_policies.py b/openedx_authz/management/commands/load_policies.py new file mode 100644 index 00000000..36d34ad6 --- /dev/null +++ b/openedx_authz/management/commands/load_policies.py @@ -0,0 +1,90 @@ +"""Django management command to load policies into the authz Django model. + +The command supports: +- Specifying the path to the Casbin policy file. Default is 'openedx_authz/engine/config/authz.policy'. +- Specifying the Casbin model configuration file. Default is 'openedx_authz/engine/config/model.conf'. +- Optionally clearing existing policies in the database before loading new ones. +""" + +import os + +import casbin +from django.core.management.base import BaseCommand + +from openedx_authz import ROOT_DIRECTORY +from openedx_authz.engine.enforcer import enforcer as global_enforcer +from openedx_authz.engine.utils import migrate_policy_between_enforcers + + +class Command(BaseCommand): + """Django management command to load policies into the authorization Django model. + + This command reads policies from a specified Casbin policy file and loads them into + the Django database model used by the Casbin adapter. This allows for easy management + and persistence of authorization policies within the Django application. + + Example Usage: + python manage.py load_policies --policy-file-path /path/to/authz.policy + python manage.py load_policies --policy-file-path /path/to/authz.policy --model-file-path /path/to/model.conf + python manage.py load_policies + """ + + help = "Load policies from a Casbin policy file into the Django database model." + + def add_arguments(self, parser) -> None: + """Add command-line arguments to the argument parser. + + Args: + parser: The Django argument parser instance to configure. + """ + parser.add_argument( + "--policy-file-path", + type=str, + default=None, + help="Path to the Casbin policy file (supports CSV format with policies, roles, and action grouping)", + ) + parser.add_argument( + "--model-file-path", + type=str, + default=None, + help="Path to the Casbin model configuration file", + ) + + def handle(self, *args, **options): + """Execute the policy loading command. + + Loads policies from the specified Casbin policy file into the Django database model. + Optionally clears existing policies before loading new ones. + + Args: + *args: Positional command arguments (unused). + **options: Command options including 'policy_file_path', 'model_file_path', and 'clear_existing'. + + Raises: + CommandError: If the policy file is not found or loading fails. + """ + policy_file_path, model_file_path = options["policy_file_path"], options["model_file_path"] + if policy_file_path is None: + policy_file_path = os.path.join( + ROOT_DIRECTORY, "engine", "config", "authz.policy" + ) + if model_file_path is None: + model_file_path = os.path.join( + ROOT_DIRECTORY, "engine", "config", "model.conf" + ) + + source_enforcer = casbin.Enforcer(model_file_path, policy_file_path) + self.migrate_policies(source_enforcer, global_enforcer) + + def migrate_policies(self, source_enforcer, target_enforcer): + """Migrate policies from the source enforcer to the target enforcer. + + This method copies all policies, role assignments, and action groupings + from the source enforcer (file-based) to the target enforcer (database-backed). + Optionally clears existing policies in the target before migration. + + Args: + source_enforcer: The Casbin enforcer instance to migrate policies from. + target_enforcer: The Casbin enforcer instance to migrate policies to. + """ + migrate_policy_between_enforcers(source_enforcer, target_enforcer) diff --git a/openedx_authz/models.py b/openedx_authz/models.py index 8297668b..a25e4017 100644 --- a/openedx_authz/models.py +++ b/openedx_authz/models.py @@ -1,3 +1,10 @@ """ -Database models for openedx_authz. +Database models for the authorization framework. + +These models will be used to store additional data about roles and permissions +that are not natively supported by Casbin, so as to avoid modifying the Casbin +schema that focuses on the core authorization logic. + +For example, we may want to store metadata about roles, such as a description +or the date it was created. """ diff --git a/openedx_authz/settings/common.py b/openedx_authz/settings/common.py index 22feabd3..c7753860 100644 --- a/openedx_authz/settings/common.py +++ b/openedx_authz/settings/common.py @@ -22,7 +22,9 @@ def plugin_settings(settings): settings.INSTALLED_APPS.append(casbin_adapter_app) # Add Casbin configuration - settings.CASBIN_MODEL = os.path.join(ROOT_DIRECTORY, "engine", "config", "model.conf") + settings.CASBIN_MODEL = os.path.join( + ROOT_DIRECTORY, "engine", "config", "model.conf" + ) settings.CASBIN_WATCHER_ENABLED = True # TODO: Replace with a more dynamic configuration # Redis host and port are temporarily loaded here for the MVP diff --git a/openedx_authz/settings/test.py b/openedx_authz/settings/test.py index 3d90b291..c52856c1 100644 --- a/openedx_authz/settings/test.py +++ b/openedx_authz/settings/test.py @@ -2,18 +2,15 @@ Test settings for openedx_authz plugin. """ -from os.path import abspath, dirname, join +import os from openedx_authz import ROOT_DIRECTORY - -def root(*args): - """ - Get the absolute path of the given path relative to the project root. - """ - return join(abspath(dirname(__file__)), *args) - - +# Add Casbin configuration +CASBIN_MODEL = os.path.join(ROOT_DIRECTORY, "engine", "config", "model.conf") +# Redis host and port are temporarily loaded here for the MVP +REDIS_HOST = "redis" +REDIS_PORT = 6379 DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", @@ -31,39 +28,32 @@ def root(*args): "django.contrib.contenttypes", "django.contrib.messages", "django.contrib.sessions", - "casbin_adapter", - "openedx_authz", + "openedx_authz.apps.OpenedxAuthzConfig", + "casbin_adapter.apps.CasbinAdapterConfig", ) -LOCALE_PATHS = [ - root("openedx_authz", "conf", "locale"), -] - -ROOT_URLCONF = "openedx_authz.urls" - -SECRET_KEY = "insecure-secret-key" - -MIDDLEWARE = ( +MIDDLEWARE = [ + "django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", -) +] TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", - "APP_DIRS": False, + "DIRS": [], + "APP_DIRS": True, "OPTIONS": { "context_processors": [ - "django.contrib.auth.context_processors.auth", # this is required for admin - "django.contrib.messages.context_processors.messages", # this is required for admin + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, - } + }, ] - -CASBIN_MODEL = join(ROOT_DIRECTORY, "engine", "config", "model.conf") +SECRET_KEY = "test-secret-key" CASBIN_WATCHER_ENABLED = False -REDIS_HOST = "redis" -REDIS_PORT = 6379 +USE_TZ = True diff --git a/openedx_authz/tests/api/__init__.py b/openedx_authz/tests/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_authz/tests/api/test_data.py b/openedx_authz/tests/api/test_data.py new file mode 100644 index 00000000..e55b5119 --- /dev/null +++ b/openedx_authz/tests/api/test_data.py @@ -0,0 +1,514 @@ +"""Test data for the authorization API.""" + +from ddt import data, ddt, unpack +from django.test import TestCase + +from openedx_authz.api.data import ( + ActionData, + ContentLibraryData, + PermissionData, + RoleAssignmentData, + RoleData, + ScopeData, + ScopeMeta, + SubjectData, + UserData, +) + + +@ddt +class TestNamespacedData(TestCase): + """Test data for the authorization API.""" + + @data( + ("instructor",), + ("admin",), + ) + @unpack + def test_role_data_namespace(self, external_key): + """Test that RoleData correctly namespaces role names. + + Expected Result: + - If input is 'instructor', expected is 'role^instructor' + - If input is 'admin', expected is 'role^admin' + """ + role = RoleData(external_key=external_key) + + expected = f"{role.NAMESPACE}{role.SEPARATOR}{external_key}" + + self.assertEqual(role.namespaced_key, expected) + + @data( + ("john_doe",), + ("jane_smith",), + ) + @unpack + def test_user_data_namespace(self, external_key): + """Test that UserData correctly namespaces user IDs. + + Expected Result: + - If input is 'john_doe', expected is 'user^john_doe' + - If input is 'jane_smith', expected is 'user^jane_smith' + """ + user = UserData(external_key=external_key) + + expected = f"{user.NAMESPACE}{user.SEPARATOR}{external_key}" + + self.assertEqual(user.namespaced_key, expected) + + @data( + ("read",), + ("write",), + ) + @unpack + def test_action_data_namespace(self, external_key): + """Test that ActionData correctly namespaces action IDs. + + Expected Result: + - If input is 'read', expected is 'act^read' + - If input is 'write', expected is 'act^write' + """ + action = ActionData(external_key=external_key) + + expected = f"{action.NAMESPACE}{action.SEPARATOR}{external_key}" + + self.assertEqual(action.namespaced_key, expected) + + @data( + ("lib:DemoX:CSPROB",), + ) + @unpack + def test_scope_content_lib_data_namespace(self, external_key): + """Test that ContentLibraryData correctly namespaces library IDs. + + Expected Result: + - If input is 'lib:DemoX:CSPROB', expected is 'lib^lib:DemoX:CSPROB' + """ + scope = ContentLibraryData(external_key=external_key) + + expected = f"{scope.NAMESPACE}{scope.SEPARATOR}{external_key}" + + self.assertEqual(scope.namespaced_key, expected) + + +@ddt +class TestPolymorphicData(TestCase): + """Test polymorphic factory pattern for SubjectData and ScopeData.""" + + @data( + ("john_doe",), + ("jane_smith",), + ) + @unpack + def test_user_data_with_namespaced_key(self, external_key): + """Test that UserData can be instantiated with namespaced_key. + + Expected Result: + - UserData(namespaced_key='user^john_doe') creates UserData instance + """ + namespaced_key = f"{UserData.NAMESPACE}{UserData.SEPARATOR}{external_key}" + + user = UserData(namespaced_key=namespaced_key) + + self.assertIsInstance(user, UserData) + self.assertEqual(user.namespaced_key, namespaced_key) + self.assertEqual(user.external_key, external_key) + + def test_subject_data_direct_instantiation_with_namespaced_key(self): + """Test that SubjectData can be instantiated with namespaced_key. + + Expected Result: + - SubjectData(namespaced_key='sub^generic') creates SubjectData instance + """ + namespaced_key = f"{SubjectData.NAMESPACE}{SubjectData.SEPARATOR}generic" + + subject = SubjectData(namespaced_key=namespaced_key) + + self.assertIsInstance(subject, SubjectData) + self.assertEqual(subject.namespaced_key, namespaced_key) + self.assertEqual(subject.external_key, "generic") + + @data( + ("math_101",), + ("science_201",), + ) + @unpack + def test_content_library_data_with_namespaced_key(self, external_key): + """Test that ContentLibraryData can be instantiated with namespaced_key. + + Expected Result: + - ContentLibraryData(namespaced_key='lib^math_101') creates ContentLibraryData instance + """ + namespaced_key = f"{ContentLibraryData.NAMESPACE}{ContentLibraryData.SEPARATOR}{external_key}" + + library = ContentLibraryData(namespaced_key=namespaced_key) + + self.assertIsInstance(library, ContentLibraryData) + self.assertEqual(library.namespaced_key, namespaced_key) + self.assertEqual(library.external_key, external_key) + + def test_scope_data_direct_instantiation_with_namespaced_key(self): + """Test that ScopeData can be instantiated with namespaced_key. + + Expected Result: + - ScopeData(namespaced_key='sc^generic') creates ScopeData instance + """ + namespaced_key = f"{ScopeData.NAMESPACE}{ScopeData.SEPARATOR}generic" + + scope = ScopeData(namespaced_key=namespaced_key) + + self.assertIsInstance(scope, ScopeData) + self.assertEqual(scope.namespaced_key, namespaced_key) + self.assertEqual(scope.external_key, "generic") + + def test_user_data_direct_instantiation(self): + """Test that UserData can be instantiated directly. + + Expected Result: + - UserData(external_key='alice') creates UserData instance + """ + user = UserData(external_key="alice") + + expected_namespaced = f"{user.NAMESPACE}{user.SEPARATOR}alice" + + self.assertIsInstance(user, UserData) + self.assertEqual(user.namespaced_key, expected_namespaced) + self.assertEqual(user.external_key, "alice") + + def test_content_library_direct_instantiation(self): + """Test that ContentLibraryData can be instantiated directly. + + Expected Result: + - ContentLibraryData(external_key='lib:Demo:CS') creates ContentLibraryData instance + """ + library = ContentLibraryData(external_key="lib:demo:cs") + + expected_namespaced = f"{library.NAMESPACE}{library.SEPARATOR}lib:demo:cs" + + self.assertIsInstance(library, ContentLibraryData) + self.assertEqual(library.namespaced_key, expected_namespaced) + self.assertEqual(library.external_key, "lib:demo:cs") + + @data( + ("lib:math_101",), + ("lib:DemoX:CSPROB",), + ) + @unpack + def test_content_library_data_with_external_key(self, external_key): + """Test that ContentLibraryData with external_key generates correct namespaced_key. + + Expected Result: + - ContentLibraryData(external_key='lib:math_101') creates ContentLibraryData instance + - namespaced_key is 'lib^lib:math_101' + """ + library = ContentLibraryData(external_key=external_key) + + expected_namespaced_key = ( + f"{library.NAMESPACE}{library.SEPARATOR}{external_key}" + ) + + self.assertIsInstance(library, ContentLibraryData) + self.assertEqual(library.external_key, external_key) + self.assertEqual(library.namespaced_key, expected_namespaced_key) + + +@ddt +class TestScopeMetaClass(TestCase): + """Test the ScopeMeta metaclass functionality.""" + + def test_scope_data_registration(self): + """Test that ScopeData and its subclasses are registered correctly. + + Expected Result: + - 'sc' namespace maps to ScopeData class + - 'lib' namespace maps to ContentLibraryData class + """ + self.assertIn("sc", ScopeData.scope_registry) + self.assertIs(ScopeData.scope_registry["sc"], ScopeData) + self.assertIn("lib", ScopeData.scope_registry) + self.assertIs(ScopeData.scope_registry["lib"], ContentLibraryData) + + @data( + ("lib^lib:DemoX:CSPROB", ContentLibraryData), + ("sc^generic_scope", ScopeData), + ) + @unpack + def test_dynamic_instantiation_via_namespaced_key( + self, namespaced_key, expected_class + ): + """Test that ScopeData dynamically instantiates the correct subclass. + + Expected Result: + - ScopeData(namespaced_key='lib^...') returns ContentLibraryData instance + - ScopeData(namespaced_key='sc^...') returns ScopeData instance + """ + instance = ScopeData(namespaced_key=namespaced_key) + + self.assertIsInstance(instance, expected_class) + self.assertEqual(instance.namespaced_key, namespaced_key) + + @data( + ("lib^lib:DemoX:CSPROB", ContentLibraryData), + ("sc^generic", ScopeData), + ("unknown^something", ScopeData), + ) + @unpack + def test_get_subclass_by_namespaced_key(self, namespaced_key, expected_class): + """Test get_subclass_by_namespaced_key returns correct subclass. + + Expected Result: + - 'lib^...' returns ContentLibraryData + - 'sc^...' returns ScopeData + - 'unknown^...' returns ScopeData (fallback) + """ + subclass = ScopeMeta.get_subclass_by_namespaced_key(namespaced_key) + + self.assertIs(subclass, expected_class) + + @data( + ("lib:DemoX:CSPROB", ContentLibraryData), + ("lib:edX:Demo", ContentLibraryData), + ("sc:generic_scope", ScopeData), + ) + @unpack + def test_get_subclass_by_external_key(self, external_key, expected_class): + """Test get_subclass_by_external_key returns correct subclass. + + Expected Result: + - 'lib:...' returns ContentLibraryData + - 'sc:...' returns ScopeData + """ + subclass = ScopeMeta.get_subclass_by_external_key(external_key) + + self.assertIs(subclass, expected_class) + + @data( + ("lib:DemoX:CSPROB", True), + ("lib:edX:Demo", True), + ("invalid_library_key", False), + ("lib-DemoX-CSPROB", False), + ) + @unpack + def test_content_library_validate_external_key(self, external_key, expected_valid): + """Test ContentLibraryData.validate_external_key validates library keys. + + Expected Result: + - Valid library keys (lib:Org:Code) return True + - Invalid formats return False + """ + result = ContentLibraryData.validate_external_key(external_key) + + self.assertEqual(result, expected_valid) + + def test_direct_subclass_instantiation_bypasses_metaclass(self): + """Test that direct subclass instantiation doesn't trigger metaclass logic. + + Expected Result: + - ContentLibraryData(external_key='...') creates ContentLibraryData directly + - No metaclass dynamic instantiation occurs + """ + library = ContentLibraryData(external_key="lib:Demo:CS") + + self.assertIsInstance(library, ContentLibraryData) + self.assertEqual(library.external_key, "lib:Demo:CS") + + def test_base_scope_data_with_external_key(self): + """Test ScopeData instantiation with external_key (not namespaced_key). + + Expected Result: + - ScopeData(external_key='...') creates ScopeData instance + - No dynamic subclass selection occurs + """ + scope = ScopeData(external_key="sc:generic_scope") + + expected_namespaced = f"{ScopeData.NAMESPACE}{ScopeData.SEPARATOR}sc:generic_scope" + + self.assertIsInstance(scope, ScopeData) + self.assertEqual(scope.external_key, "sc:generic_scope") + self.assertEqual(scope.namespaced_key, expected_namespaced) + + def test_empty_namespaced_key_raises_value_error(self): + """Test that providing an empty namespaced_key raises ValueError. + + Expected Result: + - ValueError is raised + """ + with self.assertRaises(ValueError): + ScopeData(namespaced_key="") + + def test_empty_external_key_raises_value_error(self): + """Test that providing an empty external_key raises ValueError. + + Expected Result: + - ValueError is raised + """ + with self.assertRaises(ValueError): + SubjectData(external_key="") + + +@ddt +class TestDataRepresentation(TestCase): + """Test the string representations of data classes.""" + + @data( + ("john_doe", "john_doe", "user^john_doe"), + ("jane_smith", "jane_smith", "user^jane_smith"), + ) + @unpack + def test_user_data_str_and_repr(self, external_key, expected_str, expected_repr): + """Test UserData __str__ and __repr__ methods. + + Expected Result: + - __str__ returns the username (external_key) + - __repr__ returns the namespaced_key + """ + user = UserData(external_key=external_key) + + actual_str = str(user) + actual_repr = repr(user) + + self.assertEqual(actual_str, expected_str) + self.assertEqual(actual_repr, expected_repr) + + @data( + ("read", "Read", "act^read"), + ("write", "Write", "act^write"), + ("delete_library", "Delete Library", "act^delete_library"), + ("edit_content", "Edit Content", "act^edit_content"), + ) + @unpack + def test_action_data_str_and_repr(self, external_key, expected_str, expected_repr): + """Test ActionData __str__ and __repr__ methods. + + Expected Result: + - __str__ returns the human-readable name (title case with spaces) + - __repr__ returns the namespaced_key + """ + action = ActionData(external_key=external_key) + + actual_str = str(action) + actual_repr = repr(action) + + self.assertEqual(actual_str, expected_str) + self.assertEqual(actual_repr, expected_repr) + + @data( + ("lib:DemoX:CSPROB", "lib:DemoX:CSPROB", "lib^lib:DemoX:CSPROB"), + ("lib:edX:Demo", "lib:edX:Demo", "lib^lib:edX:Demo"), + ) + @unpack + def test_scope_data_str_and_repr(self, external_key, expected_str, expected_repr): + """Test ScopeData __str__ and __repr__ methods. + + Expected Result: + - __str__ returns the external_key + - __repr__ returns the namespaced_key + """ + scope = ContentLibraryData(external_key=external_key) + + actual_str = str(scope) + actual_repr = repr(scope) + + self.assertEqual(actual_str, expected_str) + self.assertEqual(actual_repr, expected_repr) + + @data( + ("instructor", "Instructor", "role^instructor"), + ("library_admin", "Library Admin", "role^library_admin"), + ("course_staff", "Course Staff", "role^course_staff"), + ) + @unpack + def test_role_data_str_without_permissions( + self, external_key, expected_name, expected_repr + ): + """Test RoleData __str__ and __repr__ methods without permissions. + + Expected Result: + - __str__ returns the role name with empty permissions list + - __repr__ returns the namespaced_key + """ + role = RoleData(external_key=external_key) + + actual_str = str(role) + actual_repr = repr(role) + + expected_str = f"{expected_name}: " + self.assertEqual(actual_str, expected_str) + self.assertEqual(actual_repr, expected_repr) + + def test_role_data_str_with_permissions(self): + """Test RoleData __str__ method with permissions. + + Expected Result: + - __str__ returns role name followed by permissions list + """ + action1 = ActionData(external_key="read") + action2 = ActionData(external_key="write") + permission1 = PermissionData(action=action1, effect="allow") + permission2 = PermissionData(action=action2, effect="deny") + role = RoleData(external_key="instructor", permissions=[permission1, permission2]) + + actual_str = str(role) + + expected_str = "Instructor: Read - allow, Write - deny" + self.assertEqual(actual_str, expected_str) + + @data( + ("read", "allow", "Read - allow", "act^read => allow"), + ("write", "deny", "Write - deny", "act^write => deny"), + ("delete_library", "allow", "Delete Library - allow", "act^delete_library => allow"), + ) + @unpack + def test_permission_data_str_and_repr( + self, action_key, effect, expected_str, expected_repr + ): + """Test PermissionData __str__ and __repr__ methods. + + Expected Result: + - __str__ returns 'Action Name - effect' + - __repr__ returns 'namespaced_key => effect' + """ + action = ActionData(external_key=action_key) + permission = PermissionData(action=action, effect=effect) + + actual_str = str(permission) + actual_repr = repr(permission) + + self.assertEqual(actual_str, expected_str) + self.assertEqual(actual_repr, expected_repr) + + def test_role_assignment_data_str(self): + """Test RoleAssignmentData __str__ method. + + Expected Result: + - __str__ returns 'user => role names @ scope' + """ + user = UserData(external_key="john_doe") + role1 = RoleData(external_key="instructor") + role2 = RoleData(external_key="library_admin") + scope = ContentLibraryData(external_key="lib:DemoX:CSPROB") + assignment = RoleAssignmentData(subject=user, roles=[role1, role2], scope=scope) + + actual_str = str(assignment) + + expected_str = "john_doe => Instructor, Library Admin @ lib:DemoX:CSPROB" + self.assertEqual(actual_str, expected_str) + + def test_role_assignment_data_repr(self): + """Test RoleAssignmentData __repr__ method. + + Expected Result: + - __repr__ returns 'namespaced_subject => [namespaced_roles] @ namespaced_scope' + """ + user = UserData(external_key="john_doe") + role1 = RoleData(external_key="instructor") + role2 = RoleData(external_key="library_admin") + scope = ContentLibraryData(external_key="lib:DemoX:CSPROB") + assignment = RoleAssignmentData(subject=user, roles=[role1, role2], scope=scope) + + actual_repr = repr(assignment) + + expected_repr = ( + "user^john_doe => [role^instructor, role^library_admin] @ lib^lib:DemoX:CSPROB" + ) + self.assertEqual(actual_repr, expected_repr) diff --git a/openedx_authz/tests/api/test_roles.py b/openedx_authz/tests/api/test_roles.py new file mode 100644 index 00000000..2a6fbc46 --- /dev/null +++ b/openedx_authz/tests/api/test_roles.py @@ -0,0 +1,1133 @@ +"""Test cases for roles API functions. + +In this test suite, we will verify the functionality of the roles API, +including role creation, assignment, permission management, and querying +roles and permissions within specific scopes. +""" + +import casbin +from ddt import data as ddt_data +from ddt import ddt, unpack +from django.test import TestCase + +from openedx_authz.api.data import ( + ActionData, + ContentLibraryData, + PermissionData, + RoleAssignmentData, + RoleData, + ScopeData, + SubjectData, +) +from openedx_authz.api.roles import ( + assign_role_to_subject_in_scope, + batch_assign_role_to_subjects_in_scope, + get_all_subject_role_assignments_in_scope, + get_permissions_for_active_roles_in_scope, + get_permissions_for_single_role, + get_role_definitions_in_scope, + get_subject_role_assignments, + get_subject_role_assignments_for_role_in_scope, + get_subject_role_assignments_in_scope, + unassign_role_from_subject_in_scope, +) +from openedx_authz.engine.enforcer import enforcer as global_enforcer +from openedx_authz.engine.utils import migrate_policy_between_enforcers + + +class RolesTestSetupMixin(TestCase): + """Mixin to set up roles and assignments for tests.""" + + @classmethod + def _seed_database_with_policies(cls): + """Seed the database with policies from the policy file. + + This simulates the one-time database seeding that would happen + during application deployment, separate from the runtime policy loading. + """ + global_enforcer.load_policy() + migrate_policy_between_enforcers( + source_enforcer=casbin.Enforcer( + "openedx_authz/engine/config/model.conf", + "openedx_authz/engine/config/authz.policy", + ), + target_enforcer=global_enforcer, + ) + global_enforcer.clear_policy() # Clear to simulate fresh start for each test + + @classmethod + def _assign_roles_to_users( + cls, + assignments: list[dict] | None = None, + ): + """Helper method to assign roles to multiple users. + + This method can be used to assign a role to a single user or multiple users + in a specific scope. It can also handle batch assignments. + + Args: + assignments (list of dict): List of assignment dictionaries, each containing: + - subject_name (str): External key of the subject (e.g., 'john_doe'). + - role_name (str): External key of the role to assign (e.g., 'library_admin'). + - scope_name (str): External key of the scope in which to assign the role (e.g., 'lib:Org1:math_101'). + """ + if assignments: + for assignment in assignments: + assign_role_to_subject_in_scope( + subject=SubjectData( + external_key=assignment["subject_name"], + ), + role=RoleData(external_key=assignment["role_name"]), + scope=ScopeData(external_key=assignment["scope_name"]), + ) + + @classmethod + def setUpClass(cls): + """Set up test class environment.""" + super().setUpClass() + # Ensure the database is seeded once for all tests in this class + assignments = [ + # Basic library roles from authz.policy + { + "subject_name": "alice", + "role_name": "library_admin", + "scope_name": "lib:Org1:math_101", + }, + { + "subject_name": "bob", + "role_name": "library_author", + "scope_name": "lib:Org1:history_201", + }, + { + "subject_name": "carol", + "role_name": "library_collaborator", + "scope_name": "lib:Org1:science_301", + }, + { + "subject_name": "dave", + "role_name": "library_user", + "scope_name": "lib:Org1:english_101", + }, + # Multi-role assignments - same subject with different roles in different libraries + { + "subject_name": "eve", + "role_name": "library_admin", + "scope_name": "lib:Org2:physics_401", + }, + { + "subject_name": "eve", + "role_name": "library_author", + "scope_name": "lib:Org2:chemistry_501", + }, + { + "subject_name": "eve", + "role_name": "library_user", + "scope_name": "lib:Org2:biology_601", + }, + # Multiple subjects with same role in same scope + { + "subject_name": "grace", + "role_name": "library_collaborator", + "scope_name": "lib:Org1:math_advanced", + }, + { + "subject_name": "heidi", + "role_name": "library_collaborator", + "scope_name": "lib:Org1:math_advanced", + }, + # Hierarchical scope assignments - different specificity levels + { + "subject_name": "ivy", + "role_name": "library_admin", + "scope_name": "lib:Org3:cs_101", + }, + { + "subject_name": "jack", + "role_name": "library_author", + "scope_name": "lib:Org3:cs_101", + }, + { + "subject_name": "kate", + "role_name": "library_user", + "scope_name": "lib:Org3:cs_101", + }, + # Edge case: same user, same role, different scopes + { + "subject_name": "liam", + "role_name": "library_author", + "scope_name": "lib:Org4:art_101", + }, + { + "subject_name": "liam", + "role_name": "library_author", + "scope_name": "lib:Org4:art_201", + }, + { + "subject_name": "liam", + "role_name": "library_author", + "scope_name": "lib:Org4:art_301", + }, + # Mixed permission levels across libraries for comprehensive testing + { + "subject_name": "maya", + "role_name": "library_admin", + "scope_name": "lib:Org5:economics_101", + }, + { + "subject_name": "noah", + "role_name": "library_collaborator", + "scope_name": "lib:Org5:economics_101", + }, + { + "subject_name": "olivia", + "role_name": "library_user", + "scope_name": "lib:Org5:economics_101", + }, + # Complex multi-library, multi-role scenario + { + "subject_name": "peter", + "role_name": "library_admin", + "scope_name": "lib:Org6:project_alpha", + }, + { + "subject_name": "peter", + "role_name": "library_author", + "scope_name": "lib:Org6:project_beta", + }, + { + "subject_name": "peter", + "role_name": "library_collaborator", + "scope_name": "lib:Org6:project_gamma", + }, + { + "subject_name": "peter", + "role_name": "library_user", + "scope_name": "lib:Org6:project_delta", + }, + { + "subject_name": "frank", + "role_name": "library_user", + "scope_name": "lib:Org6:project_epsilon", + }, + ] + cls._seed_database_with_policies() + cls._assign_roles_to_users(assignments=assignments) + + def setUp(self): + """Set up test environment.""" + super().setUp() + global_enforcer.load_policy() # Load policies before each test to simulate fresh start + + def tearDown(self): + """Clean up after each test to ensure isolation.""" + super().tearDown() + global_enforcer.clear_policy() # Clear policies after each test to ensure isolation + + +@ddt +class TestRolesAPI(RolesTestSetupMixin): + """Test cases for roles API functions. + + The enforcer used in these tests cases is the default global enforcer + instance from `openedx_authz.engine.enforcer` automatically used by + the API to ensure consistency across tests and production environments. + + In case a different enforcer configuration is needed, consider mocking the + enforcer instance in the `openedx_authz.api.roles` module. + + These test cases depend on the roles and assignments set up in the + `RolesTestSetupMixin` class. This means: + - The database is seeded once per test class with a predefined set of roles + - Each test runs with a (in-memory) clean state, loading the same set of policies + - Tests are isolated from each other to prevent state leakage + - The global enforcer instance is used to ensure consistency with production + environments. + """ + + @ddt_data( + # Library Admin role with actual permissions from authz.policy + ( + "library_admin", + [ + PermissionData( + action=ActionData(external_key="delete_library"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="publish_library"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="manage_library_team"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="manage_library_tags"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="delete_library_content"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="publish_library_content"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="delete_library_collection"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="create_library"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="create_library_collection"), + effect="allow", + ), + ], + ), + # Library Author role with actual permissions from authz.policy + ( + "library_author", + [ + PermissionData( + action=ActionData(external_key="delete_library_content"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="publish_library_content"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="edit_library"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="manage_library_tags"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="create_library_collection"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="edit_library_collection"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="delete_library_collection"), + effect="allow", + ), + ], + ), + # Library Collaborator role with actual permissions from authz.policy + ( + "library_collaborator", + [ + PermissionData( + action=ActionData(external_key="edit_library"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="delete_library_content"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="manage_library_tags"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="create_library_collection"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="edit_library_collection"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="delete_library_collection"), + effect="allow", + ), + ], + ), + # Library User role with minimal permissions + ( + "library_user", + [ + PermissionData( + action=ActionData(external_key="view_library"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="view_library_team"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="reuse_library_content"), + effect="allow", + ), + ], + ), + # Non existent role + ( + "non_existent_role", + [], + ), + ) + @unpack + def test_get_permissions_for_roles(self, role_name, expected_permissions): + """Test retrieving permissions for roles in the current environment. + + Expected result: + - Permissions are correctly retrieved for the given roles and scope. + - The permissions match the expected permissions. + """ + assigned_permissions = get_permissions_for_single_role( + RoleData(external_key=role_name) + ) + + self.assertEqual(assigned_permissions, expected_permissions) + + @ddt_data( + # Role assigned to multiple users in different scopes + ( + "library_user", + "lib:Org1:english_101", + [ + PermissionData( + action=ActionData(external_key="view_library"), effect="allow" + ), + PermissionData( + action=ActionData(external_key="view_library_team"), effect="allow" + ), + PermissionData( + action=ActionData(external_key="reuse_library_content"), + effect="allow", + ), + ], + ), + # Role assigned to single user in single scope + ( + "library_author", + "lib:Org1:history_201", + [ + PermissionData( + action=ActionData(external_key="delete_library_content"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="publish_library_content"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="edit_library"), effect="allow" + ), + PermissionData( + action=ActionData(external_key="manage_library_tags"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="create_library_collection"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="edit_library_collection"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="delete_library_collection"), + effect="allow", + ), + ], + ), + # Role assigned to single user in multiple scopes + ( + "library_admin", + "lib:Org1:math_101", + [ + PermissionData( + action=ActionData(external_key="delete_library"), effect="allow" + ), + PermissionData( + action=ActionData(external_key="publish_library"), effect="allow" + ), + PermissionData( + action=ActionData(external_key="manage_library_team"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="manage_library_tags"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="delete_library_content"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="publish_library_content"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="delete_library_collection"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="create_library"), effect="allow" + ), + PermissionData( + action=ActionData(external_key="create_library_collection"), + effect="allow", + ), + ], + ), + ) + @unpack + def test_get_permissions_for_active_role_in_specific_scope( + self, role_name, scope_name, expected_permissions + ): + """Test retrieving permissions for a specific role after role assignments. + + Expected result: + - Permissions are correctly retrieved for the given role. + - The permissions match the expected permissions for the role. + """ + assigned_permissions = get_permissions_for_active_roles_in_scope( + ScopeData(external_key=scope_name), RoleData(external_key=role_name) + ) + + self.assertIn(role_name, assigned_permissions) + self.assertEqual( + assigned_permissions[role_name]["permissions"], + expected_permissions, + ) + + @ddt_data( + ( + "*", + { + "library_admin", + "library_author", + "library_collaborator", + "library_user", + }, + ), + ) + @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. + + Expected result: + - Roles in the given scope are correctly retrieved. + """ + # TODO: cheat and use ContentLibraryData until we have more scope types + roles_in_scope = get_role_definitions_in_scope( + ContentLibraryData(external_key=scope_name), + ) + + role_names = {role.external_key for role in roles_in_scope} + self.assertEqual(role_names, expected_roles) + + @ddt_data( + ("alice", "lib:Org1:math_101", {"library_admin"}), + ("bob", "lib:Org1:history_201", {"library_author"}), + ("carol", "lib:Org1:science_301", {"library_collaborator"}), + ("dave", "lib:Org1:english_101", {"library_user"}), + ("eve", "lib:Org2:physics_401", {"library_admin"}), + ("eve", "lib:Org2:chemistry_501", {"library_author"}), + ("eve", "lib:Org2:biology_601", {"library_user"}), + ("grace", "lib:Org1:math_advanced", {"library_collaborator"}), + ("ivy", "lib:Org3:cs_101", {"library_admin"}), + ("jack", "lib:Org3:cs_101", {"library_author"}), + ("kate", "lib:Org3:cs_101", {"library_user"}), + ("liam", "lib:Org4:art_101", {"library_author"}), + ("liam", "lib:Org4:art_201", {"library_author"}), + ("liam", "lib:Org4:art_301", {"library_author"}), + ("maya", "lib:Org5:economics_101", {"library_admin"}), + ("noah", "lib:Org5:economics_101", {"library_collaborator"}), + ("olivia", "lib:Org5:economics_101", {"library_user"}), + ("peter", "lib:Org6:project_alpha", {"library_admin"}), + ("peter", "lib:Org6:project_beta", {"library_author"}), + ("peter", "lib:Org6:project_gamma", {"library_collaborator"}), + ("peter", "lib:Org6:project_delta", {"library_user"}), + ("non_existent_user", "lib:Org1:math_101", set()), + ("alice", "lib:Org999:non_existent_scope", set()), + ("non_existent_user", "lib:Org999:non_existent_scope", set()), + ) + @unpack + def test_get_subject_role_assignments_in_scope( + self, subject_name, scope_name, expected_roles + ): + """Test retrieving roles assigned to a subject in a specific scope. + + Expected result: + - Roles assigned to the subject in the given scope are correctly retrieved. + """ + role_assignments = get_subject_role_assignments_in_scope( + SubjectData(external_key=subject_name), ScopeData(external_key=scope_name) + ) + + role_names = {r.external_key for assignment in role_assignments for r in assignment.roles} + self.assertEqual(role_names, expected_roles) + + @ddt_data( + ( + "alice", + [ + RoleData( + external_key="library_admin", + permissions=[ + PermissionData( + action=ActionData(external_key="delete_library"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="publish_library"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="manage_library_team"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="manage_library_tags"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="delete_library_content"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="publish_library_content"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="delete_library_collection"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="create_library"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="create_library_collection"), + effect="allow", + ), + ], + ), + ], + ), + ( + "eve", + [ + RoleData( + external_key="library_admin", + permissions=[ + PermissionData( + action=ActionData(external_key="delete_library"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="publish_library"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="manage_library_team"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="manage_library_tags"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="delete_library_content"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="publish_library_content"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="delete_library_collection"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="create_library"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="create_library_collection"), + effect="allow", + ), + ], + ), + RoleData( + external_key="library_author", + permissions=[ + PermissionData( + action=ActionData(external_key="delete_library_content"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="publish_library_content"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="edit_library"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="manage_library_tags"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="create_library_collection"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="edit_library_collection"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="delete_library_collection"), + effect="allow", + ), + ], + ), + RoleData( + external_key="library_user", + permissions=[ + PermissionData( + action=ActionData(external_key="view_library"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="view_library_team"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="reuse_library_content"), + effect="allow", + ), + ], + ), + ], + ), + ( + "frank", + [ + RoleData( + external_key="library_user", + permissions=[ + PermissionData( + action=ActionData(external_key="view_library"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="view_library_team"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="reuse_library_content"), + effect="allow", + ), + ], + ), + ], + ), + ("non_existent_user", []), + ) + @unpack + def test_get_all_role_assignments_scopes(self, subject_name, expected_roles): + """Test retrieving all roles assigned to a subject across all scopes. + + Expected result: + - All roles assigned to the subject across all scopes are correctly retrieved. + - Each role includes its associated permissions. + """ + role_assignments = get_subject_role_assignments( + SubjectData(external_key=subject_name) + ) + + self.assertEqual(len(role_assignments), len(expected_roles)) + for expected_role in expected_roles: + # Compare the role part of the assignment + found = any( + expected_role in assignment.roles for assignment in role_assignments + ) + self.assertTrue( + found, f"Expected role {expected_role} not found in assignments" + ) + + @ddt_data( + ("library_admin", "lib:Org1:math_101", 1), + ("library_author", "lib:Org1:history_201", 1), + ("library_collaborator", "lib:Org1:science_301", 1), + ("library_user", "lib:Org1:english_101", 1), + ("library_admin", "lib:Org2:physics_401", 1), + ("library_author", "lib:Org2:chemistry_501", 1), + ("library_user", "lib:Org2:biology_601", 1), + ("library_collaborator", "lib:Org1:math_advanced", 2), + ("library_admin", "lib:Org3:cs_101", 1), + ("library_author", "lib:Org3:cs_101", 1), + ("library_user", "lib:Org3:cs_101", 1), + ("library_author", "lib:Org4:art_101", 1), + ("library_author", "lib:Org4:art_201", 1), + ("library_author", "lib:Org4:art_301", 1), + ("library_admin", "lib:Org5:economics_101", 1), + ("library_collaborator", "lib:Org5:economics_101", 1), + ("library_user", "lib:Org5:economics_101", 1), + ("library_admin", "lib:Org6:project_alpha", 1), + ("library_author", "lib:Org6:project_beta", 1), + ("library_collaborator", "lib:Org6:project_gamma", 1), + ("library_user", "lib:Org6:project_delta", 1), + ("non_existent_role", "sc:any_library", 0), + ("library_admin", "sc:non_existent_scope", 0), + ("non_existent_role", "sc:non_existent_scope", 0), + ) + @unpack + def test_get_role_assignments_in_scope(self, role_name, scope_name, expected_count): + """Test retrieving role assignments in a specific scope. + + Expected result: + - The number of role assignments in the given scope is correctly retrieved. + """ + role_assignments = get_subject_role_assignments_for_role_in_scope( + RoleData(external_key=role_name), ScopeData(external_key=scope_name) + ) + + self.assertEqual(len(role_assignments), expected_count) + + +@ddt +class TestRoleAssignmentAPI(RolesTestSetupMixin): + """Test cases for role assignment API functions. + + The enforcer used in these tests cases is the default global enforcer + instance from `openedx_authz.engine.enforcer` automatically used by + the API to ensure consistency across tests and production environments. + + In case a different enforcer configuration is needed, consider mocking the + enforcer instance in the `openedx_authz.api.roles` module. + """ + + @ddt_data( + (["mary", "john"], "library_user", "sc:batch_test", True), + ( + ["paul", "diana", "lila"], + "library_collaborator", + "lib:Org1:math_advanced", + True, + ), + (["sarina", "ty"], "library_author", "lib:Org4:art_101", True), + (["fran", "bob"], "library_admin", "lib:Org3:cs_101", True), + ( + ["anna", "tom", "jerry"], + "library_user", + "lib:Org1:history_201", + True, + ), + ("joe", "library_collaborator", "lib:Org1:science_301", False), + ("nina", "library_author", "lib:Org1:english_101", False), + ("oliver", "library_admin", "lib:Org1:math_101", False), + ) + @unpack + def test_batch_assign_role_to_subjects_in_scope( + self, subject_names, role, scope_name, batch + ): + """Test assigning a role to a single or multiple subjects in a specific scope. + + Expected result: + - Role is successfully assigned to all specified subjects in the given scope. + - Each subject has the correct permissions associated with the assigned role. + - Each subject can perform actions allowed by the role. + """ + if batch: + subjects_list = [] + for subject in subject_names: + subjects_list.append(SubjectData(external_key=subject)) + batch_assign_role_to_subjects_in_scope( + subjects_list, + RoleData(external_key=role), + ScopeData(external_key=scope_name), + ) + for subject_name in subject_names: + user_roles = get_subject_role_assignments_in_scope( + SubjectData(external_key=subject_name), ScopeData(external_key=scope_name) + ) + role_names = {r.external_key for assignment in user_roles for r in assignment.roles} + self.assertIn(role, role_names) + else: + assign_role_to_subject_in_scope( + SubjectData(external_key=subject_names), + RoleData(external_key=role), + ScopeData(external_key=scope_name), + ) + user_roles = get_subject_role_assignments_in_scope( + SubjectData(external_key=subject_names), + ScopeData(external_key=scope_name), + ) + role_names = {r.external_key for assignment in user_roles for r in assignment.roles} + self.assertIn(role, role_names) + + @ddt_data( + (["mary", "john"], "library_user", "sc:batch_test", True), + ( + ["paul", "diana", "lila"], + "library_collaborator", + "lib:Org1:math_advanced", + True, + ), + (["sarina", "ty"], "library_author", "lib:Org4:art_101", True), + (["fran", "bob"], "library_admin", "lib:Org3:cs_101", True), + ( + ["anna", "tom", "jerry"], + "library_user", + "lib:Org1:history_201", + True, + ), + ("joe", "library_collaborator", "lib:Org1:science_301", False), + ("nina", "library_author", "lib:Org1:english_101", False), + ("oliver", "library_admin", "lib:Org1:math_101", False), + ) + @unpack + def test_unassign_role_from_subject_in_scope( + self, subject_names, role, scope_name, batch + ): + """Test unassigning a role from a subject or multiple subjects in a specific scope. + + Expected result: + - Role is successfully unassigned from the subject in the specified scope. + - Subject no longer has permissions associated with the unassigned role. + - The subject cannot perform actions that were allowed by the role. + """ + if batch: + for subject in subject_names: + unassign_role_from_subject_in_scope( + SubjectData(external_key=subject), + RoleData(external_key=role), + ScopeData(external_key=scope_name), + ) + user_roles = get_subject_role_assignments_in_scope( + SubjectData(external_key=subject), + ScopeData(external_key=scope_name), + ) + role_names = {r.external_key for assignment in user_roles for r in assignment.roles} + self.assertNotIn(role, role_names) + else: + unassign_role_from_subject_in_scope( + SubjectData(external_key=subject_names), + RoleData(external_key=role), + ScopeData(external_key=scope_name), + ) + user_roles = get_subject_role_assignments_in_scope( + SubjectData(external_key=subject_names), + ScopeData(external_key=scope_name), + ) + role_names = {r.external_key for assignment in user_roles for r in assignment.roles} + self.assertNotIn(role, role_names) + + @ddt_data( + ( + "lib:Org1:math_101", + [ + RoleAssignmentData( + subject=SubjectData(external_key="alice"), + roles=[RoleData( + external_key="library_admin", + permissions=[ + PermissionData( + action=ActionData(external_key="delete_library"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="publish_library"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="manage_library_team"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="manage_library_tags"), + effect="allow", + ), + PermissionData( + action=ActionData( + external_key="delete_library_content" + ), + effect="allow", + ), + PermissionData( + action=ActionData( + external_key="publish_library_content" + ), + effect="allow", + ), + PermissionData( + action=ActionData( + external_key="delete_library_collection" + ), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="create_library"), + effect="allow", + ), + PermissionData( + action=ActionData( + external_key="create_library_collection" + ), + effect="allow", + ), + ], + )], + scope=ScopeData(external_key="lib:Org1:math_101"), + ) + ], + ), + ( + "lib:Org1:history_201", + [ + RoleAssignmentData( + subject=SubjectData(external_key="bob"), + roles=[RoleData( + external_key="library_author", + permissions=[ + PermissionData( + action=ActionData( + external_key="delete_library_content" + ), + effect="allow", + ), + PermissionData( + action=ActionData( + external_key="publish_library_content" + ), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="edit_library"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="manage_library_tags"), + effect="allow", + ), + PermissionData( + action=ActionData( + external_key="create_library_collection" + ), + effect="allow", + ), + PermissionData( + action=ActionData( + external_key="edit_library_collection" + ), + effect="allow", + ), + PermissionData( + action=ActionData( + external_key="delete_library_collection" + ), + effect="allow", + ), + ], + )], + scope=ScopeData(external_key="lib:Org1:history_201"), + ) + ], + ), + ( + "lib:Org1:science_301", + [ + RoleAssignmentData( + subject=SubjectData(external_key="carol"), + roles=[RoleData( + external_key="library_collaborator", + permissions=[ + PermissionData( + action=ActionData(external_key="edit_library"), + effect="allow", + ), + PermissionData( + action=ActionData( + external_key="delete_library_content" + ), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="manage_library_tags"), + effect="allow", + ), + PermissionData( + action=ActionData( + external_key="create_library_collection" + ), + effect="allow", + ), + PermissionData( + action=ActionData( + external_key="edit_library_collection" + ), + effect="allow", + ), + PermissionData( + action=ActionData( + external_key="delete_library_collection" + ), + effect="allow", + ), + ], + )], + scope=ScopeData(external_key="lib:Org1:science_301"), + ) + ], + ), + ( + "lib:Org1:english_101", + [ + RoleAssignmentData( + subject=SubjectData(external_key="dave"), + roles=[RoleData( + external_key="library_user", + permissions=[ + PermissionData( + action=ActionData(external_key="view_library"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="view_library_team"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="reuse_library_content"), + effect="allow", + ), + ], + )], + scope=ScopeData(external_key="lib:Org1:english_101"), + ) + ], + ), + ("sc:non_existent_scope", []), + ) + @unpack + def test_get_all_role_assignments_in_scope(self, scope_name, expected_assignments): + """Test retrieving all role assignments in a specific scope. + + Expected result: + - All role assignments in the specified scope are correctly retrieved. + - Each assignment includes the subject, role, and scope information with permissions. + """ + role_assignments = get_all_subject_role_assignments_in_scope( + ScopeData(external_key=scope_name) + ) + + self.assertEqual(len(role_assignments), len(expected_assignments)) + for assignment in role_assignments: + self.assertIn(assignment, expected_assignments) diff --git a/openedx_authz/tests/api/test_users.py b/openedx_authz/tests/api/test_users.py new file mode 100644 index 00000000..32b7d85a --- /dev/null +++ b/openedx_authz/tests/api/test_users.py @@ -0,0 +1,415 @@ +"""Test suite for user-role assignment API functions.""" + +from ddt import data, ddt, unpack + +from openedx_authz.api.data import ( + ActionData, + ContentLibraryData, + PermissionData, + RoleAssignmentData, + RoleData, + UserData, +) +from openedx_authz.api.users import ( + assign_role_to_user_in_scope, + batch_assign_role_to_users_in_scope, + batch_unassign_role_from_users, + get_all_user_role_assignments_in_scope, + get_user_role_assignments, + get_user_role_assignments_for_role_in_scope, + get_user_role_assignments_in_scope, + is_user_allowed, + unassign_role_from_user, +) +from openedx_authz.tests.api.test_roles import RolesTestSetupMixin + + +class UserAssignmentsSetupMixin(RolesTestSetupMixin): + """Mixin to set up user-role assignments for testing.""" + + @classmethod + def _assign_roles_to_users( + cls, + assignments: list[dict] | None = None, + ): + """Helper method to assign roles to multiple users. + + This method can be used to assign a role to a single user or multiple users + in a specific scope. It can also handle batch assignments. + + Args: + assignments (list of dict): List of assignment dictionaries, each containing: + - subject_name (str): External key of the user (e.g., 'john_doe'). + - role_name (str): External key of the role to assign (e.g., 'library_admin'). + - scope_name (str): External key of the scope in which to assign the role (e.g., 'lib:Org1:math_101'). + """ + if assignments: + for assignment in assignments: + assign_role_to_user_in_scope( + assignment["subject_name"], + assignment["role_name"], + assignment["scope_name"], + ) + + +@ddt +class TestUserRoleAssignments(UserAssignmentsSetupMixin): + """Test suite for user-role assignment API functions.""" + + @data( + ("john", "library_admin", "lib:Org1:math_101", False), + ("jane", "library_user", "lib:Org1:english_101", False), + (["mary", "charlie"], "library_collaborator", "lib:Org1:science_301", True), + (["david", "sarah"], "library_author", "lib:Org1:history_201", True), + ) + @unpack + def test_assign_role_to_user_in_scope(self, username, role, scope_name, batch): + """Test assigning a role to a user in a specific scope. + + Expected result: + - The role is successfully assigned to the user in the specified scope. + """ + if batch: + batch_assign_role_to_users_in_scope( + users=username, role_external_key=role, scope_external_key=scope_name + ) + for user in username: + user_roles = get_user_role_assignments_in_scope( + user_external_key=user, scope_external_key=scope_name + ) + role_names = {r.external_key for assignment in user_roles for r in assignment.roles} + self.assertIn(role, role_names) + else: + assign_role_to_user_in_scope( + user_external_key=username, + role_external_key=role, + scope_external_key=scope_name, + ) + user_roles = get_user_role_assignments_in_scope( + user_external_key=username, scope_external_key=scope_name + ) + role_names = {r.external_key for assignment in user_roles for r in assignment.roles} + self.assertIn(role, role_names) + + @data( + (["grace"], "library_collaborator", "lib:Org1:math_advanced", True), + (["liam", "maya"], "library_author", "lib:Org4:art_101", True), + ("alice", "library_admin", "lib:Org1:math_101", False), + ("bob", "library_author", "lib:Org1:history_201", False), + ) + @unpack + def test_unassign_role_from_user(self, username, role, scope_name, batch): + """Test unassigning a role from a user in a specific scope. + + Expected result: + - The role is successfully unassigned from the user in the specified scope. + - The user no longer has the role in the specified scope. + """ + if batch: + batch_unassign_role_from_users( + users=username, role_external_key=role, scope_external_key=scope_name + ) + for user in username: + user_roles = get_user_role_assignments_in_scope( + user_external_key=user, scope_external_key=scope_name + ) + role_names = {r.external_key for assignment in user_roles for r in assignment.roles} + self.assertNotIn(role, role_names) + else: + unassign_role_from_user( + user_external_key=username, + role_external_key=role, + scope_external_key=scope_name, + ) + user_roles = get_user_role_assignments_in_scope( + user_external_key=username, scope_external_key=scope_name + ) + role_names = {r.external_key for assignment in user_roles for r in assignment.roles} + self.assertNotIn(role, role_names) + + @data( + ("eve", {"library_admin", "library_author", "library_user"}), + ("alice", {"library_admin"}), + ("liam", {"library_author"}), + ) + @unpack + def test_get_user_role_assignments(self, username, expected_roles): + """Test retrieving all role assignments for a user across all scopes. + + Expected result: + - All roles assigned to the user across all scopes are correctly retrieved. + - Each assigned role is present in the returned role assignments. + """ + role_assignments = get_user_role_assignments(user_external_key=username) + + assigned_role_names = { + r.external_key for assignment in role_assignments for r in assignment.roles + } + self.assertEqual(assigned_role_names, expected_roles) + + @data( + ("alice", "lib:Org1:math_101", {"library_admin"}), + ("bob", "lib:Org1:history_201", {"library_author"}), + ("eve", "lib:Org2:physics_401", {"library_admin"}), + ("grace", "lib:Org1:math_advanced", {"library_collaborator"}), + ) + @unpack + def test_get_user_role_assignments_in_scope( + self, username, scope_name, expected_roles + ): + """Test retrieving role assignments for a user within a specific scope. + + Expected result: + - The role assigned to the user in the specified scope is correctly retrieved. + - The returned role assignments contain the assigned role. + """ + user_roles = get_user_role_assignments_in_scope( + user_external_key=username, scope_external_key=scope_name + ) + + role_names = {r.external_key for assignment in user_roles for r in assignment.roles} + self.assertEqual(role_names, expected_roles) + + @data( + ("library_admin", "lib:Org1:math_101", {"alice"}), + ("library_author", "lib:Org1:history_201", {"bob"}), + ("library_collaborator", "lib:Org1:math_advanced", {"grace", "heidi"}), + ) + @unpack + def test_get_user_role_assignments_for_role_in_scope( + self, role_name, scope_name, expected_users + ): + """Test retrieving all users assigned to a specific role within a specific scope. + + Expected result: + - All users assigned to the role in the specified scope are correctly retrieved. + - Each assigned user is present in the returned user assignments. + """ + user_assignments = get_user_role_assignments_for_role_in_scope( + role_external_key=role_name, scope_external_key=scope_name + ) + + assigned_usernames = { + assignment.subject.username for assignment in user_assignments + } + + self.assertEqual(assigned_usernames, expected_users) + + @data( + ( + "lib:Org1:math_101", + [ + RoleAssignmentData( + subject=UserData(external_key="alice"), + roles=[RoleData( + external_key="library_admin", + permissions=[ + PermissionData( + action=ActionData(external_key="delete_library"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="publish_library"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="manage_library_team"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="manage_library_tags"), + effect="allow", + ), + PermissionData( + action=ActionData( + external_key="delete_library_content" + ), + effect="allow", + ), + PermissionData( + action=ActionData( + external_key="publish_library_content" + ), + effect="allow", + ), + PermissionData( + action=ActionData( + external_key="delete_library_collection" + ), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="create_library"), + effect="allow", + ), + PermissionData( + action=ActionData( + external_key="create_library_collection" + ), + effect="allow", + ), + ], + )], + scope=ContentLibraryData(external_key="lib:Org1:math_101"), + ), + ], + ), + ( + "lib:Org1:history_201", + [ + RoleAssignmentData( + subject=UserData(external_key="bob"), + roles=[RoleData( + external_key="library_author", + permissions=[ + PermissionData( + action=ActionData( + external_key="delete_library_content" + ), + effect="allow", + ), + PermissionData( + action=ActionData( + external_key="publish_library_content" + ), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="edit_library"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="manage_library_tags"), + effect="allow", + ), + PermissionData( + action=ActionData( + external_key="create_library_collection" + ), + effect="allow", + ), + PermissionData( + action=ActionData( + external_key="edit_library_collection" + ), + effect="allow", + ), + PermissionData( + action=ActionData( + external_key="delete_library_collection" + ), + effect="allow", + ), + ], + )], + scope=ContentLibraryData(external_key="lib:Org1:history_201"), + ), + ], + ), + ( + "lib:Org2:physics_401", + [ + RoleAssignmentData( + subject=UserData(external_key="eve"), + roles=[RoleData( + external_key="library_admin", + permissions=[ + PermissionData( + action=ActionData(external_key="delete_library"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="publish_library"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="manage_library_team"), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="manage_library_tags"), + effect="allow", + ), + PermissionData( + action=ActionData( + external_key="delete_library_content" + ), + effect="allow", + ), + PermissionData( + action=ActionData( + external_key="publish_library_content" + ), + effect="allow", + ), + PermissionData( + action=ActionData( + external_key="delete_library_collection" + ), + effect="allow", + ), + PermissionData( + action=ActionData(external_key="create_library"), + effect="allow", + ), + PermissionData( + action=ActionData( + external_key="create_library_collection" + ), + effect="allow", + ), + ], + )], + scope=ContentLibraryData(external_key="lib:Org2:physics_401"), + ), + ], + ), + ) + @unpack + def test_get_all_user_role_assignments_in_scope( + self, scope_name, expected_assignments + ): + """Test retrieving all user role assignments within a specific scope. + + Expected result: + - All user role assignments in the specified scope are correctly retrieved. + - Each assignment includes the subject, role, and scope information. + """ + role_assignments = get_all_user_role_assignments_in_scope( + scope_external_key=scope_name + ) + + self.assertEqual(len(role_assignments), len(expected_assignments)) + for assignment in role_assignments: + self.assertIn(assignment, expected_assignments) + + +@ddt +class TestUserPermissions(UserAssignmentsSetupMixin): + """Test suite for user permission API functions.""" + + @data( + ("alice", "delete_library", "lib:Org1:math_101", True), + ("bob", "publish_library_content", "lib:Org1:history_201", True), + ("eve", "manage_library_team", "lib:Org2:physics_401", True), + ("grace", "edit_library", "lib:Org1:math_advanced", True), + ("heidi", "create_library_collection", "lib:Org1:math_advanced", True), + ("charlie", "delete_library", "lib:Org1:science_301", False), + ("david", "publish_library_content", "lib:Org1:history_201", False), + ("mallory", "manage_library_team", "lib:Org1:math_101", False), + ("oscar", "edit_library", "lib:Org4:art_101", False), + ("peggy", "create_library_collection", "lib:Org2:physics_401", False), + ) + @unpack + def test_is_user_allowed(self, username, action, scope_name, expected_result): + """Test checking if a user has a specific permission in a given scope. + + Expected result: + - The function correctly identifies whether the user has the specified permission in the scope. + """ + result = is_user_allowed( + user_external_key=username, + action_external_key=action, + scope_external_key=scope_name, + ) + self.assertEqual(result, expected_result) diff --git a/openedx_authz/tests/test_commands.py b/openedx_authz/tests/test_commands.py index 0a371480..75240d2e 100644 --- a/openedx_authz/tests/test_commands.py +++ b/openedx_authz/tests/test_commands.py @@ -12,6 +12,7 @@ from django.core.management.base import CommandError from openedx_authz.management.commands.enforcement import Command as EnforcementCommand +from openedx_authz.tests.test_utils import make_action_key, make_scope_key, make_user_key # pylint: disable=protected-access @@ -104,11 +105,12 @@ def test_run_interactive_mode_displays_help(self): with patch("builtins.input", side_effect=["quit"]): self.command._run_interactive_mode(self.enforcer) + example_text = f"Example: {make_user_key('alice')} {make_action_key('read')} {make_scope_key('org', 'OpenedX')}" self.assertIn("Interactive Mode", self.buffer.getvalue()) self.assertIn("Test custom enforcement requests interactively.", self.buffer.getvalue()) self.assertIn("Enter 'quit', 'exit', or 'q' to exit the interactive mode.", self.buffer.getvalue()) self.assertIn("Format: subject action scope", self.buffer.getvalue()) - self.assertIn("Example: user:alice act:read org:OpenedX", self.buffer.getvalue()) + self.assertIn(example_text, self.buffer.getvalue()) def test_run_interactive_mode_maintains_interactive_loop(self): """Test that the interactive mode maintains the interactive loop.""" @@ -120,9 +122,9 @@ def test_run_interactive_mode_maintains_interactive_loop(self): self.assertEqual(mock_input.call_count, len(input_values)) @data( - ["user:alice act:read org:OpenedX"], - ["user:bob act:read org:OpenedX"] * 5, - ["user:john act:read org:OpenedX"] * 10, + [f"{make_user_key('alice')} {make_action_key('read')} {make_scope_key('org', 'OpenedX')}"], + [f"{make_user_key('bob')} {make_action_key('read')} {make_scope_key('org', 'OpenedX')}"] * 5, + [f"{make_user_key('john')} {make_action_key('read')} {make_scope_key('org', 'OpenedX')}"] * 10, ) def test_run_interactive_mode_processes_request(self, user_input: list[str]): """Test that the interactive mode processes the request.""" @@ -154,7 +156,7 @@ def test_handles_exceptions(self, exception: Exception): def test_interactive_request_allowed(self): """Test that `_test_interactive_request` prints allowed output format.""" self.enforcer.enforce.return_value = True - user_input = "user:alice act:read org:OpenedX" + user_input = f"{make_user_key('alice')} {make_action_key('read')} {make_scope_key('org', 'OpenedX')}" self.command._test_interactive_request(self.enforcer, user_input) @@ -164,7 +166,7 @@ def test_interactive_request_allowed(self): def test_interactive_request_denied(self): """Test that `_test_interactive_request` prints denied output format.""" self.enforcer.enforce.return_value = False - user_input = "user:alice act:delete org:OpenedX" + user_input = f"{make_user_key('alice')} {make_action_key('delete')} {make_scope_key('org', 'OpenedX')}" self.command._test_interactive_request(self.enforcer, user_input) @@ -173,21 +175,22 @@ def test_interactive_request_denied(self): def test_interactive_request_invalid_format(self): """Test that `_test_interactive_request` reports invalid input format.""" - user_input = "user:alice act:read" + user_input = f"{make_user_key('alice')} {make_action_key('read')}" self.command._test_interactive_request(self.enforcer, user_input) invalid_output = self.buffer.getvalue() self.assertIn("✗ Invalid format. Expected 3 parts, got 2", invalid_output) self.assertIn("Format: subject action scope", invalid_output) - self.assertIn(f"Example: {user_input} org:OpenedX", invalid_output) + self.assertIn(f"Example: {user_input} {make_scope_key('org', 'OpenedX')}", invalid_output) @data(ValueError(), IndexError(), TypeError()) def test_interactive_request_error(self, exception: Exception): """Test that `_test_interactive_request` handles processing errors.""" self.enforcer.enforce.side_effect = exception + user_input = f"{make_user_key('alice')} {make_action_key('read')} {make_scope_key('org', 'OpenedX')}" - self.command._test_interactive_request(self.enforcer, "user:alice act:read org:OpenedX") + self.command._test_interactive_request(self.enforcer, user_input) error_output = self.buffer.getvalue() self.assertIn(f"✗ Error processing request: {str(exception)}", error_output) diff --git a/openedx_authz/tests/test_enforcement.py b/openedx_authz/tests/test_enforcement.py index 6f30f62b..63fb9c5b 100644 --- a/openedx_authz/tests/test_enforcement.py +++ b/openedx_authz/tests/test_enforcement.py @@ -13,6 +13,13 @@ from ddt import data, ddt, unpack from openedx_authz import ROOT_DIRECTORY +from openedx_authz.tests.test_utils import ( + make_action_key, + make_library_key, + make_role_key, + make_scope_key, + make_user_key, +) class AuthRequest(TypedDict): @@ -28,11 +35,11 @@ class AuthRequest(TypedDict): COMMON_ACTION_GROUPING = [ # manage implies edit and delete - ["g2", "act:manage", "act:edit"], - ["g2", "act:manage", "act:delete"], + ["g2", make_action_key("manage"), make_action_key("edit")], + ["g2", make_action_key("manage"), make_action_key("delete")], # edit implies read and write - ["g2", "act:edit", "act:read"], - ["g2", "act:edit", "act:write"], + ["g2", make_action_key("edit"), make_action_key("read")], + ["g2", make_action_key("edit"), make_action_key("write")], ] @@ -112,33 +119,33 @@ class SystemWideRoleTests(CasbinEnforcementTestCase): """ POLICY = [ - ["p", "role:platform_admin", "act:manage", "*", "allow"], - ["g", "user:user-1", "role:platform_admin", "*"], + ["p", make_role_key("platform_admin"), make_action_key("manage"), "*", "allow"], + ["g", make_user_key("user-1"), make_role_key("platform_admin"), "*"], ] + COMMON_ACTION_GROUPING GENERAL_CASES = [ { - "subject": "user:user-1", - "action": "act:manage", + "subject": make_user_key("user-1"), + "action": make_action_key("manage"), "scope": "*", "expected_result": True, }, { - "subject": "user:user-1", - "action": "act:manage", - "scope": "org:any-org", + "subject": make_user_key("user-1"), + "action": make_action_key("manage"), + "scope": make_scope_key("org", "any-org"), "expected_result": True, }, { - "subject": "user:user-1", - "action": "act:manage", - "scope": "course:course-v1:any-org+any-course+any-course-run", + "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"), "expected_result": True, }, { - "subject": "user:user-1", - "action": "act:manage", - "scope": "lib:lib:any-org:any-library", + "subject": make_user_key("user-1"), + "action": make_action_key("manage"), + "scope": make_library_key("lib:DemoX:CSPROB"), "expected_result": True, }, ] @@ -160,33 +167,33 @@ class ActionGroupingTests(CasbinEnforcementTestCase): """ POLICY = [ - ["p", "role:role-1", "act:manage", "org:*", "allow"], - ["g", "user:user-1", "role:role-1", "org:any-org"], + ["p", make_role_key("role-1"), make_action_key("manage"), make_scope_key("org", "*"), "allow"], + ["g", make_user_key("user-1"), make_role_key("role-1"), make_scope_key("org", "any-org")], ] + COMMON_ACTION_GROUPING CASES = [ { - "subject": "user:user-1", - "action": "act:edit", - "scope": "org:any-org", + "subject": make_user_key("user-1"), + "action": make_action_key("edit"), + "scope": make_scope_key("org", "any-org"), "expected_result": True, }, { - "subject": "user:user-1", - "action": "act:read", - "scope": "org:any-org", + "subject": make_user_key("user-1"), + "action": make_action_key("read"), + "scope": make_scope_key("org", "any-org"), "expected_result": True, }, { - "subject": "user:user-1", - "action": "act:write", - "scope": "org:any-org", + "subject": make_user_key("user-1"), + "action": make_action_key("write"), + "scope": make_scope_key("org", "any-org"), "expected_result": True, }, { - "subject": "user:user-1", - "action": "act:delete", - "scope": "org:any-org", + "subject": make_user_key("user-1"), + "action": make_action_key("delete"), + "scope": make_scope_key("org", "any-org"), "expected_result": True, }, ] @@ -208,80 +215,85 @@ class RoleAssignmentTests(CasbinEnforcementTestCase): POLICY = [ # Policies - ["p", "role:platform_admin", "act:manage", "*", "allow"], - ["p", "role:org_admin", "act:manage", "org:*", "allow"], - ["p", "role:org_editor", "act:edit", "org:*", "allow"], - ["p", "role:org_author", "act:write", "org:*", "allow"], - ["p", "role:course_admin", "act:manage", "course:*", "allow"], - ["p", "role:library_admin", "act:manage", "lib:*", "allow"], - ["p", "role:library_editor", "act:edit", "lib:*", "allow"], - ["p", "role:library_reviewer", "act:read", "lib:*", "allow"], - ["p", "role:library_author", "act:write", "lib:*", "allow"], + ["p", make_role_key("platform_admin"), make_action_key("manage"), "*", "allow"], + ["p", make_role_key("org_admin"), make_action_key("manage"), make_scope_key("org", "*"), "allow"], + ["p", make_role_key("org_editor"), make_action_key("edit"), make_scope_key("org", "*"), "allow"], + ["p", make_role_key("org_author"), make_action_key("write"), make_scope_key("org", "*"), "allow"], + ["p", make_role_key("course_admin"), make_action_key("manage"), make_scope_key("course", "*"), "allow"], + ["p", make_role_key("library_admin"), make_action_key("manage"), make_scope_key("lib", "*"), "allow"], + ["p", make_role_key("library_editor"), make_action_key("edit"), make_scope_key("lib", "*"), "allow"], + ["p", make_role_key("library_reviewer"), make_action_key("read"), make_scope_key("lib", "*"), "allow"], + ["p", make_role_key("library_author"), make_action_key("write"), make_scope_key("lib", "*"), "allow"], # Role assignments - ["g", "user:user-1", "role:platform_admin", "*"], - ["g", "user:user-2", "role:org_admin", "org:any-org"], - ["g", "user:user-3", "role:org_editor", "org:any-org"], - ["g", "user:user-4", "role:org_author", "org:any-org"], - ["g", "user:user-5", "role:course_admin", "course:course-v1:any-org+any-course+any-course-run"], - ["g", "user:user-6", "role:library_admin", "lib:lib:any-org:any-library"], - ["g", "user:user-7", "role:library_editor", "lib:lib:any-org:any-library"], - ["g", "user:user-8", "role:library_reviewer", "lib:lib:any-org:any-library"], - ["g", "user:user-9", "role:library_author", "lib:lib:any-org:any-library"], + ["g", make_user_key("user-1"), make_role_key("platform_admin"), "*"], + ["g", make_user_key("user-2"), make_role_key("org_admin"), make_scope_key("org", "any-org")], + ["g", make_user_key("user-3"), make_role_key("org_editor"), make_scope_key("org", "any-org")], + ["g", make_user_key("user-4"), make_role_key("org_author"), make_scope_key("org", "any-org")], + [ + "g", + make_user_key("user-5"), + make_role_key("course_admin"), + make_scope_key("course", "course-v1:any-org+any-course+any-course-run"), + ], + ["g", make_user_key("user-6"), make_role_key("library_admin"), make_library_key("lib:DemoX:CSPROB")], + ["g", make_user_key("user-7"), make_role_key("library_editor"), make_library_key("lib:DemoX:CSPROB")], + ["g", make_user_key("user-8"), make_role_key("library_reviewer"), make_library_key("lib:DemoX:CSPROB")], + ["g", make_user_key("user-9"), make_role_key("library_author"), make_library_key("lib:DemoX:CSPROB")], ] + COMMON_ACTION_GROUPING CASES = [ { - "subject": "user:user-1", - "action": "act:manage", - "scope": "org:any-org", + "subject": make_user_key("user-1"), + "action": make_action_key("manage"), + "scope": make_scope_key("org", "any-org"), "expected_result": True, }, { - "subject": "user:user-2", - "action": "act:manage", - "scope": "org:any-org", + "subject": make_user_key("user-2"), + "action": make_action_key("manage"), + "scope": make_scope_key("org", "any-org"), "expected_result": True, }, { - "subject": "user:user-3", - "action": "act:edit", - "scope": "org:any-org", + "subject": make_user_key("user-3"), + "action": make_action_key("edit"), + "scope": make_scope_key("org", "any-org"), "expected_result": True, }, { - "subject": "user:user-4", - "action": "act:write", - "scope": "org:any-org", + "subject": make_user_key("user-4"), + "action": make_action_key("write"), + "scope": make_scope_key("org", "any-org"), "expected_result": True, }, { - "subject": "user:user-5", - "action": "act:manage", - "scope": "course:course-v1:any-org+any-course+any-course-run", + "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"), "expected_result": True, }, { - "subject": "user:user-6", - "action": "act:manage", - "scope": "lib:lib:any-org:any-library", + "subject": make_user_key("user-6"), + "action": make_action_key("manage"), + "scope": make_library_key("lib:DemoX:CSPROB"), "expected_result": True, }, { - "subject": "user:user-7", - "action": "act:edit", - "scope": "lib:lib:any-org:any-library", + "subject": make_user_key("user-7"), + "action": make_action_key("edit"), + "scope": make_library_key("lib:DemoX:CSPROB"), "expected_result": True, }, { - "subject": "user:user-8", - "action": "act:read", - "scope": "lib:lib:any-org:any-library", + "subject": make_user_key("user-8"), + "action": make_action_key("read"), + "scope": make_library_key("lib:DemoX:CSPROB"), "expected_result": True, }, { - "subject": "user:user-9", - "action": "act:write", - "scope": "lib:lib:any-org:any-library", + "subject": make_user_key("user-9"), + "action": make_action_key("write"), + "scope": make_library_key("lib:DemoX:CSPROB"), "expected_result": True, }, ] @@ -301,46 +313,52 @@ class DeniedAccessTests(CasbinEnforcementTestCase): """ POLICY = [ - ["p", "role:platform_admin", "act:manage", "*", "allow"], - ["p", "role:platform_admin", "act:manage", "org:restricted-org", "deny"], - ["g", "user:user-1", "role:platform_admin", "*"], + ["p", make_role_key("platform_admin"), make_action_key("manage"), "*", "allow"], + [ + "p", + make_role_key("platform_admin"), + make_action_key("manage"), + make_scope_key("org", "restricted-org"), + "deny", + ], + ["g", make_user_key("user-1"), make_role_key("platform_admin"), "*"], ] + COMMON_ACTION_GROUPING CASES = [ { - "subject": "user:user-1", - "action": "act:manage", - "scope": "org:allowed-org", + "subject": make_user_key("user-1"), + "action": make_action_key("manage"), + "scope": make_scope_key("org", "allowed-org"), "expected_result": True, }, { - "subject": "user:user-1", - "action": "act:manage", - "scope": "org:restricted-org", + "subject": make_user_key("user-1"), + "action": make_action_key("manage"), + "scope": make_scope_key("org", "restricted-org"), "expected_result": False, }, { - "subject": "user:user-1", - "action": "act:edit", - "scope": "org:restricted-org", + "subject": make_user_key("user-1"), + "action": make_action_key("edit"), + "scope": make_scope_key("org", "restricted-org"), "expected_result": False, }, { - "subject": "user:user-1", - "action": "act:read", - "scope": "org:restricted-org", + "subject": make_user_key("user-1"), + "action": make_action_key("read"), + "scope": make_scope_key("org", "restricted-org"), "expected_result": False, }, { - "subject": "user:user-1", - "action": "act:write", - "scope": "org:restricted-org", + "subject": make_user_key("user-1"), + "action": make_action_key("write"), + "scope": make_scope_key("org", "restricted-org"), "expected_result": False, }, { - "subject": "user:user-1", - "action": "act:delete", - "scope": "org:restricted-org", + "subject": make_user_key("user-1"), + "action": make_action_key("delete"), + "scope": make_scope_key("org", "restricted-org"), "expected_result": False, }, ] @@ -356,35 +374,37 @@ 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 + or "org^*" for organization-wide access) can properly access resources within their authorized scope boundaries. + + TODO: this needs to be updated with the latest changes in the model. """ POLICY = [ # Policies - ["p", "role:platform_admin", "act:manage", "*", "allow"], - ["p", "role:org_admin", "act:manage", "org:*", "allow"], - ["p", "role:course_admin", "act:manage", "course:*", "allow"], - ["p", "role:library_admin", "act:manage", "lib:*", "allow"], + ["p", make_role_key("platform_admin"), make_action_key("manage"), "*", "allow"], + ["p", make_role_key("org_admin"), make_action_key("manage"), make_scope_key("org", "*"), "allow"], + ["p", make_role_key("course_admin"), make_action_key("manage"), make_scope_key("course", "*"), "allow"], + ["p", make_role_key("library_admin"), make_action_key("manage"), make_scope_key("lib", "*"), "allow"], # Role assignments - ["g", "user:user-1", "role:platform_admin", "*"], - ["g", "user:user-2", "role:org_admin", "*"], - ["g", "user:user-3", "role:course_admin", "*"], - ["g", "user:user-4", "role:library_admin", "*"], + ["g", make_user_key("user-1"), make_role_key("platform_admin"), "*"], + ["g", make_user_key("user-2"), make_role_key("org_admin"), "*"], + ["g", make_user_key("user-3"), make_role_key("course_admin"), "*"], + ["g", make_user_key("user-4"), make_role_key("library_admin"), "*"], ] + COMMON_ACTION_GROUPING @data( ("*", True), - ("org:MIT", True), - ("course:course-v1:OpenedX+DemoX+CS101", True), - ("lib:lib:OpenedX:math-basics", 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": "user:user-1", - "action": "act:manage", + "subject": make_user_key("user-1"), + "action": make_action_key("manage"), "scope": scope, "expected_result": expected_result, } @@ -392,16 +412,16 @@ def test_wildcard_global_access(self, scope: str, expected_result: bool): @data( ("*", False), - ("org:MIT", True), - ("course:course-v1:OpenedX+DemoX+CS101", False), - ("lib:lib:OpenedX:math-basics", 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": "user:user-2", - "action": "act:manage", + "subject": make_user_key("user-2"), + "action": make_action_key("manage"), "scope": scope, "expected_result": expected_result, } @@ -409,16 +429,16 @@ def test_wildcard_org_access(self, scope: str, expected_result: bool): @data( ("*", False), - ("org:MIT", False), - ("course:course-v1:OpenedX+DemoX+CS101", True), - ("lib:lib:OpenedX:math-basics", False), + (make_scope_key("org", "MIT"), False), + (make_scope_key("course", "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": "user:user-3", - "action": "act:manage", + "subject": make_user_key("user-3"), + "action": make_action_key("manage"), "scope": scope, "expected_result": expected_result, } @@ -426,16 +446,16 @@ def test_wildcard_course_access(self, scope: str, expected_result: bool): @data( ("*", False), - ("org:MIT", False), - ("course:course-v1:OpenedX+DemoX+CS101", False), - ("lib:lib:OpenedX:math-basics", True), + (make_scope_key("org", "MIT"), False), + (make_scope_key("course", "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": "user:user-4", - "action": "act:manage", + "subject": make_user_key("user-4"), + "action": make_action_key("manage"), "scope": scope, "expected_result": expected_result, } diff --git a/openedx_authz/tests/test_enforcer.py b/openedx_authz/tests/test_enforcer.py new file mode 100644 index 00000000..3d3b033c --- /dev/null +++ b/openedx_authz/tests/test_enforcer.py @@ -0,0 +1,408 @@ +"""Test cases for enforcer policy loading strategies. + +This test suite verifies the functionality of policy loading mechanisms +including filtered loading, scope-based loading, and lifecycle management +that would be used in production environments. +""" + +import casbin +from ddt import data as ddt_data +from ddt import ddt +from django.test import TestCase + +from openedx_authz.engine.enforcer import enforcer as global_enforcer +from openedx_authz.engine.filter import Filter +from openedx_authz.engine.utils import migrate_policy_between_enforcers + + +class PolicyLoadingTestSetupMixin(TestCase): + """Mixin providing policy loading test utilities.""" + + @staticmethod + def _count_policies_in_file(scope_pattern: str = None, role: str = None): + """Count policies in the authz.policy file matching the given criteria. + + This provides a dynamic way to get expected policy counts without + hardcoding values that might change as the policy file evolves. + + Args: + scope_pattern: Scope pattern to match (e.g., 'lib^*') + role: Role to match (e.g., 'role^library_admin') + + Returns: + int: Number of matching policies + """ + count = 0 + with open("openedx_authz/engine/config/authz.policy", "r") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + if not line.startswith("p,"): + continue + + parts = [p.strip() for p in line.split(",")] + if len(parts) < 4: + continue + + # parts[0] = 'p', parts[1] = role, parts[2] = action, parts[3] = scope + matches = True + if role and parts[1] != role: + matches = False + if scope_pattern and parts[3] != scope_pattern: + matches = False + + if matches: + count += 1 + return count + + def _seed_database_with_policies(self): + """Seed the database with policies from the policy file. + + This simulates the one-time database seeding that would happen + during application deployment, separate from runtime policy loading. + """ + # Always start with completely clean state + global_enforcer.clear_policy() + + migrate_policy_between_enforcers( + source_enforcer=casbin.Enforcer( + "openedx_authz/engine/config/model.conf", + "openedx_authz/engine/config/authz.policy", + ), + target_enforcer=global_enforcer, + ) + # Ensure enforcer memory is clean for test isolation + global_enforcer.clear_policy() + + def _load_policies_for_scope(self, scope: str = None): + """Load policies for a specific scope using load_filtered_policy. + + This simulates the real-world scenario where the application + loads only relevant policies based on the current context. + + Args: + scope: The scope to load policies for (e.g., 'lib^*' for all libraries). + If None, loads all policies using load_policy(). + """ + if scope is None: + global_enforcer.load_policy() + else: + policy_filter = Filter(v2=[scope]) + global_enforcer.load_filtered_policy(policy_filter) + + def _load_policies_for_user_context(self, scopes: list[str] = None): + """Load policies relevant to a user's context like accessible scopes. + + Args: + scopes: List of scopes the user is operating in. + """ + global_enforcer.clear_policy() + + if scopes: + scope_filter = Filter(v2=scopes) + global_enforcer.load_filtered_policy(scope_filter) + else: + global_enforcer.load_policy() + + def _load_policies_for_role_management(self, role_name: str = None): + """Load policies needed for role management operations. + + This simulates loading policies when performing role management + operations like assigning roles, checking permissions, etc. + + Args: + role_name: Specific role to load policies for, if any. + """ + global_enforcer.clear_policy() + + if role_name: + role_filter = Filter(v0=[role_name]) + global_enforcer.load_filtered_policy(role_filter) + else: + role_filter = Filter(ptype=["p"]) + global_enforcer.load_filtered_policy(role_filter) + + def _add_test_policies_for_multiple_scopes(self): + """Add test policies for different scopes to demonstrate filtering. + + This adds course and organization policies in addition to existing + library policies to create a realistic multi-scope environment. + """ + test_policies = [ + # Course policies + ["role^course_instructor", "act^edit_course", "course^*", "allow"], + ["role^course_instructor", "act^grade_students", "course^*", "allow"], + ["role^course_ta", "act^view_course", "course^*", "allow"], + ["role^course_ta", "act^grade_assignments", "course^*", "allow"], + ["role^course_student", "act^view_course", "course^*", "allow"], + ["role^course_student", "act^submit_assignment", "course^*", "allow"], + # Organization policies + ["role^org_admin", "act^manage_org", "org^*", "allow"], + ["role^org_admin", "act^create_courses", "org^*", "allow"], + ["role^org_member", "act^view_org", "org^*", "allow"], + ] + + for policy in test_policies: + global_enforcer.add_policy(*policy) + + +@ddt +class TestPolicyLoadingStrategies(PolicyLoadingTestSetupMixin): + """Test cases demonstrating realistic policy loading strategies. + + These tests demonstrate how policy loading would work in real-world scenarios, + including scope-based loading, user-context loading, and role-specific loading. + All based on our basic policy setup in authz.policy file. + """ + + LIBRARY_ROLES = [ + "role^library_user", + "role^library_admin", + "role^library_author", + "role^library_collaborator", + ] + + def setUp(self): + """Set up test environment without auto-loading policies.""" + super().setUp() + self._seed_database_with_policies() + + def tearDown(self): + """Clean up after each test to ensure isolation.""" + global_enforcer.clear_policy() + super().tearDown() + + @ddt_data( + "lib^*", # Library policies from authz.policy file + "course^*", # No course policies in basic setup + "org^*", # No org policies in basic setup + ) + def test_scope_based_policy_loading(self, scope): + """Test loading policies for specific scopes. + + This demonstrates how an application would load only policies + relevant to the current scope when user navigates to a section. + + Expected result: + - Enforcer starts empty + - Only scope-relevant policies are loaded + - Policy count matches expected for scope + """ + expected_policy_count = self._count_policies_in_file(scope_pattern=scope) + initial_policy_count = len(global_enforcer.get_policy()) + + self._load_policies_for_scope(scope) + loaded_policies = global_enforcer.get_policy() + + self.assertEqual(initial_policy_count, 0) + self.assertEqual(len(loaded_policies), expected_policy_count) + + if expected_policy_count > 0: + scope_prefix = scope.replace("*", "") + for policy in loaded_policies: + self.assertTrue(policy[2].startswith(scope_prefix)) + + @ddt_data( + ["lib^*"], + ["lib^*", "course^*"], + ["org^*"], + ) + def test_user_context_policy_loading(self, user_scopes): + """Test loading policies based on user context. + + This demonstrates loading policies when a user logs in or + changes context switching between accessible resources. + + Expected result: + - Enforcer starts empty + - Policies are loaded for user's scopes + - Policy count is reasonable for context + """ + initial_policy_count = len(global_enforcer.get_policy()) + + self._load_policies_for_user_context(user_scopes) + loaded_policies = global_enforcer.get_policy() + + self.assertEqual(initial_policy_count, 0) + self.assertGreaterEqual(len(loaded_policies), 0) + + @ddt_data(*LIBRARY_ROLES) + def test_role_specific_policy_loading(self, role_name): + """Test loading policies for specific role management operations. + + This demonstrates loading policies when performing administrative + operations like role assignment or permission checking. + + Expected result: + - Enforcer starts empty + - Role-specific policies are loaded + - Loaded policies contain expected role + """ + initial_policy_count = len(global_enforcer.get_policy()) + + self._load_policies_for_role_management(role_name) + loaded_policies = global_enforcer.get_policy() + + self.assertEqual(initial_policy_count, 0) + self.assertGreater(len(loaded_policies), 0) + + role_found = any(role_name in str(policy) for policy in loaded_policies) + self.assertTrue(role_found) + + def test_policy_loading_lifecycle(self): + """Test the complete policy loading lifecycle. + + This demonstrates a realistic sequence of policy loading operations + that might occur during application runtime. + + Expected result: + - Each loading stage produces expected policy counts + - Policy counts change appropriately between stages + - No policies exist at startup + """ + startup_policy_count = len(global_enforcer.get_policy()) + + self.assertEqual(startup_policy_count, 0) + + self._load_policies_for_scope("lib^*") + library_policy_count = len(global_enforcer.get_policy()) + + self.assertGreater(library_policy_count, 0) + + self._load_policies_for_role_management("role^library_admin") + admin_policy_count = len(global_enforcer.get_policy()) + + self.assertLessEqual(admin_policy_count, library_policy_count) + + self._load_policies_for_user_context(["lib^*"]) + user_policy_count = len(global_enforcer.get_policy()) + + self.assertEqual(user_policy_count, library_policy_count) + + def test_empty_enforcer_behavior(self): + """Test behavior when no policies are loaded. + + This demonstrates what happens when the enforcer has no policies, + which is the default state in production before explicit loading. + + Expected result: + - Enforcer starts empty + - Policy queries return empty results + - No enforcement decisions are possible + """ + initial_policy_count = len(global_enforcer.get_policy()) + all_policies = global_enforcer.get_policy() + all_grouping_policies = global_enforcer.get_grouping_policy() + + self.assertEqual(initial_policy_count, 0) + self.assertEqual(len(all_policies), 0) + self.assertEqual(len(all_grouping_policies), 0) + + @ddt_data( + Filter(v2=["lib^*"]), # Load all library policies + Filter(v2=["course^*"]), # Load all course policies + Filter(v2=["org^*"]), # Load all organization policies + Filter(v2=["lib^*", "course^*"]), # Load library and course policies + Filter(v0=["role^library_user"]), # Load policies for specific role + Filter(ptype=["p"]), # Load all 'p' type policies + ) + def test_filtered_policy_loading_variations(self, policy_filter): + """Test various filtered policy loading scenarios. + + This demonstrates different filtering strategies that can be used + to load specific subsets of policies based on application needs. + + Expected result: + - Enforcer starts empty + - Filtered loading works without errors + - Appropriate policies are loaded based on filter + """ + initial_policy_count = len(global_enforcer.get_policy()) + + global_enforcer.clear_policy() + global_enforcer.load_filtered_policy(policy_filter) + + loaded_policies = global_enforcer.get_policy() + + self.assertEqual(initial_policy_count, 0) + self.assertGreaterEqual(len(loaded_policies), 0) + + def test_policy_clear_and_reload(self): + """Test clearing and reloading policies maintains consistency. + + Expected result: + - Cleared enforcer has no policies + - Reloading produces same count as initial load + """ + self._load_policies_for_scope("lib^*") + initial_load_count = len(global_enforcer.get_policy()) + + self.assertGreater(initial_load_count, 0) + + global_enforcer.clear_policy() + cleared_count = len(global_enforcer.get_policy()) + + self.assertEqual(cleared_count, 0) + + self._load_policies_for_scope("lib^*") + reloaded_count = len(global_enforcer.get_policy()) + + self.assertEqual(reloaded_count, initial_load_count) + + @ddt_data(*LIBRARY_ROLES) + def test_filtered_loading_by_role(self, role_name): + """Test loading policies filtered by specific role. + + Expected result: + - Filtered count matches policies in file for that role + - All loaded policies contain the specified role + """ + expected_count = self._count_policies_in_file(role=role_name) + + self._load_policies_for_role_management(role_name) + loaded_policies = global_enforcer.get_policy() + + self.assertEqual(len(loaded_policies), expected_count) + + for policy in loaded_policies: + self.assertIn(role_name, str(policy)) + + def test_multi_scope_filtering(self): + """Test filtering across multiple scopes. + + Expected result: + - Combined scope filter loads sum of individual scopes + - Total load equals sum of all scope policies + """ + lib_scope = "lib^*" + course_scope = "course^*" + org_scope = "org^*" + + expected_lib_count = self._count_policies_in_file(scope_pattern=lib_scope) + self._add_test_policies_for_multiple_scopes() + + self._load_policies_for_scope(lib_scope) + lib_count = len(global_enforcer.get_policy()) + + self._load_policies_for_scope(course_scope) + course_count = len(global_enforcer.get_policy()) + + self._load_policies_for_scope(org_scope) + org_count = len(global_enforcer.get_policy()) + + self.assertEqual(lib_count, expected_lib_count) + self.assertEqual(course_count, 6) + self.assertEqual(org_count, 3) + + global_enforcer.clear_policy() + combined_filter = Filter(v2=[lib_scope, course_scope]) + global_enforcer.load_filtered_policy(combined_filter) + combined_count = len(global_enforcer.get_policy()) + + self.assertEqual(combined_count, lib_count + course_count) + + global_enforcer.load_policy() + total_count = len(global_enforcer.get_policy()) + + self.assertEqual(total_count, lib_count + course_count + org_count) diff --git a/openedx_authz/tests/test_filter.py b/openedx_authz/tests/test_filter.py index a36ebb44..666cb163 100644 --- a/openedx_authz/tests/test_filter.py +++ b/openedx_authz/tests/test_filter.py @@ -10,6 +10,7 @@ import unittest from openedx_authz.engine.filter import Filter +from openedx_authz.tests.test_utils import make_action_key, make_role_key, make_scope_key, make_user_key class TestFilter(unittest.TestCase): @@ -35,27 +36,32 @@ def test_initialization_with_ptype(self): def test_initialization_with_multiple_attributes(self): """Test Filter initialization with multiple attributes.""" - f = Filter(ptype=["p"], v0=["user:alice"], v1=["act:read"], v2=["org:MIT"]) + f = Filter( + ptype=["p"], + v0=[make_user_key("alice")], + v1=[make_action_key("read")], + v2=[make_scope_key("org", "MIT")] + ) self.assertEqual(f.ptype, ["p"]) - self.assertEqual(f.v0, ["user:alice"]) - self.assertEqual(f.v1, ["act:read"]) - self.assertEqual(f.v2, ["org:MIT"]) + self.assertEqual(f.v0, [make_user_key("alice")]) + self.assertEqual(f.v1, [make_action_key("read")]) + self.assertEqual(f.v2, [make_scope_key("org", "MIT")]) def test_initialization_with_all_attributes(self): """Test Filter initialization with all attributes.""" f = Filter( ptype=["p", "g"], - v0=["user:alice"], - v1=["act:read"], - v2=["org:MIT"], + v0=[make_user_key("alice")], + v1=[make_action_key("read")], + v2=[make_scope_key("org", "MIT")], v3=["allow"], v4=["context1"], v5=["context2"], ) self.assertEqual(f.ptype, ["p", "g"]) - self.assertEqual(f.v0, ["user:alice"]) - self.assertEqual(f.v1, ["act:read"]) - self.assertEqual(f.v2, ["org:MIT"]) + self.assertEqual(f.v0, [make_user_key("alice")]) + self.assertEqual(f.v1, [make_action_key("read")]) + self.assertEqual(f.v2, [make_scope_key("org", "MIT")]) self.assertEqual(f.v3, ["allow"]) self.assertEqual(f.v4, ["context1"]) self.assertEqual(f.v5, ["context2"]) @@ -70,11 +76,11 @@ def test_modify_multiple_attributes(self): """Test modifying multiple attributes after creation.""" f = Filter() f.ptype = ["g"] - f.v0 = ["user:bob"] - f.v1 = ["role:admin"] + f.v0 = [make_user_key("bob")] + f.v1 = [make_role_key("admin")] self.assertEqual(f.ptype, ["g"]) - self.assertEqual(f.v0, ["user:bob"]) - self.assertEqual(f.v1, ["role:admin"]) + self.assertEqual(f.v0, [make_user_key("bob")]) + self.assertEqual(f.v1, [make_role_key("admin")]) def test_empty_list_assignment(self): """Test assigning empty lists to attributes.""" @@ -116,35 +122,40 @@ def test_filter_multiple_policy_types(self): def test_filter_user_permissions(self): """Test filter for a specific user's permissions.""" - f = Filter(ptype=["p"], v0=["user:alice"]) + f = Filter(ptype=["p"], v0=[make_user_key("alice")]) self.assertEqual(f.ptype, ["p"]) - self.assertEqual(f.v0, ["user:alice"]) + self.assertEqual(f.v0, [make_user_key("alice")]) def test_filter_role_assignments(self): """Test filter for role assignments for a user.""" - f = Filter(ptype=["g"], v0=["user:alice"], v1=["role:admin"], v2=["org:MIT"]) + f = Filter( + ptype=["g"], + v0=[make_user_key("alice")], + v1=[make_role_key("admin")], + v2=[make_scope_key("org", "MIT")] + ) self.assertEqual(f.ptype, ["g"]) - self.assertEqual(f.v0, ["user:alice"]) - self.assertEqual(f.v1, ["role:admin"]) - self.assertEqual(f.v2, ["org:MIT"]) + self.assertEqual(f.v0, [make_user_key("alice")]) + self.assertEqual(f.v1, [make_role_key("admin")]) + self.assertEqual(f.v2, [make_scope_key("org", "MIT")]) def test_filter_organization_policies(self): """Test filter for all policies related to an organization.""" - f = Filter(v2=["org:MIT"]) - self.assertEqual(f.v2, ["org:MIT"]) + f = Filter(v2=[make_scope_key("org", "MIT")]) + self.assertEqual(f.v2, [make_scope_key("org", "MIT")]) self.assertEqual(f.ptype, []) def test_filter_specific_action(self): """Test filter for policies with a specific action.""" - f = Filter(ptype=["p"], v1=["act:edit", "act:delete"]) + f = Filter(ptype=["p"], v1=[make_action_key("edit"), make_action_key("delete")]) self.assertEqual(f.ptype, ["p"]) - self.assertEqual(f.v1, ["act:edit", "act:delete"]) + self.assertEqual(f.v1, [make_action_key("edit"), make_action_key("delete")]) def test_filter_action_hierarchy(self): """Test filter for action grouping hierarchy.""" - f = Filter(ptype=["g2"], v0=["act:manage"]) + f = Filter(ptype=["g2"], v0=[make_action_key("manage")]) self.assertEqual(f.ptype, ["g2"]) - self.assertEqual(f.v0, ["act:manage"]) + self.assertEqual(f.v0, [make_action_key("manage")]) def test_filter_deny_policies(self): """Test filter for deny effect policies.""" @@ -154,18 +165,18 @@ def test_filter_deny_policies(self): def test_filter_wildcard_resources(self): """Test filter for wildcard resource patterns.""" - f = Filter(ptype=["p"], v2=["lib:*", "course:*"]) + f = Filter(ptype=["p"], v2=[make_scope_key("lib", "*"), make_scope_key("course", "*")]) self.assertEqual(f.ptype, ["p"]) - self.assertIn("lib:*", f.v2) - self.assertIn("course:*", f.v2) + self.assertIn(make_scope_key("lib", "*"), f.v2) + self.assertIn(make_scope_key("course", "*"), f.v2) def test_complex_permission_filter(self): """Test complex filter combining multiple criteria.""" f = Filter( ptype=["p"], - v0=["role:instructor", "role:admin"], - v1=["act:edit", "act:delete"], - v2=["course:CS101", "course:CS102"], + v0=[make_role_key("instructor"), make_role_key("admin")], + v1=[make_action_key("edit"), make_action_key("delete")], + v2=[make_scope_key("course", "CS101"), make_scope_key("course", "CS102")], ) self.assertEqual(len(f.ptype), 1) self.assertEqual(len(f.v0), 2) diff --git a/openedx_authz/tests/test_utils.py b/openedx_authz/tests/test_utils.py new file mode 100644 index 00000000..1efbb970 --- /dev/null +++ b/openedx_authz/tests/test_utils.py @@ -0,0 +1,64 @@ +"""Test utilities for creating namespaced keys using class constants.""" + +from openedx_authz.api.data import ActionData, ContentLibraryData, RoleData, ScopeData, UserData + + +def make_user_key(key: str) -> str: + """Create a namespaced user key. + + Args: + key: The user identifier (e.g., 'user-1', 'alice') + + Returns: + str: Namespaced user key (e.g., 'user^user-1') + """ + return f"{UserData.NAMESPACE}{UserData.SEPARATOR}{key}" + + +def make_role_key(key: str) -> str: + """Create a namespaced role key. + + Args: + key: The role identifier (e.g., 'platform_admin', 'library_editor') + + Returns: + str: Namespaced role key (e.g., 'role^platform_admin') + """ + return f"{RoleData.NAMESPACE}{RoleData.SEPARATOR}{key}" + + +def make_action_key(key: str) -> str: + """Create a namespaced action key. + + Args: + key: The action identifier (e.g., 'manage', 'edit', 'read') + + Returns: + str: Namespaced action key (e.g., 'act^manage') + """ + return f"{ActionData.NAMESPACE}{ActionData.SEPARATOR}{key}" + + +def make_library_key(key: str) -> str: + """Create a namespaced library key. + + Args: + key: The library identifier (e.g., 'lib:DemoX:CSPROB') + + Returns: + str: Namespaced library key (e.g., 'lib^lib:DemoX:CSPROB') + """ + return f"{ContentLibraryData.NAMESPACE}{ContentLibraryData.SEPARATOR}{key}" + + +def make_scope_key(namespace: str, key: str) -> str: + """Create a namespaced scope key with custom namespace. + + Args: + namespace: The scope namespace (e.g., 'org', 'course') + key: The scope identifier (e.g., 'any-org', 'course-v1:...') + + Returns: + str: Namespaced scope key (e.g., 'org^any-org') + """ + return f"{namespace}{ScopeData.SEPARATOR}{key}" diff --git a/requirements/base.in b/requirements/base.in index 939b2799..92e38c0b 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -9,3 +9,4 @@ attrs # Classes without boilerplate pycasbin # Authorization library for implementing access control models casbin-django-orm-adapter # Adapter for Django ORM for Casbin redis-watcher # Watcher for Redis for Casbin +edx-opaque-keys # Opaque keys for resource identification diff --git a/requirements/base.txt b/requirements/base.txt index 48aa2b73..c54c9f33 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile --cert=None --client-cert=None --index-url=None --output-file=requirements/base.txt --pip-args=None requirements/base.in +# pip-compile --output-file=requirements/base.txt requirements/base.in # asgiref==3.9.1 # via django @@ -12,9 +12,13 @@ casbin-django-orm-adapter==1.7.0 # via -r requirements/base.in django==4.2.24 # via - # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.in # casbin-django-orm-adapter +dnspython==2.8.0 + # via pymongo +edx-opaque-keys==3.0.0 + # via -r requirements/base.in openedx-atlas==0.7.0 # via -r requirements/base.in pycasbin==2.2.0 @@ -22,6 +26,8 @@ pycasbin==2.2.0 # -r requirements/base.in # casbin-django-orm-adapter # redis-watcher +pymongo==4.15.2 + # via edx-opaque-keys redis==6.4.0 # via redis-watcher redis-watcher==1.8.0 @@ -30,3 +36,7 @@ simpleeval==1.0.3 # via pycasbin sqlparse==0.5.3 # via django +stevedore==5.5.0 + # via edx-opaque-keys +typing-extensions==4.15.0 + # via edx-opaque-keys diff --git a/requirements/ci.txt b/requirements/ci.txt index fc973e79..236d83e6 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile --cert=None --client-cert=None --index-url=None --output-file=requirements/ci.txt --pip-args=None requirements/ci.in +# pip-compile --output-file=requirements/ci.txt requirements/ci.in # cachetools==6.2.0 # via tox diff --git a/requirements/dev.txt b/requirements/dev.txt index 4a6c599e..2dda5413 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile --cert=None --client-cert=None --index-url=None --output-file=requirements/dev.txt --pip-args=None requirements/dev.in +# pip-compile --output-file=requirements/dev.txt requirements/dev.in # asgiref==3.9.1 # via @@ -68,14 +68,20 @@ distlib==0.4.0 # virtualenv django==4.2.24 # via - # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/quality.txt # casbin-django-orm-adapter # edx-i18n-tools +dnspython==2.8.0 + # via + # -r requirements/quality.txt + # pymongo edx-i18n-tools==1.9.0 # via -r requirements/dev.in edx-lint==5.6.0 # via -r requirements/quality.txt +edx-opaque-keys==3.0.0 + # via -r requirements/quality.txt filelock==3.19.1 # via # -r requirements/ci.txt @@ -121,7 +127,7 @@ packaging==25.0 # tox path==16.16.0 # via edx-i18n-tools -pip-tools==7.5.0 +pip-tools==7.5.1 # via -r requirements/pip-tools.txt platformdirs==4.4.0 # via @@ -174,6 +180,10 @@ pylint-plugin-utils==0.9.0 # -r requirements/quality.txt # pylint-celery # pylint-django +pymongo==4.15.2 + # via + # -r requirements/quality.txt + # edx-opaque-keys pyproject-api==1.9.1 # via # -r requirements/ci.txt @@ -227,6 +237,7 @@ stevedore==5.5.0 # via # -r requirements/quality.txt # code-annotations + # edx-opaque-keys text-unidecode==1.3 # via # -r requirements/quality.txt @@ -237,6 +248,10 @@ tomlkit==0.13.3 # pylint tox==4.30.2 # via -r requirements/ci.txt +typing-extensions==4.15.0 + # via + # -r requirements/quality.txt + # edx-opaque-keys virtualenv==20.34.0 # via # -r requirements/ci.txt diff --git a/requirements/doc.txt b/requirements/doc.txt index 4a267b09..4638a67d 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile --cert=None --client-cert=None --index-url=None --output-file=requirements/doc.txt --pip-args=None requirements/doc.in +# pip-compile --output-file=requirements/doc.txt requirements/doc.in # accessible-pygments==0.0.5 # via pydata-sphinx-theme @@ -48,9 +48,13 @@ ddt==1.7.2 # via -r requirements/test.txt django==4.2.24 # via - # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt # casbin-django-orm-adapter +dnspython==2.8.0 + # via + # -r requirements/test.txt + # pymongo doc8==2.0.0 # via -r requirements/doc.in docutils==0.21.2 @@ -60,6 +64,8 @@ docutils==0.21.2 # readme-renderer # restructuredtext-lint # sphinx +edx-opaque-keys==3.0.0 + # via -r requirements/test.txt id==1.5.0 # via twine idna==3.10 @@ -137,6 +143,10 @@ pygments==2.19.2 # readme-renderer # rich # sphinx +pymongo==4.15.2 + # via + # -r requirements/test.txt + # edx-opaque-keys pyproject-hooks==1.2.0 # via build pytest==8.4.2 @@ -218,6 +228,7 @@ stevedore==5.5.0 # -r requirements/test.txt # code-annotations # doc8 + # edx-opaque-keys text-unidecode==1.3 # via # -r requirements/test.txt @@ -226,7 +237,9 @@ twine==6.2.0 # via -r requirements/doc.in typing-extensions==4.15.0 # via + # -r requirements/test.txt # beautifulsoup4 + # edx-opaque-keys # pydata-sphinx-theme urllib3==2.5.0 # via diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt index b34c27e0..f87d1e6b 100644 --- a/requirements/pip-tools.txt +++ b/requirements/pip-tools.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile --cert=None --client-cert=None --index-url=None --output-file=requirements/pip-tools.txt --pip-args=None requirements/pip-tools.in +# pip-compile --output-file=requirements/pip-tools.txt requirements/pip-tools.in # build==1.3.0 # via pip-tools @@ -10,7 +10,7 @@ click==8.3.0 # via pip-tools packaging==25.0 # via build -pip-tools==7.5.0 +pip-tools==7.5.1 # via -r requirements/pip-tools.in pyproject-hooks==1.2.0 # via diff --git a/requirements/pip.txt b/requirements/pip.txt index 204fe225..b6bd229a 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -2,15 +2,13 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile --allow-unsafe --cert=None --client-cert=None --index-url=None --output-file=requirements/pip.txt --pip-args=None requirements/pip.in +# pip-compile --allow-unsafe --output-file=requirements/pip.txt requirements/pip.in # wheel==0.45.1 # via -r requirements/pip.in # The following packages are considered to be unsafe in a requirements file: -pip==24.2 - # via - # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt - # -r requirements/pip.in +pip==25.2 + # via -r requirements/pip.in setuptools==80.9.0 # via -r requirements/pip.in diff --git a/requirements/quality.txt b/requirements/quality.txt index 046dd275..731b58df 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile --cert=None --client-cert=None --index-url=None --output-file=requirements/quality.txt --pip-args=None requirements/quality.in +# pip-compile --output-file=requirements/quality.txt requirements/quality.in # asgiref==3.9.1 # via @@ -38,11 +38,17 @@ dill==0.4.0 # via pylint django==4.2.24 # via - # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt # casbin-django-orm-adapter +dnspython==2.8.0 + # via + # -r requirements/test.txt + # pymongo edx-lint==5.6.0 # via -r requirements/quality.in +edx-opaque-keys==3.0.0 + # via -r requirements/test.txt iniconfig==2.1.0 # via # -r requirements/test.txt @@ -101,6 +107,10 @@ pylint-plugin-utils==0.9.0 # via # pylint-celery # pylint-django +pymongo==4.15.2 + # via + # -r requirements/test.txt + # edx-opaque-keys pytest==8.4.2 # via # -r requirements/test.txt @@ -140,9 +150,14 @@ stevedore==5.5.0 # via # -r requirements/test.txt # code-annotations + # edx-opaque-keys text-unidecode==1.3 # via # -r requirements/test.txt # python-slugify tomlkit==0.13.3 # via pylint +typing-extensions==4.15.0 + # via + # -r requirements/test.txt + # edx-opaque-keys diff --git a/requirements/test.txt b/requirements/test.txt index aed9c827..dca86d1d 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile --cert=None --client-cert=None --index-url=None --output-file=requirements/test.txt --pip-args=None requirements/test.in +# pip-compile --output-file=requirements/test.txt requirements/test.in # asgiref==3.9.1 # via @@ -21,9 +21,15 @@ coverage[toml]==7.10.6 ddt==1.7.2 # via -r requirements/test.in # via - # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.txt # casbin-django-orm-adapter +dnspython==2.8.0 + # via + # -r requirements/base.txt + # pymongo +edx-opaque-keys==3.0.0 + # via -r requirements/base.txt iniconfig==2.1.0 # via pytest jinja2==3.1.6 @@ -45,6 +51,10 @@ pycasbin==2.2.0 # redis-watcher pygments==2.19.2 # via pytest +pymongo==4.15.2 + # via + # -r requirements/base.txt + # edx-opaque-keys pytest==8.4.2 # via # pytest-cov @@ -72,6 +82,13 @@ sqlparse==0.5.3 # -r requirements/base.txt # django stevedore==5.5.0 - # via code-annotations + # via + # -r requirements/base.txt + # code-annotations + # edx-opaque-keys text-unidecode==1.3 # via python-slugify +typing-extensions==4.15.0 + # via + # -r requirements/base.txt + # edx-opaque-keys