Skip to content

Commit e2e50de

Browse files
feat: add decorator to manage policy lifecycle for low-level APIs
1 parent b7dbf55 commit e2e50de

10 files changed

Lines changed: 1523 additions & 4 deletions

File tree

openedx_authz/api/data.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,8 @@ class ScopeData(AuthZData, metaclass=ScopeMeta):
203203
"""
204204

205205
NAMESPACE: ClassVar[str] = "sc"
206+
POLICY_POSITION = 2 # Position of scope in Casbin policy rules (p = sub, act, obj)
207+
GROUPING_POLICY_POSITION = 2 # Position of scope in Casbin grouping policy rules (g = sub, role, scope)
206208

207209
@classmethod
208210
def validate_external_key(cls, _: str) -> bool:
@@ -220,6 +222,15 @@ def validate_external_key(cls, _: str) -> bool:
220222
"""
221223
return True
222224

225+
@property
226+
def policy_template(self) -> str:
227+
"""Get the policy template for the scope.
228+
229+
Returns:
230+
str: The policy template string.
231+
"""
232+
return f"{self.NAMESPACE}{self.SEPARATOR}*"
233+
223234

224235
@define
225236
class ContentLibraryData(ScopeData):

openedx_authz/api/decorators.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""Decorators for the authorization public API."""
2+
from functools import wraps
3+
4+
from openedx_authz.api.data import ScopeData
5+
from openedx_authz.engine.enforcer import enforcer
6+
from openedx_authz.engine.filter import Filter
7+
8+
9+
def manage_policy_lifecycle(filter_on: str = ""):
10+
"""Decorator to manage policy lifecycle around API calls.
11+
12+
This decorator ensures proper policy loading and clearing around API function calls.
13+
It loads relevant policies before execution and clears them afterward to prevent
14+
stale policy issues in long-running processes.
15+
16+
Can be used in two ways:
17+
@manage_policy_lifecycle() -> Loads full policy
18+
@manage_policy_lifecycle(filter_on="scope") -> Loads filtered policy by scope
19+
20+
Args:
21+
filter_on (str): The type of data class to filter on (e.g., "scope").
22+
If empty, loads full policy.
23+
24+
Returns:
25+
callable: The decorated function or decorator.
26+
27+
Examples:
28+
# Without filtering (loads all policies):
29+
@manage_policy_lifecycle()
30+
def get_all_roles():
31+
return enforcer.get_all_roles()
32+
33+
# With scope filtering (loads only relevant policies):
34+
@manage_policy_lifecycle(filter_on="scope")
35+
def get_roles_in_scope(scope: ScopeData):
36+
return enforcer.get_filtered_roles(scope.namespaced_key)
37+
"""
38+
FILTER_DATA_CLASSES = {
39+
"scope": ScopeData,
40+
}
41+
42+
def build_filter_from_args(args) -> Filter:
43+
"""Build a Filter object from function arguments based on the filter_on parameter.
44+
45+
Args:
46+
args (tuple): Positional arguments passed to the decorated function.
47+
48+
Returns:
49+
Filter: A Filter object populated with relevant filter values.
50+
"""
51+
filter_obj = Filter()
52+
if filter_on and filter_on in FILTER_DATA_CLASSES:
53+
for arg in args:
54+
if isinstance(arg, FILTER_DATA_CLASSES[filter_on]):
55+
filter_value = getattr(filter_obj, f"v{arg.POLICY_POSITION}")
56+
filter_value.append(arg.policy_template) # Used to load p type policies as well. E.g., lib^*
57+
filter_value.append(arg.namespaced_key) # E.g., lib^lib:DemoX:CSPROB
58+
return filter_obj
59+
60+
def decorator(f):
61+
"""Inner decorator that wraps the function with policy lifecycle management."""
62+
@wraps(f)
63+
def wrapper(*args, **kwargs):
64+
"""Wrapper that handles policy loading, execution, and cleanup."""
65+
filter_obj = build_filter_from_args(args)
66+
67+
if any([filter_obj.ptype, filter_obj.v0, filter_obj.v1, filter_obj.v2]):
68+
enforcer.load_filtered_policy(filter_obj)
69+
else:
70+
enforcer.load_policy()
71+
72+
try:
73+
return f(*args, **kwargs)
74+
finally:
75+
enforcer.clear_policy()
76+
77+
return wrapper
78+
79+
return decorator

openedx_authz/api/permissions.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""
77

88
from openedx_authz.api.data import ActionData, PermissionData, PolicyIndex, ScopeData, SubjectData
9+
from openedx_authz.api.decorators import manage_policy_lifecycle
910
from openedx_authz.engine.enforcer import enforcer
1011

1112
__all__ = [
@@ -48,6 +49,7 @@ def get_all_permissions_in_scope(scope: ScopeData) -> list[PermissionData]:
4849
return [get_permission_from_policy(action) for action in actions]
4950

5051

52+
@manage_policy_lifecycle(filter_on="scope")
5153
def is_subject_allowed(
5254
subject: SubjectData,
5355
action: ActionData,

openedx_authz/api/roles.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
)
2222
from openedx_authz.api.permissions import get_permission_from_policy
2323
from openedx_authz.engine.enforcer import enforcer
24+
from openedx_authz.api.decorators import manage_policy_lifecycle
2425

2526
__all__ = [
2627
"get_permissions_for_single_role",
@@ -48,6 +49,7 @@
4849
# in this case, ALL the policies, but that might not be the case
4950

5051

52+
@manage_policy_lifecycle()
5153
def get_permissions_for_single_role(
5254
role: RoleData,
5355
) -> list[PermissionData]:
@@ -63,6 +65,7 @@ def get_permissions_for_single_role(
6365
return [get_permission_from_policy(policy) for policy in policies]
6466

6567

68+
@manage_policy_lifecycle()
6669
def get_permissions_for_roles(
6770
roles: list[RoleData],
6871
) -> dict[str, dict[str, list[PermissionData | str]]]:
@@ -84,6 +87,7 @@ def get_permissions_for_roles(
8487
return permissions_by_role
8588

8689

90+
@manage_policy_lifecycle(filter_on="scope")
8791
def get_permissions_for_active_roles_in_scope(
8892
scope: ScopeData, role: RoleData | None = None
8993
) -> dict[str, dict[str, list[PermissionData | str]]]:
@@ -133,6 +137,7 @@ def get_permissions_for_active_roles_in_scope(
133137
)
134138

135139

140+
@manage_policy_lifecycle(filter_on="scope")
136141
def get_role_definitions_in_scope(scope: ScopeData) -> list[RoleData]:
137142
"""Get all role definitions available in a specific scope.
138143
@@ -171,7 +176,7 @@ def get_role_definitions_in_scope(scope: ScopeData) -> list[RoleData]:
171176
for role, permissions in permissions_per_role.items()
172177
]
173178

