From d13a8755950858880020836eea9ddc39653376a9 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 12 Nov 2025 19:17:43 -0500 Subject: [PATCH 1/5] refactor: update content library permissions to use namespaced identifiers --- .../content_library_roles.rst | 76 ++++++++-------- openedx_authz/api/data.py | 21 ++--- openedx_authz/constants/permissions.py | 25 +++--- openedx_authz/engine/config/authz.policy | 84 ++++++++--------- .../management/commands/enforcement.py | 14 +-- openedx_authz/rest_api/decorators.py | 7 +- openedx_authz/rest_api/v1/permissions.py | 2 +- openedx_authz/rest_api/v1/views.py | 4 +- openedx_authz/tests/api/test_data.py | 10 ++- openedx_authz/tests/api/test_roles.py | 36 ++++---- openedx_authz/tests/rest_api/test_views.py | 4 +- openedx_authz/tests/test_commands.py | 90 +++++++++++++++---- openedx_authz/tests/test_enforcement.py | 12 +-- openedx_authz/tests/test_utils.py | 14 ++- 14 files changed, 238 insertions(+), 161 deletions(-) diff --git a/docs/concepts/core_roles_and_permissions/content_library_roles.rst b/docs/concepts/core_roles_and_permissions/content_library_roles.rst index 0e294be1..42b87a19 100644 --- a/docs/concepts/core_roles_and_permissions/content_library_roles.rst +++ b/docs/concepts/core_roles_and_permissions/content_library_roles.rst @@ -28,46 +28,46 @@ The following permissions are associated with the content library roles: Library Permissions ======================= -- **View the library** (``view_library``): Allows users to view the content library. -- **Manage library tags** (``manage_library_tags``): Allows users to manage the tags associated with library items. -- **Delete the library** (``delete_library``): Allows users to delete the entire content library. +- **View the library** (``content_libraries.view_library``): Allows users to view the content library. +- **Manage library tags** (``content_libraries.manage_library_tags``): Allows users to manage the tags associated with library items. +- **Delete the library** (``content_libraries.delete_library``): Allows users to delete the entire content library. Library Content Permissions =============================== -- **Edit library content** (``edit_library_content``): Allows users to edit existing content within the library. -- **Publish library content** (``publish_library_content``): Allows users to publish content to or from the library. -- **Reuse library content** (``reuse_library_content``): Allows users to reuse content from the library in other contexts. +- **Edit library content** (``content_libraries.edit_library_content``): Allows users to edit existing content within the library. +- **Publish library content** (``content_libraries.publish_library_content``): Allows users to publish content to or from the library. +- **Reuse library content** (``content_libraries.reuse_library_content``): Allows users to reuse content from the library in other contexts. Library Team Permissions ============================= -- **View the library team** (``view_library_team``): Allows users to view the list of users or roles associated with the library. -- **Manage the library team** (``manage_library_team``): Allows users to add, remove, or change the roles of users in the library team. +- **View the library team** (``content_libraries.view_library_team``): Allows users to view the list of users or roles associated with the library. +- **Manage the library team** (``content_libraries.manage_library_team``): Allows users to add, remove, or change the roles of users in the library team. Library Collections Permissions =================================== -- **Create library collections** (``create_library_collection``): Allows users to create new collections within the library. -- **Edit library collections** (``edit_library_collection``): Allows users to modify existing collections within the library. -- **Delete library collections** (``delete_library_collection``): Allows users to delete collections within the library. +- **Create library collections** (``content_libraries.create_library_collection``): Allows users to create new collections within the library. +- **Edit library collections** (``content_libraries.edit_library_collection``): Allows users to modify existing collections within the library. +- **Delete library collections** (``content_libraries.delete_library_collection``): Allows users to delete collections within the library. Permissions Inheritance ======================== -* **Managing library tags** (``manage_library_tags``) implies **editing library content** (``edit_library_content``). -* **Deleting the library** (``delete_library``) implies **editing library content** (``edit_library_content``). -* **Publishing library content** (``publish_library_content``) implies **editing library content** (``edit_library_content``). -* **Editing library content** (``edit_library_content``) implies **viewing the library** (``view_library``). -* **Reusing library content** (``reuse_library_content``) implies **viewing the library** (``view_library``). -* **Publishing library content** (``publish_library_content``) implies **viewing the library** (``view_library``). -* **Managing the library team** (``manage_library_team``) implies **viewing the library team** (``view_library_team``). -* **Deleting a library collection** (``delete_library_collection``) implies **editing a library collection** (``edit_library_collection``). -* **Creating a library collection** (``create_library_collection``) implies **editing a library collection** (``edit_library_collection``). -* **Editing a library collection** (``edit_library_collection``) implies **viewing the library** (``view_library``). +* **Managing library tags** (``content_libraries.manage_library_tags``) implies **editing library content** (``content_libraries.edit_library_content``). +* **Deleting the library** (``content_libraries.delete_library``) implies **editing library content** (``content_libraries.edit_library_content``). +* **Publishing library content** (``content_libraries.publish_library_content``) implies **editing library content** (``content_libraries.edit_library_content``). +* **Editing library content** (``content_libraries.edit_library_content``) implies **viewing the library** (``content_libraries.view_library``). +* **Reusing library content** (``content_libraries.reuse_library_content``) implies **viewing the library** (``content_libraries.view_library``). +* **Publishing library content** (``content_libraries.publish_library_content``) implies **viewing the library** (``content_libraries.view_library``). +* **Managing the library team** (``content_libraries.manage_library_team``) implies **viewing the library team** (``content_libraries.view_library_team``). +* **Deleting a library collection** (``content_libraries.delete_library_collection``) implies **editing a library collection** (``content_libraries.edit_library_collection``). +* **Creating a library collection** (``content_libraries.create_library_collection``) implies **editing a library collection** (``content_libraries.edit_library_collection``). +* **Editing a library collection** (``content_libraries.edit_library_collection``) implies **viewing the library** (``content_libraries.view_library``). Roles and Permissions Summary Table @@ -76,25 +76,29 @@ Roles and Permissions Summary Table .. table:: Matrix of Content Library Roles and Permissions :widths: auto - ============================= ================= ================ ===================== ============== - Permissions Library Admin Library Author Library Contributor Library User - ============================= ================= ================ ===================== ============== + ============================================= ================= ================ ===================== ============== + Permissions Library Admin Library Author Library Contributor Library User + ============================================= ================= ================ ===================== ============== **Library** - view_library ✅ ✅ ✅ ✅ - manage_library_tags ✅ ✅ ✅ ❌ - delete_library ✅ ❌ ❌ ❌ + --------------------------------------------- ----------------- ---------------- --------------------- -------------- + content_libraries.view_library ✅ ✅ ✅ ✅ + content_libraries.manage_library_tags ✅ ✅ ✅ ❌ + content_libraries.delete_library ✅ ❌ ❌ ❌ **Content** - edit_library_content ✅ ✅ ✅ ❌ - publish_library_content ✅ ✅ ❌ ❌ - reuse_library_content ✅ ✅ ✅ ✅ + --------------------------------------------- ----------------- ---------------- --------------------- -------------- + content_libraries.edit_library_content ✅ ✅ ✅ ❌ + content_libraries.publish_library_content ✅ ✅ ❌ ❌ + content_libraries.reuse_library_content ✅ ✅ ✅ ✅ **Team** - view_library_team ✅ ✅ ✅ ✅ - manage_library_team ✅ ❌ ❌ ❌ + --------------------------------------------- ----------------- ---------------- --------------------- -------------- + content_libraries.view_library_team ✅ ✅ ✅ ✅ + content_libraries.manage_library_team ✅ ❌ ❌ ❌ **Collections** - create_library_collection ✅ ✅ ✅ ❌ - edit_library_collection ✅ ✅ ✅ ❌ - delete_library_collection ✅ ✅ ✅ ❌ - ============================= ================= ================ ===================== ============== + --------------------------------------------- ----------------- ---------------- --------------------- -------------- + content_libraries.create_library_collection ✅ ✅ ✅ ❌ + content_libraries.edit_library_collection ✅ ✅ ✅ ❌ + content_libraries.delete_library_collection ✅ ✅ ✅ ❌ + ============================================= ================= ================ ===================== ============== **Maintenance chart** diff --git a/openedx_authz/api/data.py b/openedx_authz/api/data.py index 01a67826..7a7222c6 100644 --- a/openedx_authz/api/data.py +++ b/openedx_authz/api/data.py @@ -600,29 +600,29 @@ class ActionData(AuthZData): 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'). + external_key: The action identifier (e.g., 'content_libraries.view_library'). + namespaced_key: The action identifier with namespace (e.g., 'act^content_libraries.view_library'). + name: Property that returns a human-readable action name (e.g., 'Content Libraries.View Library'). Examples: - >>> action = ActionData(external_key='delete_library') + >>> action = ActionData(external_key='content_libraries.delete_library') >>> action.namespaced_key - 'act^delete_library' + 'act^content_libraries.delete_library' >>> action.name - 'Delete Library' + 'Content Libraries.Delete Library' """ NAMESPACE: ClassVar[str] = "act" @property def name(self) -> str: - """The human-readable name of the action (e.g., 'Delete Library', 'Edit Content'). + """The human-readable name of the action (e.g., 'Content Libraries.Delete Library'). 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'). + str: The human-readable action name (e.g., 'Content Libraries.Delete Library'). """ return self.external_key.replace("_", " ").title() @@ -665,7 +665,7 @@ def identifier(self) -> str: """Get the permission identifier. Returns: - str: The permission identifier (e.g., 'delete_library'). + str: The permission identifier (e.g., 'content_libraries.delete_library'). """ return self.action.external_key @@ -753,7 +753,8 @@ def get_permission_identifiers(self) -> list[str]: """Get the technical identifiers for all permissions in this role. Returns: - list[str]: Permission identifiers (e.g., ['delete_library', 'edit_content']). + list[str]: Permission identifiers + (e.g., ['content_libraries.delete_library', 'content_libraries.edit_library_content']). """ return [permission.identifier for permission in self.permissions] diff --git a/openedx_authz/constants/permissions.py b/openedx_authz/constants/permissions.py index 376fb63f..033a8ee6 100644 --- a/openedx_authz/constants/permissions.py +++ b/openedx_authz/constants/permissions.py @@ -5,48 +5,51 @@ from openedx_authz.api.data import ActionData, PermissionData # Content Library Permissions + +CONTENT_LIBRARIES_NAMESPACE = "content_libraries" + VIEW_LIBRARY = PermissionData( - action=ActionData(external_key="view_library"), + action=ActionData(external_key=f"{CONTENT_LIBRARIES_NAMESPACE}.view_library"), effect="allow", ) MANAGE_LIBRARY_TAGS = PermissionData( - action=ActionData(external_key="manage_library_tags"), + action=ActionData(external_key=f"{CONTENT_LIBRARIES_NAMESPACE}.manage_library_tags"), effect="allow", ) DELETE_LIBRARY = PermissionData( - action=ActionData(external_key="delete_library"), + action=ActionData(external_key=f"{CONTENT_LIBRARIES_NAMESPACE}.delete_library"), effect="allow", ) EDIT_LIBRARY_CONTENT = PermissionData( - action=ActionData(external_key="edit_library_content"), + action=ActionData(external_key=f"{CONTENT_LIBRARIES_NAMESPACE}.edit_library_content"), effect="allow", ) PUBLISH_LIBRARY_CONTENT = PermissionData( - action=ActionData(external_key="publish_library_content"), + action=ActionData(external_key=f"{CONTENT_LIBRARIES_NAMESPACE}.publish_library_content"), effect="allow", ) REUSE_LIBRARY_CONTENT = PermissionData( - action=ActionData(external_key="reuse_library_content"), + action=ActionData(external_key=f"{CONTENT_LIBRARIES_NAMESPACE}.reuse_library_content"), effect="allow", ) VIEW_LIBRARY_TEAM = PermissionData( - action=ActionData(external_key="view_library_team"), + action=ActionData(external_key=f"{CONTENT_LIBRARIES_NAMESPACE}.view_library_team"), effect="allow", ) MANAGE_LIBRARY_TEAM = PermissionData( - action=ActionData(external_key="manage_library_team"), + action=ActionData(external_key=f"{CONTENT_LIBRARIES_NAMESPACE}.manage_library_team"), effect="allow", ) CREATE_LIBRARY_COLLECTION = PermissionData( - action=ActionData(external_key="create_library_collection"), + action=ActionData(external_key=f"{CONTENT_LIBRARIES_NAMESPACE}.create_library_collection"), effect="allow", ) EDIT_LIBRARY_COLLECTION = PermissionData( - action=ActionData(external_key="edit_library_collection"), + action=ActionData(external_key=f"{CONTENT_LIBRARIES_NAMESPACE}.edit_library_collection"), effect="allow", ) DELETE_LIBRARY_COLLECTION = PermissionData( - action=ActionData(external_key="delete_library_collection"), + action=ActionData(external_key=f"{CONTENT_LIBRARIES_NAMESPACE}.delete_library_collection"), effect="allow", ) diff --git a/openedx_authz/engine/config/authz.policy b/openedx_authz/engine/config/authz.policy index d167b388..810dbe4e 100644 --- a/openedx_authz/engine/config/authz.policy +++ b/openedx_authz/engine/config/authz.policy @@ -9,62 +9,62 @@ # For role definitions use: lib^*, course^*, org^* to specify the scope of the role # Library Admin Role Policies -p, role^library_admin, act^view_library, lib^*, allow -p, role^library_admin, act^manage_library_tags, lib^*, allow -p, role^library_admin, act^delete_library, lib^*, allow -p, role^library_admin, act^edit_library_content, lib^*, allow -p, role^library_admin, act^publish_library_content, lib^*, allow -p, role^library_admin, act^reuse_library_content, lib^*, allow -p, role^library_admin, act^view_library_team, lib^*, allow -p, role^library_admin, act^manage_library_team, lib^*, allow -p, role^library_admin, act^create_library_collection, lib^*, allow -p, role^library_admin, act^edit_library_collection, lib^*, allow -p, role^library_admin, act^delete_library_collection, lib^*, allow +p, role^library_admin, act^content_libraries.view_library, lib^*, allow +p, role^library_admin, act^content_libraries.manage_library_tags, lib^*, allow +p, role^library_admin, act^content_libraries.delete_library, lib^*, allow +p, role^library_admin, act^content_libraries.edit_library_content, lib^*, allow +p, role^library_admin, act^content_libraries.publish_library_content, lib^*, allow +p, role^library_admin, act^content_libraries.reuse_library_content, lib^*, allow +p, role^library_admin, act^content_libraries.view_library_team, lib^*, allow +p, role^library_admin, act^content_libraries.manage_library_team, lib^*, allow +p, role^library_admin, act^content_libraries.create_library_collection, lib^*, allow +p, role^library_admin, act^content_libraries.edit_library_collection, lib^*, allow +p, role^library_admin, act^content_libraries.delete_library_collection, lib^*, allow # Library Author Role Policies -p, role^library_author, act^view_library, lib^*, allow -p, role^library_author, act^manage_library_tags, lib^*, allow -p, role^library_author, act^edit_library_content, lib^*, allow -p, role^library_author, act^publish_library_content, lib^*, allow -p, role^library_author, act^reuse_library_content, lib^*, allow -p, role^library_author, act^view_library_team, 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 +p, role^library_author, act^content_libraries.view_library, lib^*, allow +p, role^library_author, act^content_libraries.manage_library_tags, lib^*, allow +p, role^library_author, act^content_libraries.edit_library_content, lib^*, allow +p, role^library_author, act^content_libraries.publish_library_content, lib^*, allow +p, role^library_author, act^content_libraries.reuse_library_content, lib^*, allow +p, role^library_author, act^content_libraries.view_library_team, lib^*, allow +p, role^library_author, act^content_libraries.create_library_collection, lib^*, allow +p, role^library_author, act^content_libraries.edit_library_collection, lib^*, allow +p, role^library_author, act^content_libraries.delete_library_collection, lib^*, allow # Library Contributor Role Policies -p, role^library_contributor, act^view_library, lib^*, allow -p, role^library_contributor, act^manage_library_tags, lib^*, allow -p, role^library_contributor, act^edit_library_content, lib^*, allow -p, role^library_contributor, act^reuse_library_content, lib^*, allow -p, role^library_contributor, act^view_library_team, lib^*, allow -p, role^library_contributor, act^create_library_collection, lib^*, allow -p, role^library_contributor, act^edit_library_collection, lib^*, allow -p, role^library_contributor, act^delete_library_collection, lib^*, allow +p, role^library_contributor, act^content_libraries.view_library, lib^*, allow +p, role^library_contributor, act^content_libraries.manage_library_tags, lib^*, allow +p, role^library_contributor, act^content_libraries.edit_library_content, lib^*, allow +p, role^library_contributor, act^content_libraries.reuse_library_content, lib^*, allow +p, role^library_contributor, act^content_libraries.view_library_team, lib^*, allow +p, role^library_contributor, act^content_libraries.create_library_collection, lib^*, allow +p, role^library_contributor, act^content_libraries.edit_library_collection, lib^*, allow +p, role^library_contributor, act^content_libraries.delete_library_collection, lib^*, allow # Library User Role Policies -p, role^library_user, act^view_library, lib^*, allow -p, role^library_user, act^reuse_library_content, lib^*, allow -p, role^library_user, act^view_library_team, lib^*, allow +p, role^library_user, act^content_libraries.view_library, lib^*, allow +p, role^library_user, act^content_libraries.reuse_library_content, lib^*, allow +p, role^library_user, act^content_libraries.view_library_team, 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 +# Example: g2, act^content_libraries.delete_library, act^content_libraries.view_library means delete permission includes view permission # Library -g2, act^manage_library_tags, act^edit_library_content -g2, act^delete_library, act^edit_library_content +g2, act^content_libraries.manage_library_tags, act^content_libraries.edit_library_content +g2, act^content_libraries.delete_library, act^content_libraries.edit_library_content # Content -g2, act^publish_library_content, act^edit_library_content -g2, act^edit_library_content, act^view_library -g2, act^reuse_library_content, act^view_library -g2, act^publish_library_content, act^view_library +g2, act^content_libraries.publish_library_content, act^content_libraries.edit_library_content +g2, act^content_libraries.edit_library_content, act^content_libraries.view_library +g2, act^content_libraries.reuse_library_content, act^content_libraries.view_library +g2, act^content_libraries.publish_library_content, act^content_libraries.view_library # Team -g2, act^manage_library_team, act^view_library_team +g2, act^content_libraries.manage_library_team, act^content_libraries.view_library_team # Collections -g2, act^delete_library_collection, act^edit_library_collection -g2, act^create_library_collection, act^edit_library_collection -g2, act^edit_library_collection, act^view_library +g2, act^content_libraries.delete_library_collection, act^content_libraries.edit_library_collection +g2, act^content_libraries.create_library_collection, act^content_libraries.edit_library_collection +g2, act^content_libraries.edit_library_collection, act^content_libraries.view_library diff --git a/openedx_authz/management/commands/enforcement.py b/openedx_authz/management/commands/enforcement.py index acbb392f..b3507a2d 100644 --- a/openedx_authz/management/commands/enforcement.py +++ b/openedx_authz/management/commands/enforcement.py @@ -23,10 +23,10 @@ python manage.py lms enforcement -m /path/to/model.conf -p /path/to/policies.csv Example test input: - >>> alice view_library_team lib:OpenedX:CSPROB - ✓ ALLOWED: alice view_library_team lib:OpenedX:CSPROB - >>> bob manage_library_team lib:DemoX:LIB1 - ✗ DENIED: bob manage_library_team lib:DemoX:LIB1 + >>> alice content_libraries.view_library_team lib:OpenedX:CSPROB + ✓ ALLOWED: alice content_libraries.view_library_team lib:OpenedX:CSPROB + >>> bob content_libraries.manage_library_team lib:DemoX:LIB1 + ✗ DENIED: bob content_libraries.manage_library_team lib:DemoX:LIB1 """ import argparse @@ -207,7 +207,7 @@ def _run_interactive_mode(self) -> 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: alice view_library_team lib:OpenedX:CSPROB") + self.stdout.write("Example: alice content_libraries.view_library_team lib:OpenedX:CSPROB") self.stdout.write("") while True: @@ -236,7 +236,7 @@ def _test_interactive_request(self, user_input: str) -> None: Expected format: subject: The requesting entity (e.g., 'alice') - action: The requested action (e.g., 'view_library_team') + action: The requested action (e.g., 'content_libraries.view_library_team') scope: The authorization context (e.g., 'lib:OpenedX:CSPROB') """ try: @@ -244,7 +244,7 @@ def _test_interactive_request(self, user_input: str) -> None: if len(parts) != 3: 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: alice view_library_team lib:OpenedX:CSPROB") + self.stdout.write("Example: alice content_libraries.view_library_team lib:OpenedX:CSPROB") return subject, action, scope = parts diff --git a/openedx_authz/rest_api/decorators.py b/openedx_authz/rest_api/decorators.py index 1aead964..b42c2d46 100644 --- a/openedx_authz/rest_api/decorators.py +++ b/openedx_authz/rest_api/decorators.py @@ -52,15 +52,16 @@ def authz_permissions(permissions: list[str]): by MethodPermissionMixin during authorization. Args: - permissions: List of permission identifiers (e.g., ["view_library_team", "manage_library_team"]) + permissions: List of permission identifiers + e.g., ["content_libraries.view_library_team", "content_libraries.manage_library_team"]) Examples: >>> class MyView(APIView): - ... @authz_permissions(["view_library_team"]) + ... @authz_permissions(["content_libraries.view_library_team"]) ... def get(self, request): ... pass ... - ... @authz_permissions(["manage_library_team"]) + ... @authz_permissions(["content_libraries.manage_library_team"]) ... def post(self, request): ... pass """ diff --git a/openedx_authz/rest_api/v1/permissions.py b/openedx_authz/rest_api/v1/permissions.py index 5193fb46..f681294d 100644 --- a/openedx_authz/rest_api/v1/permissions.py +++ b/openedx_authz/rest_api/v1/permissions.py @@ -221,7 +221,7 @@ class MethodPermissionMixin: >>> class MyView(APIView): ... permission_classes = [MyPermission] ... - ... @authz_permissions(["view_library_team"]) + ... @authz_permissions(["content_libraries.view_library_team"]) ... def get(self, request): ... pass """ diff --git a/openedx_authz/rest_api/v1/views.py b/openedx_authz/rest_api/v1/views.py index a79c790d..a95d73fd 100644 --- a/openedx_authz/rest_api/v1/views.py +++ b/openedx_authz/rest_api/v1/views.py @@ -61,7 +61,7 @@ class PermissionValidationMeView(APIView): Expects a list of permission objects, each containing: - - action: The action to validate (e.g., 'edit_library', 'delete_library_content') + - action: The action to validate (e.g., 'content_libraries.edit_library_content') - scope: The authorization scope (e.g., 'lib:DemoX:CSPROB') **Response Format** @@ -379,7 +379,7 @@ class RoleListView(APIView): Returns a paginated list of role objects, each containing: - role: The role's external identifier (e.g., 'library_author', 'library_user') - - permissions: List of permission action keys granted by this role (e.g., 'delete_library_content') + - permissions: List of permission identifiers granted by this role (e.g., 'content_libraries.delete_library') - user_count: Number of users currently assigned to this role **Authentication and Permissions** diff --git a/openedx_authz/tests/api/test_data.py b/openedx_authz/tests/api/test_data.py index 7f44e75e..ecff44e8 100644 --- a/openedx_authz/tests/api/test_data.py +++ b/openedx_authz/tests/api/test_data.py @@ -394,7 +394,11 @@ def test_user_data_str_and_repr(self, external_key, expected_str, expected_repr) @data( ("read", "Read", "act^read"), ("write", "Write", "act^write"), - (permissions.DELETE_LIBRARY.identifier, "Delete Library", "act^delete_library"), + ( + permissions.DELETE_LIBRARY.identifier, + "Content Libraries.Delete Library", + "act^content_libraries.delete_library", + ), ("edit_content", "Edit Content", "act^edit_content"), ) @unpack @@ -478,8 +482,8 @@ def test_role_data_str_with_permissions(self): ( permissions.DELETE_LIBRARY.identifier, "allow", - "Delete Library - allow", - "act^delete_library => allow", + "Content Libraries.Delete Library - allow", + "act^content_libraries.delete_library => allow", ), ) @unpack diff --git a/openedx_authz/tests/api/test_roles.py b/openedx_authz/tests/api/test_roles.py index 172fa7ec..431ca9ab 100644 --- a/openedx_authz/tests/api/test_roles.py +++ b/openedx_authz/tests/api/test_roles.py @@ -5,10 +5,10 @@ roles and permissions within specific scopes. """ +from importlib.resources import files from unittest.mock import patch import casbin -import pkg_resources from ddt import data as ddt_data from ddt import ddt, unpack from django.test import TestCase @@ -36,7 +36,7 @@ get_subjects_for_role_in_scope, unassign_role_from_subject_in_scope, ) -from openedx_authz.constants import roles +from openedx_authz.constants import permissions, roles from openedx_authz.constants.roles import ( LIBRARY_ADMIN_PERMISSIONS, LIBRARY_AUTHOR_PERMISSIONS, @@ -90,8 +90,8 @@ def _seed_database_with_policies(cls): """ global_enforcer = AuthzEnforcer.get_enforcer() global_enforcer.load_policy() - model_path = pkg_resources.resource_filename("openedx_authz.engine", "config/model.conf") - policy_path = pkg_resources.resource_filename("openedx_authz.engine", "config/authz.policy") + model_path = str(files("openedx_authz.engine").joinpath("config/model.conf")) + policy_path = str(files("openedx_authz.engine").joinpath("config/authz.policy")) migrate_policy_between_enforcers( source_enforcer=casbin.Enforcer(model_path, policy_path), @@ -593,73 +593,73 @@ def test_get_role_assignments_in_scope(self, role_name, scope_name, expected_cou # Test case: alice with 'view_library' permission (has library_admin in math_101) ( "alice", - "view_library", + permissions.VIEW_LIBRARY.identifier, ["lib:Org1:math_101"], ), # Test case: alice with 'publish_library_content' permission (admin grants publish) ( "alice", - "publish_library_content", + permissions.PUBLISH_LIBRARY_CONTENT.identifier, ["lib:Org1:math_101"], ), # Test case: alice with 'delete_library' permission (admin grants delete) ( "alice", - "delete_library", + permissions.DELETE_LIBRARY.identifier, ["lib:Org1:math_101"], ), # Test case: bob with 'view_library' permission (has library_author in history_201) ( "bob", - "view_library", + permissions.VIEW_LIBRARY.identifier, ["lib:Org1:history_201"], ), # Test case: bob with 'publish_library_content' permission (author grants publish) ( "bob", - "publish_library_content", + permissions.PUBLISH_LIBRARY_CONTENT.identifier, ["lib:Org1:history_201"], ), # Test case: bob with 'delete_library' permission (author does NOT grant delete) ( "bob", - "delete_library", + permissions.DELETE_LIBRARY.identifier, [], ), # Test case: carol with 'view_library' permission (has library_contributor in science_301) ( "carol", - "view_library", + permissions.VIEW_LIBRARY.identifier, ["lib:Org1:science_301"], ), # Test case: carol with 'publish_library_content' permission (contributor does NOT grant publish) ( "carol", - "publish_library_content", + permissions.PUBLISH_LIBRARY_CONTENT.identifier, [], ), # Test case: dave with 'view_library' permission (has library_user in english_101) ( "dave", - "view_library", + permissions.VIEW_LIBRARY.identifier, ["lib:Org1:english_101"], ), # Test case: dave with 'publish_library_content' permission (user does NOT grant publish) ( "dave", - "publish_library_content", + permissions.PUBLISH_LIBRARY_CONTENT.identifier, [], ), # Test case: liam with 'view_library' permission (has library_author in 3 art libraries) ( "liam", - "view_library", + permissions.VIEW_LIBRARY.identifier, ["lib:Org4:art_101", "lib:Org4:art_201", "lib:Org4:art_301"], ), # Test case: non-existent user ( "nonexistent", - "view_library", + permissions.VIEW_LIBRARY.identifier, [], ), ) @@ -718,7 +718,7 @@ def test_get_scopes_for_subject_and_permission_no_duplicates(self): ) subject = SubjectData(external_key=test_subject) - permission = PermissionData(action=ActionData(external_key="view_library")) + permission = PermissionData(action=ActionData(external_key=permissions.VIEW_LIBRARY.identifier)) scopes = get_scopes_for_subject_and_permission(subject, permission) scope_external_keys = [scope.external_key for scope in scopes] @@ -968,7 +968,7 @@ def test_assign_role_creates_extended_casbin_rule(self): new_count = ExtendedCasbinRule.objects.count() self.assertEqual(new_count, initial_count + 1) - extended_rule = ExtendedCasbinRule.objects.order_by('-id').first() + extended_rule = ExtendedCasbinRule.objects.order_by("-id").first() self.assertIsNotNone(extended_rule) self.assertIsNotNone(extended_rule.casbin_rule) self.assertIsNotNone(extended_rule.subject) diff --git a/openedx_authz/tests/rest_api/test_views.py b/openedx_authz/tests/rest_api/test_views.py index 71018f0e..87dde6b7 100644 --- a/openedx_authz/tests/rest_api/test_views.py +++ b/openedx_authz/tests/rest_api/test_views.py @@ -172,13 +172,13 @@ def setUp(self): # Single permission - denied (scope not assigned to user) ([{"action": permissions.VIEW_LIBRARY.identifier, "scope": "lib:Org2:LIB2"}], [False]), # Single permission - denied (action not assigned to user) - ([{"action": "edit_library", "scope": "lib:Org1:LIB1"}], [False]), + ([{"action": "content_libraries.edit_library", "scope": "lib:Org1:LIB1"}], [False]), # Multiple permissions - mixed results ( [ {"action": permissions.VIEW_LIBRARY.identifier, "scope": "lib:Org1:LIB1"}, {"action": permissions.VIEW_LIBRARY.identifier, "scope": "lib:Org2:LIB2"}, - {"action": "edit_library", "scope": "lib:Org1:LIB1"}, + {"action": "content_libraries.edit_library", "scope": "lib:Org1:LIB1"}, ], [True, False, False], ), diff --git a/openedx_authz/tests/test_commands.py b/openedx_authz/tests/test_commands.py index 7d444867..8d270571 100644 --- a/openedx_authz/tests/test_commands.py +++ b/openedx_authz/tests/test_commands.py @@ -13,9 +13,28 @@ from openedx_authz import ROOT_DIRECTORY from openedx_authz import api as authz_api -from openedx_authz.constants import permissions +from openedx_authz.api.data import ContentLibraryData +from openedx_authz.constants.permissions import ( + DELETE_LIBRARY, + MANAGE_LIBRARY_TEAM, + PUBLISH_LIBRARY_CONTENT, + VIEW_LIBRARY, +) +from openedx_authz.constants.roles import LIBRARY_ADMIN from openedx_authz.engine.enforcer import AuthzEnforcer from openedx_authz.management.commands.load_policies import Command as LoadPoliciesCommand +from openedx_authz.tests.test_utils import ( + make_action_key, + make_library_key, + make_role_key, + make_user_key, + make_wildcard_key, +) + +lib_id_1 = "lib:Org1:LIB1" +lib_id_2 = "lib:Org1:LIB2" +lib_key_namespaced_1 = make_library_key(lib_id_1) +lib_key_namespaced_2 = make_library_key(lib_id_2) @ddt @@ -39,16 +58,40 @@ def setUp(self): self.policy_file_path = NamedTemporaryFile(suffix=".policy") self.model_file_path = NamedTemporaryFile(suffix=".conf") + self.username = "alice" + self.policies = [ - ["role^library_admin", "act^delete_library", "lib^*", "allow"], - ["role^library_admin", "act^publish_library", "lib^*", "allow"], - ["role^library_admin", "act^manage_library_team", "lib^*", "allow"], + [ + make_role_key(LIBRARY_ADMIN.external_key), + make_action_key(DELETE_LIBRARY.identifier), + make_wildcard_key(ContentLibraryData.NAMESPACE), + "allow", + ], + [ + make_role_key(LIBRARY_ADMIN.external_key), + make_action_key(PUBLISH_LIBRARY_CONTENT.identifier), + make_wildcard_key(ContentLibraryData.NAMESPACE), + "allow", + ], + [ + make_role_key(LIBRARY_ADMIN.external_key), + make_action_key(MANAGE_LIBRARY_TEAM.identifier), + make_wildcard_key(ContentLibraryData.NAMESPACE), + "allow", + ], ] self.roles = [ - ["user^alice", "role^library_admin", "lib^*"], + [ + make_user_key(self.username), + make_role_key(LIBRARY_ADMIN.external_key), + make_wildcard_key(ContentLibraryData.NAMESPACE), + ] ] self.action_grouping = [ - ["act^delete_library", "act^view_library"], + [ + make_action_key(DELETE_LIBRARY.identifier), + make_action_key(VIEW_LIBRARY.identifier), + ] ] self.enforcer = Mock() @@ -136,12 +179,14 @@ def test_interactive_mode_allowed_request(self, mock_is_allowed: Mock, mock_get_ mock_get_enforcer.return_value = self.enforcer mock_is_allowed.return_value = True - with patch("builtins.input", side_effect=["alice view_library lib:Org1:LIB1", "quit"]): + with patch( + "builtins.input", side_effect=[f"{self.username} {VIEW_LIBRARY.identifier} {lib_key_namespaced_1}", "quit"] + ): call_command(self.command_name, stdout=self.buffer) output = self.buffer.getvalue() - self.assertIn("✓ ALLOWED: alice view_library lib:Org1:LIB1", output) - mock_is_allowed.assert_called_once_with("alice", permissions.VIEW_LIBRARY.identifier, "lib:Org1:LIB1") + self.assertIn(f"✓ ALLOWED: {self.username} {VIEW_LIBRARY.identifier} {lib_key_namespaced_1}", output) + mock_is_allowed.assert_called_once_with(self.username, VIEW_LIBRARY.identifier, lib_key_namespaced_1) @patch.object(AuthzEnforcer, "get_enforcer") @patch.object(authz_api, "is_user_allowed") @@ -149,20 +194,23 @@ def test_interactive_mode_denied_request(self, mock_is_allowed: Mock, mock_get_e """Test interactive mode with a denied enforcement request.""" mock_get_enforcer.return_value = self.enforcer mock_is_allowed.return_value = False + username = "bob" - with patch("builtins.input", side_effect=["bob delete_library lib:Org2:LIB2", "quit"]): + with patch( + "builtins.input", side_effect=[f"{username} {DELETE_LIBRARY.identifier} {lib_key_namespaced_2}", "quit"] + ): call_command(self.command_name, stdout=self.buffer) output = self.buffer.getvalue() - self.assertIn("✗ DENIED: bob delete_library lib:Org2:LIB2", output) - mock_is_allowed.assert_called_once_with("bob", permissions.DELETE_LIBRARY.identifier, "lib:Org2:LIB2") + self.assertIn(f"✗ DENIED: {username} {DELETE_LIBRARY.identifier} {lib_key_namespaced_2}", output) + mock_is_allowed.assert_called_once_with(username, DELETE_LIBRARY.identifier, lib_key_namespaced_2) @patch("openedx_authz.management.commands.enforcement.Enforcer") def test_interactive_mode_file_mode_enforcement(self, mock_enforcer_class: Mock): """Test that file mode uses custom enforcer for enforcement checks.""" mock_enforcer_class.return_value = self.enforcer - with patch("builtins.input", side_effect=["alice view_library lib:Org1:LIB1", "quit"]): + with patch("builtins.input", side_effect=[f"{self.username} {VIEW_LIBRARY.identifier} {lib_id_1}", "quit"]): call_command( self.command_name, policy_file_path=self.policy_file_path.name, @@ -171,14 +219,16 @@ def test_interactive_mode_file_mode_enforcement(self, mock_enforcer_class: Mock) ) output = self.buffer.getvalue() - self.assertIn("✓ ALLOWED: alice view_library lib:Org1:LIB1", output) - self.enforcer.enforce.assert_called_once_with("user^alice", "act^view_library", "lib^lib:Org1:LIB1") + self.assertIn(f"✓ ALLOWED: {self.username} {VIEW_LIBRARY.identifier} {lib_id_1}", output) + self.enforcer.enforce.assert_called_once_with( + make_user_key(self.username), make_action_key(VIEW_LIBRARY.identifier), lib_key_namespaced_1 + ) @data( "alice", - "alice view_library", - "alice view_library lib:Org1:LIB1 lib:Org1:LIB1", - "alice view_library lib:Org1:LIB1 lib:Org1:LIB1 lib:Org1:LIB1", + f"alice {VIEW_LIBRARY.identifier}", + f"alice {VIEW_LIBRARY.identifier} {lib_id_1} {lib_id_1}", + f"alice {VIEW_LIBRARY.identifier} {lib_id_1} {lib_id_1} {lib_id_1}", ) @patch.object(AuthzEnforcer, "get_enforcer") def test_interactive_mode_invalid_format(self, user_input: str, mock_get_enforcer: Mock): @@ -263,7 +313,9 @@ def test_interactive_request_error(self, exception: Exception, mock_is_allowed: mock_get_enforcer.return_value = self.enforcer mock_is_allowed.side_effect = exception - with patch("builtins.input", side_effect=["alice view_library lib:Org1:LIB1", "quit"]): + with patch( + "builtins.input", side_effect=[f"{self.username} {VIEW_LIBRARY.identifier} {lib_key_namespaced_1}", "quit"] + ): call_command(self.command_name, stdout=self.buffer) output = self.buffer.getvalue() diff --git a/openedx_authz/tests/test_enforcement.py b/openedx_authz/tests/test_enforcement.py index 13adfe57..9afbb415 100644 --- a/openedx_authz/tests/test_enforcement.py +++ b/openedx_authz/tests/test_enforcement.py @@ -607,40 +607,40 @@ def setUp(self) -> None: # Staff user has automatic access to any library scope ( make_user_key("staff_user"), - make_action_key("view_library"), + make_action_key("content_libraries.view_library"), make_library_key("lib:TestOrg:TestLib"), True, ), ( make_user_key("staff_user"), - make_action_key("edit_library"), + make_action_key("content_libraries.edit_library"), make_library_key("lib:AnyOrg:AnyLib"), True, ), # Superuser has automatic access to any library scope ( make_user_key("superuser"), - make_action_key("view_library"), + make_action_key("content_libraries.view_library"), make_library_key("lib:TestOrg:TestLib"), True, ), ( make_user_key("superuser"), - make_action_key("delete_library"), + make_action_key("content_libraries.delete_library"), make_library_key("lib:AnyOrg:AnyLib"), True, ), # Regular user without role assignment has no access ( make_user_key("regular_user"), - make_action_key("view_library"), + make_action_key("content_libraries.view_library"), make_library_key("lib:TestOrg:TestLib"), False, ), # Non existent library scope access denied ( make_user_key("regular_user"), - make_action_key("view_library"), + make_action_key("content_libraries.view_library"), make_library_key("lib:NonExistent:NoLib"), False, ), diff --git a/openedx_authz/tests/test_utils.py b/openedx_authz/tests/test_utils.py index 1efbb970..3b7a4713 100644 --- a/openedx_authz/tests/test_utils.py +++ b/openedx_authz/tests/test_utils.py @@ -1,6 +1,6 @@ """Test utilities for creating namespaced keys using class constants.""" -from openedx_authz.api.data import ActionData, ContentLibraryData, RoleData, ScopeData, UserData +from openedx_authz.api.data import GLOBAL_SCOPE_WILDCARD, ActionData, ContentLibraryData, RoleData, ScopeData, UserData def make_user_key(key: str) -> str: @@ -62,3 +62,15 @@ def make_scope_key(namespace: str, key: str) -> str: str: Namespaced scope key (e.g., 'org^any-org') """ return f"{namespace}{ScopeData.SEPARATOR}{key}" + + +def make_wildcard_key(namespace: str) -> str: + """Create a wildcard pattern for a given namespace. + + Args: + namespace: The namespace to create a wildcard for (e.g., 'lib', 'org', 'course') + + Returns: + str: Wildcard pattern (e.g., 'lib^*', 'org^*', 'course^*') + """ + return f"{namespace}{ScopeData.SEPARATOR}*" From e84d2db46c39707c9bf4133c85c9fc49663af76c Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 12 Nov 2025 19:30:28 -0500 Subject: [PATCH 2/5] refactor: update wildcard key generation to use GLOBAL_SCOPE_WILDCARD --- openedx_authz/tests/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openedx_authz/tests/test_utils.py b/openedx_authz/tests/test_utils.py index 3b7a4713..b6b04ad1 100644 --- a/openedx_authz/tests/test_utils.py +++ b/openedx_authz/tests/test_utils.py @@ -73,4 +73,4 @@ def make_wildcard_key(namespace: str) -> str: Returns: str: Wildcard pattern (e.g., 'lib^*', 'org^*', 'course^*') """ - return f"{namespace}{ScopeData.SEPARATOR}*" + return f"{namespace}{ScopeData.SEPARATOR}{GLOBAL_SCOPE_WILDCARD}" From 520a3ea7aebe4f41d6dbb97ad9f31995453cc72a Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Thu, 13 Nov 2025 11:30:46 -0500 Subject: [PATCH 3/5] chore: bump version to 0.16.0 --- CHANGELOG.rst | 13 +++++++++++++ openedx_authz/__init__.py | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index cc73601a..c1a71af4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,19 @@ Change Log Unreleased ********** +* + +0.16.0 - 2025-11-13 +******************** + +Changed +======= + +* Update permission format to include app namespace prefix. + +Added +===== + * Register ``CasbinRule`` model in the Django admin. * Register ``ExtendedCasbinRule`` model in the Django admin as an inline model of ``CasbinRule``. diff --git a/openedx_authz/__init__.py b/openedx_authz/__init__.py index 2b9ed8f0..5c417147 100644 --- a/openedx_authz/__init__.py +++ b/openedx_authz/__init__.py @@ -4,6 +4,6 @@ import os -__version__ = "0.15.0" +__version__ = "0.16.0" ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__)) From 108378f9b9f14ab1f064b07e39cb1ded4bdc9aaf Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Thu, 13 Nov 2025 17:52:23 -0500 Subject: [PATCH 4/5] refactor: update action name formatting to use ' > ' instead of '.' --- openedx_authz/api/data.py | 13 +++++++------ openedx_authz/tests/api/test_data.py | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/openedx_authz/api/data.py b/openedx_authz/api/data.py index 7a7222c6..3eb6e6c5 100644 --- a/openedx_authz/api/data.py +++ b/openedx_authz/api/data.py @@ -602,29 +602,30 @@ class ActionData(AuthZData): NAMESPACE: 'act' for actions. external_key: The action identifier (e.g., 'content_libraries.view_library'). namespaced_key: The action identifier with namespace (e.g., 'act^content_libraries.view_library'). - name: Property that returns a human-readable action name (e.g., 'Content Libraries.View Library'). + name: Property that returns a human-readable action name (e.g., 'Content Libraries > View Library'). Examples: >>> action = ActionData(external_key='content_libraries.delete_library') >>> action.namespaced_key 'act^content_libraries.delete_library' >>> action.name - 'Content Libraries.Delete Library' + 'Content Libraries > Delete Library' """ NAMESPACE: ClassVar[str] = "act" @property def name(self) -> str: - """The human-readable name of the action (e.g., 'Content Libraries.Delete Library'). + """The human-readable name of the action (e.g., 'Content Libraries > Delete Library'). This property transforms the external_key into a human-readable display name - by replacing underscores with spaces and capitalizing each word. + by replacing dots with ' > ' and capitalizing each word. Returns: - str: The human-readable action name (e.g., 'Content Libraries.Delete Library'). + str: The human-readable action name (e.g., 'Content Libraries > Delete Library'). """ - return self.external_key.replace("_", " ").title() + parts = self.external_key.split(".") + return " > ".join(part.replace("_", " ").title() for part in parts) def __str__(self): """Human readable string representation of the action.""" diff --git a/openedx_authz/tests/api/test_data.py b/openedx_authz/tests/api/test_data.py index ecff44e8..a1ac6227 100644 --- a/openedx_authz/tests/api/test_data.py +++ b/openedx_authz/tests/api/test_data.py @@ -396,7 +396,7 @@ def test_user_data_str_and_repr(self, external_key, expected_str, expected_repr) ("write", "Write", "act^write"), ( permissions.DELETE_LIBRARY.identifier, - "Content Libraries.Delete Library", + "Content Libraries > Delete Library", "act^content_libraries.delete_library", ), ("edit_content", "Edit Content", "act^edit_content"), @@ -482,7 +482,7 @@ def test_role_data_str_with_permissions(self): ( permissions.DELETE_LIBRARY.identifier, "allow", - "Content Libraries.Delete Library - allow", + "Content Libraries > Delete Library - allow", "act^content_libraries.delete_library => allow", ), ) From 9fe8f8a0e6e6b9f6065c5f9ca6d5f2b62844bbf9 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 14 Nov 2025 08:55:16 -0500 Subject: [PATCH 5/5] chore: add breaking change in changelog --- CHANGELOG.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c1a71af4..3d2045ad 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -22,7 +22,7 @@ Unreleased Changed ======= -* Update permission format to include app namespace prefix. +* **BREAKING**: Update permission format to include app namespace prefix. Added =====