Skip to content

Commit 834dbbe

Browse files
refactor: get permissions' scopes instead of role
1 parent dff3863 commit 834dbbe

4 files changed

Lines changed: 170 additions & 34 deletions

File tree

openedx_authz/api/data.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,15 @@ def library_id(self) -> str:
366366
"""
367367
return self.external_key
368368

369+
@property
370+
def library_key(self) -> LibraryLocatorV2:
371+
"""The LibraryLocatorV2 object for the content library.
372+
373+
Returns:
374+
LibraryLocatorV2: The library locator object.
375+
"""
376+
return LibraryLocatorV2.from_string(self.library_id)
377+
369378
@classmethod
370379
def validate_external_key(cls, external_key: str) -> bool:
371380
"""Validate the external_key format for ContentLibraryData.
@@ -611,6 +620,33 @@ def identifier(self) -> str:
611620
"""
612621
return self.action.external_key
613622

623+
def __eq__(self, other):
624+
"""Compare permissions based on their action identifier.
625+
626+
Two permissions are considered equal if they have the same action,
627+
regardless of the effect. This allows checking if a role grants a
628+
specific action using the 'in' operator.
629+
630+
Args:
631+
other: Another PermissionData instance or any object.
632+
633+
Returns:
634+
bool: True if the actions match, False otherwise.
635+
636+
Example:
637+
>>> perm1 = PermissionData(action=ActionData(external_key='view'), effect='allow')
638+
>>> perm2 = PermissionData(action=ActionData(external_key='view'), effect='deny')
639+
>>> perm1 == perm2 # True - same action TODO: should we also consider the effect?
640+
True
641+
>>> perm1 in [perm2] # Uses __eq__
642+
True
643+
"""
644+
if not isinstance(other, PermissionData):
645+
return False
646+
if self.action is None or other.action is None:
647+
return False
648+
return self.action.external_key == other.action.external_key
649+
614650
def __str__(self):
615651
"""Human readable string representation of the permission and its effect."""
616652
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:
@@ -376,20 +376,23 @@ def get_subjects_for_role(role: RoleData) -> list[SubjectData]:
376376
return [SubjectData(namespaced_key=policy[GroupingPolicyIndex.SUBJECT.value]) for policy in policies]
377377

378378

379-
def get_scopes_for_role_and_subject(role: RoleData, subject: SubjectData) -> list[ScopeData]:
380-
"""Get all the scopes where a specific subject is assigned a specific role.
379+
def get_scopes_for_subject_and_permission(
380+
subject: SubjectData,
381+
permission: PermissionData,
382+
) -> list[ScopeData]:
383+
"""Get all scopes where a specific subject has been assigned a specific permission via roles.
381384
382385
Args:
383-
role (RoleData): The role to filter scopes.
386+
permission (PermissionData): The permission to filter scopes.
384387
subject (SubjectData): The subject to filter scopes.
385388
386389
Returns:
387-
list[ScopeData]: A list of scopes where the subject is assigned the specified role.
390+
list[ScopeData]: A list of scopes where the subject is assigned the specified permission.
388391
"""
389-
enforcer = AuthzEnforcer.get_enforcer()
390-
policies = enforcer.get_filtered_grouping_policy(GroupingPolicyIndex.SUBJECT.value, subject.namespaced_key)
391-
return [
392-
ScopeData(namespaced_key=policy[GroupingPolicyIndex.SCOPE.value])
393-
for policy in policies
394-
if policy[GroupingPolicyIndex.ROLE.value] == role.namespaced_key
395-
]
392+
roles_for_subject = get_subject_role_assignments(subject)
393+
scopes = []
394+
for role_assignment in roles_for_subject:
395+
for role in role_assignment.roles:
396+
if permission in role.permissions:
397+
scopes.append(role_assignment.scope)
398+
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,
@@ -35,6 +35,7 @@
3535
"get_all_user_role_assignments_in_scope",
3636
"is_user_allowed",
3737
"get_users_for_role",
38+
"get_scopes_for_user_and_permission",
3839
]
3940

4041

@@ -201,17 +202,20 @@ def get_users_for_role(role_external_key: str) -> list[UserData]:
201202
return [UserData(namespaced_key=user.namespaced_key) for user in users]
202203

203204

204-
def get_scopes_for_role_and_user(role_external_key: str, user_external_key: str) -> list[ScopeData]:
205-
"""Get all scopes where a specific user has been assigned a specific role.
205+
def get_scopes_for_user_and_permission(
206+
user_external_key: str,
207+
action_external_key: str,
208+
) -> list[ScopeData]:
209+
"""Get all scopes where a specific user is assigned a specific permission.
206210
207211
Args:
208-
role_external_key (str): The role to filter scopes (e.g., 'instructor').
209212
user_external_key (str): ID of the user (e.g., 'john_doe').
213+
action_external_key (str): The action to filter scopes (e.g., 'view', 'edit').
210214
211215
Returns:
212-
list[ScopeData]: A list of scopes where the user has the specified role.
216+
list[ScopeData]: A list of scopes where the user is assigned the specified permission.
213217
"""
214-
return get_scopes_for_role_and_subject(
215-
RoleData(external_key=role_external_key),
218+
return get_scopes_for_subject_and_permission(
216219
UserData(external_key=user_external_key),
220+
PermissionData(action=ActionData(external_key=action_external_key)),
217221
)

openedx_authz/tests/api/test_roles.py

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

13-
from openedx_authz.api.data import ContentLibraryData, RoleAssignmentData, RoleData, ScopeData, SubjectData
13+
from openedx_authz.api.data import (
14+
ActionData,
15+
ContentLibraryData,
16+
PermissionData,
17+
RoleAssignmentData,
18+
RoleData,
19+
ScopeData,
20+
SubjectData,
21+
)
1422
from openedx_authz.api.roles import (
1523
assign_role_to_subject_in_scope,
1624
batch_assign_role_to_subjects_in_scope,
1725
get_all_subject_role_assignments_in_scope,
1826
get_permissions_for_active_roles_in_scope,
1927
get_permissions_for_single_role,
2028
get_role_definitions_in_scope,
21-
get_scopes_for_role_and_subject,
29+
get_scopes_for_subject_and_permission,
2230
get_subject_role_assignments,
2331
get_subject_role_assignments_for_role_in_scope,
2432
get_subject_role_assignments_in_scope,
@@ -506,23 +514,108 @@ def test_get_role_assignments_in_scope(self, role_name, scope_name, expected_cou
506514

507515
self.assertEqual(len(role_assignments), expected_count)
508516

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

519-
scopes = get_scopes_for_role_and_subject(
520-
RoleData(external_key=role_name),
521-
SubjectData(external_key=subject_name),
522-
)
611+
scopes = get_scopes_for_subject_and_permission(subject, permission)
612+
613+
# Extract scope external keys for comparison
614+
actual_scope_names = [scope.external_key for scope in scopes]
523615

524-
scope_names = {scope.external_key for scope in scopes}
525-
self.assertEqual(scope_names, expected_scopes)
616+
self.assertEqual(len(actual_scope_names), len(expected_scope_names))
617+
for expected_scope in expected_scope_names:
618+
self.assertIn(expected_scope, actual_scope_names)
526619

527620

528621
@ddt

0 commit comments

Comments
 (0)