Skip to content

Commit f2c164f

Browse files
refactor: add tests for getting assignments for role
1 parent d762a84 commit f2c164f

6 files changed

Lines changed: 101 additions & 62 deletions

File tree

openedx_authz/api/data.py

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,16 @@ class AuthZData:
3232
Attributes:
3333
NAMESPACE: The namespace prefix for the data type (e.g., 'user', 'role').
3434
SEPARATOR: The separator between the namespace and the identifier (e.g., ':', '@').
35+
36+
Subclasses are automatically registered by their NAMESPACE for factory pattern.
3537
"""
3638

3739
SEPARATOR: str = "@"
38-
NAMESPACE: str = None # To be defined in subclasses
40+
NAMESPACE: str = None
41+
42+
# TODO: Implement factory method to return correct subclass based on NAMESPACE prefix.
43+
# This would allow initializing with either subject or scope, etc. and returning the correct subclass.
44+
# So we don't have to manage each subclass separately or hardcoded anywhere.
3945

4046

4147
@define
@@ -45,21 +51,25 @@ class ScopeData(AuthZData):
4551
Attributes:
4652
scope_id: The scope identifier (e.g., 'org@Demo').
4753
48-
This class assumes that the scope is already namespaced appropriately
49-
before being passed in, as scopes can vary widely (e.g., courses, organizations).
54+
Acts as a factory: automatically returns the correct subclass based on the scope_id prefix.
5055
"""
5156

52-
NAMESPACE: str = "sc" # Generic scope namespace, should be overridden by specific scope types
57+
NAMESPACE: str = "sc"
5358
scope_id: str = ""
54-
name: str = "" # Optional human-readable name
59+
name: str = ""
5560

5661
def __attrs_post_init__(self):
5762
"""Ensure scope ID has appropriate namespace prefix."""
5863
if not self.scope_id:
5964
self.scope_id = f"{self.NAMESPACE}{self.SEPARATOR}{self.name}".lower()
6065

6166
# Allow reverse lookup of name from scope_id
62-
if not self.name and self.scope_id and self.NAMESPACE and self.scope_id.startswith(f"{self.NAMESPACE}{self.SEPARATOR}"):
67+
if (
68+
not self.name
69+
and self.scope_id
70+
and self.NAMESPACE
71+
and self.scope_id.startswith(f"{self.NAMESPACE}{self.SEPARATOR}")
72+
):
6373
self.name = self.scope_id.split(self.SEPARATOR, 1)[1].lower()
6474

6575

@@ -81,7 +91,9 @@ def __attrs_post_init__(self):
8191
self.scope_id = f"{self.NAMESPACE}{self.SEPARATOR}{self.library_id}".lower()
8292

8393
# Allow reverse lookup of library_id from scope_id
84-
if not self.library_id and self.scope_id.startswith(f"{self.NAMESPACE}{self.SEPARATOR}"):
94+
if not self.library_id and self.scope_id.startswith(
95+
f"{self.NAMESPACE}{self.SEPARATOR}"
96+
):
8597
self.library_id = self.scope_id.split(self.SEPARATOR, 1)[1].lower()
8698

8799

