Skip to content

Commit 90598e7

Browse files
[FC-0099] refactor: get permissions' scopes instead of role (#123)
1 parent e74ea6f commit 90598e7

7 files changed

Lines changed: 173 additions & 35 deletions

File tree

CHANGELOG.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,19 @@ Unreleased
1616

1717
*
1818

19+
0.11.1 - 2025-10-29
20+
********************
21+
22+
Changed
23+
=======
24+
25+
* Refactor to get permissions' scopes instead of role.
26+
27+
Fixed
28+
=====
29+
30+
* Use correct content library toggle to check if Content Library V2 is enabled.
31+
1932
0.11.0 - 2025-10-29
2033
********************
2134

openedx_authz/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@
44

55
import os
66

7-
__version__ = "0.11.0"
7+
__version__ = "0.11.1"
88

99
ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))

openedx_authz/api/data.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,30 @@ def identifier(self) -> str:
656656
"""
657657
return self.action.external_key
658658

659+
def __eq__(self, other: "PermissionData") -> bool:
660+
"""Compare permissions based on their action identifier.
661+
662+
Two PermissionData instances are considered equal if they have the same action's
663+
external_key and effect.
664+
665+
Args:
666+
other: Another PermissionData instance or any object.
667+
668+
Returns:
669+
bool: True if the actions match, False otherwise.
670+
671+
Example:
672+
>>> perm1 = PermissionData(action=ActionData(external_key='view'), effect='allow')
673+
>>> perm2 = PermissionData(action=ActionData(external_key='view'), effect='allow')
674+
>>> perm1 == perm2 # True - same action and effect
675+
True
676+
>>> perm1 in [perm2] # Uses __eq__
677+
True
678+
"""
679+
if self.action is None or other.action is None:
680+
return False
681+
return self.action.external_key == other.action.external_key and self.effect == other.effect
682+
659683
def __str__(self):
660684
"""Human readable string representation of the permission and its effect."""
661685
return f"{self.action} - {self.effect}"

openedx_authz/api/roles.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"get_subject_role_assignments_for_role_in_scope",
3838
"get_all_subject_role_assignments_in_scope",
3939
"get_subject_role_assignments",
40-
"get_scopes_for_role_and_subject",
40+
"get_scopes_for_subject_and_permission",
4141
]
4242

4343
# TODO: these are the concerns we still have to address:
@@ -381,20 +381,23 @@ def get_subjects_for_role_in_scope(role: RoleData, scope: ScopeData) -> list[Sub
381381
]
382382

383383

384-
def get_scopes_for_role_and_subject(role: RoleData, subject: SubjectData) -> list[ScopeData]:
385-
"""Get all the scopes where a specific subject is assigned a specific role.
384+
def get_scopes_for_subject_and_permission(
385+
subject: SubjectData,
386+
permission: PermissionData,
387+
) -> list[ScopeData]:
388+
"""Get all scopes where a specific subject has been assigned a specific permission via roles.
386389
387390
Args:
388-
role (RoleData): The role to filter scopes.
391+
permission (PermissionData): The permission to filter scopes.
389392
subject (SubjectData): The subject to filter scopes.
390393
391394
Returns:
392-
list[ScopeData]: A list of scopes where the subject is assigned the specified role.
395+
list[ScopeData]: A list of scopes where the subject is assigned the specified permission.
393396
"""
394-
enforcer = AuthzEnforcer.get_enforcer()
395-
policies = enforcer.get_filtered_grouping_policy(GroupingPolicyIndex.SUBJECT.value, subject.namespaced_key)
396-
return [
397-
ScopeData(namespaced_key=policy[GroupingPolicyIndex.SCOPE.value])
398-
for policy in policies
399-
if policy[GroupingPolicyIndex.ROLE.value] == role.namespaced_key
400-
]
397+
roles_for_subject = get_subject_role_assignments(subject)
398+
scopes = []
399+
for role_assignment in roles_for_subject:
400+
for role in role_assignment.roles:
401+
if permission in role.permissions:
402+
scopes.append(role_assignment.scope)
403+
return scopes

