Skip to content

Commit bf94a4b

Browse files
refactor: wrap namespace into attrs classes
1 parent ca26e5e commit bf94a4b

9 files changed

Lines changed: 557 additions & 394 deletions

File tree

openedx_authz/api/data.py

Lines changed: 77 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,20 +26,80 @@ class PolicyIndex(Enum):
2626

2727

2828
@define
29-
class Permission: # TODO: change to policy?
29+
class UserData:
30+
"""A user is a subject that can be assigned roles and permissions.
31+
32+
Attributes:
33+
username: The username. Automatically prefixed with 'user:' if not present.
34+
"""
35+
36+
username: str
37+
38+
def __attrs_post_init__(self):
39+
"""Ensure username has 'user:' namespace prefix."""
40+
if not self.username.startswith("user:"):
41+
object.__setattr__(self, "username", f"user:{self.username}")
42+
43+
44+
@define
45+
class ScopeData:
46+
"""A scope is a context in which roles and permissions are assigned.
47+
48+
Attributes:
49+
scope_id: The scope identifier (e.g., 'course-v1:edX+DemoX+2021_T1').
50+
51+
This class assumes that the scope is already namespaced appropriately
52+
before being passed in, as scopes can vary widely (e.g., courses, organizations).
53+
"""
54+
55+
scope_id: str
56+
57+
58+
@define
59+
class SubjectData:
60+
"""A subject is an entity that can be assigned roles and permissions.
61+
62+
Attributes:
63+
subject_id: The subject identifier namespaced (e.g., 'user:john_doe').
64+
65+
This class assumes that the subject was already namespaced by their own
66+
type (e.g., 'user:', 'group:') before being passed in since subjects can be
67+
users, groups, or other entities.
68+
"""
69+
70+
subject_id: str
71+
72+
73+
@define
74+
class ActionData:
75+
"""An action is an operation that can be performed in a specific scope.
76+
77+
Attributes:
78+
action: The action name. Automatically prefixed with 'act:' if not present.
79+
"""
80+
81+
action_id: str
82+
83+
def __attrs_post_init__(self):
84+
"""Ensure action name has 'act:' namespace prefix."""
85+
if not self.action_id.startswith("act:"):
86+
object.__setattr__(self, "action_id", f"act:{self.action_id}")
87+
88+
89+
@define
90+
class PermissionData: # TODO: change to policy?
3091
"""A permission is an action that can be performed under certain conditions.
3192
3293
Attributes:
3394
name: The name of the permission.
3495
"""
3596

36-
# TODO: what other attributes should a permission have?
37-
name: str
97+
action: ActionData
3898
effect: Literal["allow", "deny"] = "allow"
3999

40100

41101
@define
42-
class RoleMetadata:
102+
class RoleMetadataData:
43103
"""Metadata for a role.
44104
45105
Attributes:
@@ -54,25 +114,29 @@ class RoleMetadata:
54114

55115

56116
@define
57-
class Role:
117+
class RoleData:
58118
"""A role is a named group of permissions.
59119
60120
Attributes:
61-
name: The name of the role.
121+
name: The name of the role. Must have 'role:' namespace prefix.
62122
permissions: A list of permissions assigned to the role.
63123
scopes: A list of scopes assigned to the role.
64124
metadata: A dictionary of metadata assigned to the role. This can include
65125
information such as the description of the role, creation date, etc.
66126
"""
67127

68128
name: str
69-
scopes: list[str]
70-
permissions: list[Permission] = None
71-
metadata: RoleMetadata = None
129+
permissions: list[PermissionData] = None
130+
metadata: RoleMetadataData = None
131+
132+
def __attrs_post_init__(self):
133+
"""Ensure role name has 'role:' namespace prefix."""
134+
if not self.name.startswith("role:"):
135+
object.__setattr__(self, "name", f"role:{self.name}")
72136

73137

74138
@define
75-
class RoleAssignment:
139+
class RoleAssignmentData:
76140
"""A role assignment is the assignment of a role to a subject in a specific scope.
77141
78142
Attributes:
@@ -82,5 +146,6 @@ class RoleAssignment:
82146
scope: The scope in which the role is assigned.
83147
"""
84148

85-
subject: str # TODO: I think here it makes sense to sanitize the subject so it's the username?
86-
role: Role
149+
subject: UserData
150+
role: RoleData
151+
scope: ScopeData

openedx_authz/api/permissions.py

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,39 +5,60 @@
55
are not explicitly defined, but are inferred from the policy rules.
66
"""
77

8-
from typing import Literal
9-
10-
from openedx_authz.api.data import Permission, PolicyIndex
8+
from openedx_authz.api.data import ActionData, PermissionData, PolicyIndex, ScopeData, SubjectData
119
from openedx_authz.engine.enforcer import enforcer
1210

13-
__all__ = ["get_permission_from_policy", "get_all_permissions_in_scope"]
11+
__all__ = [
12+
"get_permission_from_policy",
13+
"get_all_permissions_in_scope",
14+
"has_permission",
15+
]
1416

1517

16-
def get_permission_from_policy(policy: list[str]) -> Permission:
17-
"""Convert a Casbin policy list to a Permission object.
18+
def get_permission_from_policy(policy: list[str]) -> PermissionData:
19+
"""Convert a Casbin policy list to a PermissionData object.
1820
1921
Args:
2022
policy: A list representing a Casbin policy.
2123
2224
Returns:
23-
Permission: The corresponding Permission object or an empty Permission if the policy is invalid.
25+
PermissionData: The corresponding PermissionData object or an empty PermissionData if the policy is invalid.
2426
"""
2527
if len(policy) < 4: # Do not count ptype
26-
return Permission(name="", effect="")
28+
return PermissionData(action=ActionData(action_id=""), effect="allow")
2729

28-
return Permission(
29-
name=policy[PolicyIndex.ACT.value], effect=policy[PolicyIndex.EFFECT.value]
30+
return PermissionData(
31+
action=ActionData(action_id=policy[PolicyIndex.ACT.value]),
32+
effect=policy[PolicyIndex.EFFECT.value],
3033
)
3134

3235

33-
def get_all_permissions_in_scope(scope: str) -> list[Permission]:
36+
def get_all_permissions_in_scope(scope: ScopeData) -> list[PermissionData]:
3437
"""Retrieve all permissions associated with a specific scope.
3538
3639
Args:
3740
scope: The scope to filter permissions by.
3841
3942
Returns:
40-
list of Permission: A list of Permission objects associated with the given scope.
43+
list of PermissionData: A list of PermissionData objects associated with the given scope.
4144
"""
42-
actions = enforcer.get_filtered_policy(PolicyIndex.SCOPE.value, scope)
45+
actions = enforcer.get_filtered_policy(PolicyIndex.SCOPE.value, scope.scope_id)
4346
return [get_permission_from_policy(action) for action in actions]
47+
48+
49+
def has_permission(
50+
subject: SubjectData,
51+
action: ActionData,
52+
scope: ScopeData,
53+
) -> bool:
54+
"""Check if a subject has a specific permission in a given scope.
55+
56+
Args:
57+
subject: The subject to check (e.g., user or service).
58+
action: The action to check (e.g., 'view_course').
59+
scope: The scope in which to check the permission (e.g., 'course-v1:edX+DemoX+2021_T1').
60+
61+
Returns:
62+
bool: True if the subject has the specified permission in the scope, False otherwise.
63+
"""
64+
return enforcer.enforce(subject.subject_id, action.action_id, scope.scope_id)

0 commit comments

Comments
 (0)