@@ -92,27 +104,22 @@ class SubjectData(AuthZData):
92104
Attributes:
93105
subject_id: The subject identifier namespaced (e.g., 'user@john_doe').
94106
95-
This class assumes that the subject was already namespaced by their own
96-
type (e.g., 'user@', 'group@') before being passed in since subjects can be
97-
users, groups, or other entities.
107+
Acts as a factory: automatically returns the correct subclass based on the subject_id prefix.
98108
"""
99109

100-
NAMESPACE: str = (
101-
"sub" # Generic subject namespace, should be overridden by specific subject types
102-
)
110+
NAMESPACE: str = "sub"
103111
subject_id: str = ""
104-
name: str = "" # Optional human-readable name
112+
name: str = ""
105113

106114
def __attrs_post_init__(self):
107-
"""Ensure subject ID has appropriate namespace prefix.
108-
109-
This allows initialization with either name= or subject_id= parameter.
110-
"""
115+
"""Ensure subject ID has appropriate namespace prefix."""
111116
if not self.subject_id:
112117
self.subject_id = f"{self.NAMESPACE}{self.SEPARATOR}{self.name}".lower()
113118

114119
# Allow reverse lookup of name from subject_id
115-
if not self.name and self.subject_id.startswith(f"{self.NAMESPACE}{self.SEPARATOR}"):
120+
if not self.name and self.subject_id.startswith(
121+
f"{self.NAMESPACE}{self.SEPARATOR}"
122+
):
116123
self.name = self.subject_id.split(self.SEPARATOR, 1)[1].lower()
117124

118125

@@ -140,7 +147,9 @@ def __attrs_post_init__(self):
140147
self.subject_id = f"{self.NAMESPACE}{self.SEPARATOR}{self.username}".lower()
141148

142149
# Allow reverse lookup of username from subject_id
143-
if not self.username and self.subject_id.startswith(f"{self.NAMESPACE}{self.SEPARATOR}"):
150+
if not self.username and self.subject_id.startswith(
151+
f"{self.NAMESPACE}{self.SEPARATOR}"
152+
):
144153
self.username = self.subject_id.split(self.SEPARATOR, 1)[1].lower()
145154

146155

@@ -165,7 +174,9 @@ def __attrs_post_init__(self):
165174
self.action_id = f"{self.NAMESPACE}{self.SEPARATOR}{self.name}".lower()
166175

167176
# Allow reverse lookup of name from action_id
168-
if not self.name and self.action_id.startswith(f"{self.NAMESPACE}{self.SEPARATOR}"):
177+
if not self.name and self.action_id.startswith(
178+
f"{self.NAMESPACE}{self.SEPARATOR}"
179+
):
169180
self.name = self.action_id.split(self.SEPARATOR, 1)[1].lower()
170181

171182

@@ -219,13 +230,18 @@ def __attrs_post_init__(self):
219230
220231
This allows initialization with either name= or role_id= parameter.
221232
"""
222-
if not self.role_id or not self.role_id.startswith(f"{self.NAMESPACE}{self.SEPARATOR}"):
233+
if not self.role_id or not self.role_id.startswith(
234+
f"{self.NAMESPACE}{self.SEPARATOR}"
235+
):
223236
self.role_id = f"{self.NAMESPACE}{self.SEPARATOR}{self.name}".lower()
224237

225238
# Allow reverse lookup of name from role_id
226-
if not self.name and self.role_id.startswith(f"{self.NAMESPACE}{self.SEPARATOR}"):
239+
if not self.name and self.role_id.startswith(
240+
f"{self.NAMESPACE}{self.SEPARATOR}"
241+
):
227242
self.name = self.role_id.split(self.SEPARATOR, 1)[1].lower()
228243