openedx_authz/api/users.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@
99
(e.g., 'user^john_doe').
1010
"""
1111

12-
from openedx_authz.api.data import ActionData, RoleAssignmentData, RoleData, ScopeData, UserData
12+
from openedx_authz.api.data import ActionData, PermissionData, RoleAssignmentData, RoleData, ScopeData, UserData
1313
from openedx_authz.api.permissions import is_subject_allowed
1414
from openedx_authz.api.roles import (
1515
assign_role_to_subject_in_scope,
1616
batch_assign_role_to_subjects_in_scope,
1717
batch_unassign_role_from_subjects_in_scope,
1818
get_all_subject_role_assignments_in_scope,
19-
get_scopes_for_role_and_subject,
19+
get_scopes_for_subject_and_permission,
2020
get_subject_role_assignments,
2121
get_subject_role_assignments_for_role_in_scope,
2222
get_subject_role_assignments_in_scope,
@@ -34,6 +34,7 @@
3434
"get_user_role_assignments_for_role_in_scope",
3535
"get_all_user_role_assignments_in_scope",
3636
"is_user_allowed",
37+
"get_scopes_for_user_and_permission",
3738
"get_users_for_role_in_scope",
3839
]
3940

@@ -205,17 +206,20 @@ def get_users_for_role_in_scope(role_external_key: str, scope_external_key: str)
205206
return [UserData(namespaced_key=user.namespaced_key) for user in users]
206207

207208

208-
def get_scopes_for_role_and_user(role_external_key: str, user_external_key: str) -> list[ScopeData]:
209-
"""Get all scopes where a specific user has been assigned a specific role.
209+
def get_scopes_for_user_and_permission(
210+
user_external_key: str,
211+
action_external_key: str,
212+
) -> list[ScopeData]:
213+
"""Get all scopes where a specific user is assigned a specific permission.
210214
211215
Args:
212-
role_external_key (str): The role to filter scopes (e.g., 'instructor').
213216
user_external_key (str): ID of the user (e.g., 'john_doe').
217+
action_external_key (str): The action to filter scopes (e.g., 'view', 'edit').
214218
215219
Returns:
216-
list[ScopeData]: A list of scopes where the user has the specified role.
220+
list[ScopeData]: A list of scopes where the user is assigned the specified permission.
217221
"""
218-
return get_scopes_for_role_and_subject(
219-
RoleData(external_key=role_external_key),
222+
return get_scopes_for_subject_and_permission(
220223
UserData(external_key=user_external_key),
224+
PermissionData(action=ActionData(external_key=action_external_key)),
221225
)

openedx_authz/constants/permissions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""
22
Default permission constants.
33
"""
4+
45
from openedx_authz.api.data import ActionData, PermissionData
56

67
# Content Library Permissions

openedx_authz/tests/api/test_roles.py

Lines changed: 107 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,23 @@
1111
from ddt import ddt, unpack
1212
from django.test import TestCase
1313

14-
from openedx_authz.api.data import ContentLibraryData, RoleAssignmentData, RoleData, ScopeData, SubjectData
14+
from openedx_authz.api.data import (
15+
ActionData,
16+
ContentLibraryData,
17+
PermissionData,
18+
RoleAssignmentData,
19+
RoleData,
20+
ScopeData,
21+
SubjectData,
22+
)
1523
from openedx_authz.api.roles import (
1624
assign_role_to_subject_in_scope,
1725
batch_assign_role_to_subjects_in_scope,
1826
get_all_subject_role_assignments_in_scope,
1927
get_permissions_for_active_roles_in_scope,
2028
get_permissions_for_single_role,
2129
get_role_definitions_in_scope,
22-
get_scopes_for_role_and_subject,
30+
get_scopes_for_subject_and_permission,
2331
get_subject_role_assignments,
2432
get_subject_role_assignments_for_role_in_scope,
2533
get_subject_role_assignments_in_scope,
@@ -509,23 +517,108 @@ def test_get_role_assignments_in_scope(self, role_name, scope_name, expected_cou
509517

510518
self.assertEqual(len(role_assignments), expected_count)
511519

512-
def test_get_scopes_for_role_and_subject(self):
513-
"""Test retrieving scopes for a given role and subject.
520+
@ddt_data(
521+
# Test case: alice with 'view_library' permission (has library_admin in math_101)
522+
(
523+
"alice",
524+
"view_library",
525+
["lib:Org1:math_101"],
526+
),
527+
# Test case: alice with 'publish_library_content' permission (admin grants publish)
528+
(
529+
"alice",
530+
"publish_library_content",
531+
["lib:Org1:math_101"],
532+
),
533+
# Test case: alice with 'delete_library' permission (admin grants delete)
534+
(
535+
"alice",
536+
"delete_library",
537+
["lib:Org1:math_101"],
538+
),
539+
# Test case: bob with 'view_library' permission (has library_author in history_201)
540+
(
541+
"bob",
542+
"view_library",
543+
["lib:Org1:history_201"],
544+
),
545+
# Test case: bob with 'publish_library_content' permission (author grants publish)
546+
(
547+
"bob",
548+
"publish_library_content",
549+
["lib:Org1:history_201"],
550+
),
551+
# Test case: bob with 'delete_library' permission (author does NOT grant delete)
552+
(
553+
"bob",
554+
"delete_library",
555+
[],
556+
),
557+
# Test case: carol with 'view_library' permission (has library_contributor in science_301)
558+
(
559+
"carol",
560+
"view_library",
561+
["lib:Org1:science_301"],
562+
),
563+
# Test case: carol with 'publish_library_content' permission (contributor does NOT grant publish)
564+
(
565+
"carol",
566+
"publish_library_content",
567+
[],
568+
),
569+
# Test case: dave with 'view_library' permission (has library_user in english_101)
570+
(
571+
"dave",
572+
"view_library",
573+
["lib:Org1:english_101"],
574+
),
575+
# Test case: dave with 'publish_library_content' permission (user does NOT grant publish)
576+
(
577+
"dave",
578+
"publish_library_content",
579+
[],
580+
),
581+
# Test case: liam with 'view_library' permission (has library_author in 3 art libraries)
582+
(
583+
"liam",
584+
"view_library",
585+
["lib:Org4:art_101", "lib:Org4:art_201", "lib:Org4:art_301"],
586+
),
587+
# Test case: non-existent user
588+
(
589+
"nonexistent",
590+
"view_library",
591+
[],
592+
),
593+
)
594+
@unpack
595+
def test_get_scopes_for_subject_and_permission(self, subject_name, action_name, expected_scope_names):
596+
"""Test retrieving scopes where a subject has a specific permission.
597+
598+
This tests the get_scopes_for_subject_and_permission function which
599+
returns all scopes where a subject has been granted a specific permission
600+
through their role assignments.
601+
602+
Args:
603+
subject_name: The external key of the subject (e.g., 'alice')
604+
action_name: The action to check (e.g., 'view', 'edit', 'delete')
605+
expected_scope_names: List of expected scope external keys
514606
515607
Expected result:
516-
- The scopes associated with the specified role and subject are correctly retrieved.
608+
- Returns all scopes where the subject has roles that grant the permission
609+
- Returns empty list if subject has no roles with that permission
517610
"""
518-
role_name = roles.LIBRARY_AUTHOR.external_key
519-
subject_name = "liam"
520-
expected_scopes = {"lib:Org4:art_101", "lib:Org4:art_201", "lib:Org4:art_301"}
611+
subject = SubjectData(external_key=subject_name)
612+
permission = PermissionData(action=ActionData(external_key=action_name))
521613

522-
scopes = get_scopes_for_role_and_subject(
523-
RoleData(external_key=role_name),
524-
SubjectData(external_key=subject_name),
525-
)
614+
scopes = get_scopes_for_subject_and_permission(subject, permission)
615+
616+
# Extract scope external keys for comparison
617+
actual_scope_names = [scope.external_key for scope in scopes]
526618

527-
scope_names = {scope.external_key for scope in scopes}
528-
self.assertEqual(scope_names, expected_scopes)
619+
self.assertEqual(len(actual_scope_names), len(expected_scope_names))
620+
for expected_scope in expected_scope_names:
621+
self.assertIn(expected_scope, actual_scope_names)
529622

530623
@ddt_data(
531624
(roles.LIBRARY_AUTHOR.external_key, "lib:Org4:art_101", {"liam"}),

0 commit comments

Comments
 (0)