174-
179+
@manage_policy_lifecycle()
175180
def get_all_roles_names() -> list[str]:
176181
"""Get all the available roles names in the current environment.
177182
@@ -181,6 +186,7 @@ def get_all_roles_names() -> list[str]:
181186
return enforcer.get_all_subjects()
182187

183188

189+
@manage_policy_lifecycle(filter_on="scope")
184190
def get_all_roles_in_scope(scope: ScopeData) -> list[list[str]]:
185191
"""Get all the available role grouping policies in a specific scope.
186192
@@ -195,6 +201,7 @@ def get_all_roles_in_scope(scope: ScopeData) -> list[list[str]]:
195201
)
196202

197203

204+
@manage_policy_lifecycle(filter_on="scope")
198205
def assign_role_to_subject_in_scope(
199206
subject: SubjectData, role: RoleData, scope: ScopeData
200207
) -> None:
@@ -211,6 +218,7 @@ def assign_role_to_subject_in_scope(
211218
)
212219

213220

221+
@manage_policy_lifecycle(filter_on="scope")
214222
def batch_assign_role_to_subjects_in_scope(
215223
subjects: list[SubjectData], role: RoleData, scope: ScopeData
216224
) -> None:
@@ -224,6 +232,7 @@ def batch_assign_role_to_subjects_in_scope(
224232
assign_role_to_subject_in_scope(subject, role, scope)
225233

226234

235+
@manage_policy_lifecycle(filter_on="scope")
227236
def unassign_role_from_subject_in_scope(
228237
subject: SubjectData, role: RoleData, scope: ScopeData
229238
) -> None:
@@ -239,6 +248,7 @@ def unassign_role_from_subject_in_scope(
239248
)
240249

241250

251+
@manage_policy_lifecycle(filter_on="scope")
242252
def batch_unassign_role_from_subjects_in_scope(
243253
subjects: list[SubjectData], role: RoleData, scope: ScopeData
244254
) -> None:
@@ -253,6 +263,7 @@ def batch_unassign_role_from_subjects_in_scope(
253263
unassign_role_from_subject_in_scope(subject, role, scope)
254264

255265

266+
@manage_policy_lifecycle()
256267
def get_subject_role_assignments(subject: SubjectData) -> list[RoleAssignmentData]:
257268
"""Get all the roles for a subject across all scopes.
258269
@@ -279,6 +290,7 @@ def get_subject_role_assignments(subject: SubjectData) -> list[RoleAssignmentDat
279290
return role_assignments
280291

281292

293+
@manage_policy_lifecycle(filter_on="scope")
282294
def get_subject_role_assignments_in_scope(
283295
subject: SubjectData, scope: ScopeData
284296
) -> list[RoleAssignmentData]:
@@ -310,6 +322,7 @@ def get_subject_role_assignments_in_scope(
310322
return role_assignments
311323

312324

325+
@manage_policy_lifecycle(filter_on="scope")
313326
def get_subject_role_assignments_for_role_in_scope(
314327
role: RoleData, scope: ScopeData
315328
) -> list[RoleAssignmentData]:
@@ -344,6 +357,7 @@ def get_subject_role_assignments_for_role_in_scope(
344357
return role_assignments
345358

346359

360+
@manage_policy_lifecycle(filter_on="scope")
347361
def get_all_subject_role_assignments_in_scope(scope: ScopeData) -> list[RoleAssignmentData]:
348362
"""Get all the subjects assigned to any role in a specific scope.
349363

openedx_authz/engine/adapter.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from casbin.persist import FilteredAdapter
1919
from casbin_adapter.adapter import Adapter
2020
from casbin_adapter.models import CasbinRule
21-
from django.db.models import QuerySet
21+
from django.db.models import QuerySet, Q
2222

2323
from openedx_authz.engine.filter import Filter
2424

0 commit comments

Comments
 (0)