244+
229245
@define
230246
class RoleAssignmentData(AuthZData):
231247
"""A role assignment is the assignment of a role to a subject in a specific scope.

openedx_authz/api/roles.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,9 @@ def get_subject_role_assignments(subject: SubjectData) -> list[RoleAssignmentDat
247247
GroupingPolicyIndex.SUBJECT.value, subject.subject_id
248248
):
249249
role = RoleData(role_id=policy[GroupingPolicyIndex.ROLE.value])
250-
role.permissions = get_permissions_for_roles(role)[role.name][ # Index by role name for readability
250+
role.permissions = get_permissions_for_roles(role)[
251+
role.name
252+
][ # Index by role name for readability
251253
"permissions"
252254
]
253255

@@ -307,7 +309,7 @@ def get_subjects_role_assignments_for_role_in_scope(
307309
"""
308310
role_assignments = []
309311
for subject in enforcer.get_users_for_role_in_domain(role.role_id, scope.scope_id):
310-
if subject.startswith(RoleData.NAMESPACE):
312+
if subject.startswith(f"{RoleData.NAMESPACE}@"):
311313
# Skip roles that are also subjects
312314
continue
313315
role_assignments.append(

openedx_authz/api/users.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@
99
(e.g., 'user@john_doe').
1010
"""
1111

12-
from openedx_authz.api.data import RoleAssignmentData, RoleData, ScopeData, SubjectData, UserData
12+
from openedx_authz.api.data import (
13+
RoleAssignmentData,
14+
RoleData,
15+
ScopeData,
16+
SubjectData,
17+
UserData,
18+
)
1319
from openedx_authz.api.roles import (
1420
assign_role_to_subject_in_scope,
1521
batch_assign_role_to_subjects_in_scope,
@@ -104,7 +110,9 @@ def get_user_role_assignments(username: str) -> list[RoleAssignmentData]:
104110
return get_subject_role_assignments(UserData(username=username))
105111

106112

107-
def get_user_role_assignments_in_scope(username: str, scope: str) -> list[RoleAssignmentData]:
113+
def get_user_role_assignments_in_scope(
114+
username: str, scope: str
115+
) -> list[RoleAssignmentData]:
108116
"""Get the roles assigned to a user in a specific scope.
109117
110118
Args:
@@ -118,7 +126,10 @@ def get_user_role_assignments_in_scope(username: str, scope: str) -> list[RoleAs
118126
UserData(username=username), ScopeData(name=scope)
119127
)
120128

121-
def get_user_role_assignments_for_role_in_scope(role_name:str, scope:str) -> list[RoleAssignmentData]:
129+
130+
def get_user_role_assignments_for_role_in_scope(
131+
role_name: str, scope: str
132+
) -> list[RoleAssignmentData]:
122133
"""Get all users assigned to a specific role across all scopes.
123134
124135
Args:
@@ -128,4 +139,20 @@ def get_user_role_assignments_for_role_in_scope(role_name:str, scope:str) -> lis
128139
Returns:
129140
list[dict]: A list of user names and all their metadata assigned to the role.
130141
"""
131-
return get_subjects_role_assignments_for_role_in_scope(RoleData(name=role_name), ScopeData(name=scope))
142+
# TODO: this SHOULD definitely be managed in a better way by using class inheritance and factories
143+
# But for now we'll keep it simple and explicit
144+
user_role_assignments = []
145+
for role_assignment in get_subjects_role_assignments_for_role_in_scope(
146+
RoleData(name=role_name), ScopeData(name=scope)
147+
):
148+
print(role_assignment)
149+
user_role_assignments.append(
150+
RoleAssignmentData(
151+
subject=UserData(
152+
subject_id=role_assignment.subject.subject_id
153+
), # TODO: this gets the username from the subject_id
154+
role=role_assignment.role,
155+
scope=role_assignment.scope,
156+
)
157+
)
158+
return user_role_assignments

openedx_authz/settings/common.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ def plugin_settings(settings):
2222
settings.INSTALLED_APPS.append(casbin_adapter_app)
2323

2424
# Add Casbin configuration
25-
settings.CASBIN_MODEL = os.path.join(ROOT_DIRECTORY, "engine", "config", "model.conf")
25+
settings.CASBIN_MODEL = os.path.join(
26+
ROOT_DIRECTORY, "engine", "config", "model.conf"
27+
)
2628
settings.CASBIN_WATCHER_ENABLED = True
2729
# TODO: Replace with a more dynamic configuration
2830
# Redis host and port are temporarily loaded here for the MVP

openedx_authz/tests/api/test_roles.py

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -117,11 +117,6 @@ def setUpClass(cls):
117117
"role_name": "library_collaborator",
118118
"scope_name": "math_advanced",
119119
},
120-
{
121-
"subject_name": "Henry",
122-
"role_name": "library_collaborator",
123-
"scope_name": "math_advanced",
124-
},
125120
# Hierarchical scope_id assignments - different specificity levels
126121
{
127122
"subject_name": "Ivy",
@@ -195,7 +190,7 @@ def setUpClass(cls):
195190
"subject_name": "Frank",
196191
"role_name": "library_user",
197192
"scope_name": "project_epsilon",
198-
}
193+
},
199194
]
200195
cls._seed_database_with_policies()
201196
cls._assign_roles_to_users(assignments=assignments)
@@ -449,9 +444,7 @@ def test_get_permissions_for_roles(self, role_name, expected_permissions):
449444
"library_user",
450445
"english_101",
451446
[
452-
PermissionData(
453-
action=ActionData(name="view_library"), effect="allow"
454-
),
447+
PermissionData(action=ActionData(name="view_library"), effect="allow"),
455448
PermissionData(
456449
action=ActionData(name="view_library_team"), effect="allow"
457450
),
@@ -474,9 +467,7 @@ def test_get_permissions_for_roles(self, role_name, expected_permissions):
474467
action=ActionData(name="publish_library_content"),
475468
effect="allow",
476469
),
477-
PermissionData(
478-
action=ActionData(name="edit_library"), effect="allow"
479-
),
470+
PermissionData(action=ActionData(name="edit_library"), effect="allow"),
480471
PermissionData(
481472
action=ActionData(name="manage_library_tags"),
482473
effect="allow",
@@ -581,7 +572,9 @@ def test_get_roles_in_scope(self, scope_name, expected_roles):
581572
# Need to cheat here and use library data class to get lib@* scope_name
582573
# TODO: it'd be better to have our own policies for testing but for now we're using
583574
# the existing ones in authz.policy
584-
roles_in_scope = get_role_definitions_in_scope(ContentLibraryData(library_id=scope_name))
575+
roles_in_scope = get_role_definitions_in_scope(
576+
ContentLibraryData(library_id=scope_name)
577+
)
585578

586579
role_names = {role.name for role in roles_in_scope}
587580
self.assertEqual(role_names, expected_roles)
@@ -595,7 +588,6 @@ def test_get_roles_in_scope(self, scope_name, expected_roles):
595588
("eve", "chemistry_501", {"library_author"}),
596589
("eve", "biology_601", {"library_user"}),
597590
("grace", "math_advanced", {"library_collaborator"}),
598-
("henry", "math_advanced", {"library_collaborator"}),
599591
("ivy", "cs_101", {"library_admin"}),
600592
("jack", "cs_101", {"library_author"}),
601593
("kate", "cs_101", {"library_user"}),
@@ -797,9 +789,7 @@ def test_get_subject_role_assignments_in_scope(
797789
("non_existent_user", []),
798790
)
799791
@unpack
800-
def test_get_all_role_assignments_scopes(
801-
self, subject_name, expected_roles
802-
):
792+
def test_get_all_role_assignments_scopes(self, subject_name, expected_roles):
803793
"""Test retrieving all roles assigned to a subject across all scopes.
804794
805795
Expected result:
@@ -851,7 +841,7 @@ def test_get_role_assignments_in_scope(self, role_name, scope_name, expected_cou
851841
Expected result:
852842
- The number of role assignments in the given scope is correctly retrieved.
853843
"""
854-
role_assignments = get_subject_role_assignments_for_role_in_scope(
844+
role_assignments = get_subjects_role_assignments_for_role_in_scope(
855845
RoleData(name=role_name), ScopeData(name=scope_name)
856846
)
857847

openedx_authz/tests/api/test_users.py

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,7 @@ def test_assign_role_to_user_in_scope(self, username, role, scope_name, batch):
5757
- The role is successfully assigned to the user in the specified scope.
5858
"""
5959
if batch:
60-
batch_assign_role_to_users(
61-
users=username, role_name=role, scope=scope_name
62-
)
60+
batch_assign_role_to_users(users=username, role_name=role, scope=scope_name)
6361
for user in username:
6462
user_roles = get_user_role_assignments_in_scope(
6563
username=user, scope=scope_name
@@ -77,7 +75,7 @@ def test_assign_role_to_user_in_scope(self, username, role, scope_name, batch):
7775
self.assertIn(role, role_names)
7876

7977
@data(
80-
(["Grace", "Henry"], "library_collaborator", "math_advanced", True),
78+
(["Grace"], "library_collaborator", "math_advanced", True),
8179
(["Liam", "Maya"], "library_author", "art_101", True),
8280
("Alice", "library_admin", "math_101", False),
8381
("Bob", "library_author", "history_201", False),
@@ -101,9 +99,7 @@ def test_unassign_role_from_user(self, username, role, scope_name, batch):
10199
role_names = {assignment.role.name for assignment in user_roles}
102100
self.assertNotIn(role, role_names)
103101
else:
104-
unassign_role_from_user(
105-
user=username, role_name=role, scope=scope_name
106-
)
102+
unassign_role_from_user(user=username, role_name=role, scope=scope_name)
107103
user_roles = get_user_role_assignments_in_scope(
108104
username=username, scope=scope_name
109105
)
@@ -136,7 +132,9 @@ def test_get_user_role_assignments(self, username, expected_roles):
136132
("Grace", "math_advanced", {"library_collaborator"}),
137133
)
138134
@unpack
139-
def test_get_user_role_assignments_in_scope(self, username, scope_name, expected_roles):
135+
def test_get_user_role_assignments_in_scope(
136+
self, username, scope_name, expected_roles
137+
):
140138
"""Test retrieving role assignments for a user within a specific scope.
141139
142140
Expected result:
@@ -150,14 +148,15 @@ def test_get_user_role_assignments_in_scope(self, username, scope_name, expected
150148
role_names = {assignment.role.name for assignment in user_roles}
151149
self.assertEqual(role_names, expected_roles)
152150

153-
154151
@data(
155-
("library_admin", "math_101", {"Alice", "Eve"}),
156-
("library_author", "history_201", {"Bob"}),
157-
("library_collaborator", "math_advanced", {"Grace"}),
152+
("library_admin", "math_101", {"alice"}),
153+
("library_author", "history_201", {"bob"}),
154+
("library_collaborator", "math_advanced", {"grace"}),
158155
)
159156
@unpack
160-
def test_get_user_role_assignments_for_role_in_scope(self, role_name, scope_name, expected_users):
157+
def test_get_user_role_assignments_for_role_in_scope(
158+
self, role_name, scope_name, expected_users
159+
):
161160
"""Test retrieving all users assigned to a specific role within a specific scope.
162161
163162
Expected result:
@@ -168,5 +167,8 @@ def test_get_user_role_assignments_for_role_in_scope(self, role_name, scope_name
168167
role_name=role_name, scope=scope_name
169168
)
170169

171-
assigned_usernames = {assignment.subject.username for assignment in user_assignments}
170+
assigned_usernames = {
171+
assignment.subject.username for assignment in user_assignments
172+
}
173+
172174
self.assertEqual(assigned_usernames, expected_users)

0 commit comments

Comments
 (0)