Skip to content

Commit f7cd951

Browse files
authoredJun 26, 2024
Merge pull request feast-dev#22 from dmartinol/feast-rbac
docstrings for permissions package
2 parents 155475a + 6d008db commit f7cd951

12 files changed

+238
-88
lines changed
 

‎sdk/python/feast/permissions/action.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
class AuthzedAction(enum.Enum):
55
"""
6-
Identifies the type of action being secured by the permissions framework, according to the familiar CRUD and Feast terminology.
6+
Identify the type of action being secured by the permissions framework, according to the familiar CRUD and Feast terminology.
77
"""
88

99
ALL = "all" # All actions

‎sdk/python/feast/permissions/decision.py

+39-4
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
class DecisionStrategy(enum.Enum):
99
"""
10-
The strategy to be adopted in case multiple policies are defined.
10+
The strategy to be adopted in case multiple permissions match an execution request.
1111
"""
1212

1313
UNANIMOUS = "unanimous" # All policies must evaluate to a positive decision for the final decision to be also positive.
@@ -18,6 +18,23 @@ class DecisionStrategy(enum.Enum):
1818

1919

2020
class DecisionEvaluator:
21+
"""
22+
A class to implement the decision logic, according to the selected strategy.
23+
24+
Args:
25+
decision_strategy: The associated `DecisionStrategy`.
26+
num_of_voters: The expected number of votes to complete the decision.
27+
28+
Examples:
29+
Create the instance and specify the strategy and number of decisions:
30+
`evaluator = DecisionEvaluator(DecisionStrategy.UNANIMOUS, 3)
31+
32+
For each vote that you receivem, add a decision grant: `evaluator.add_grant(vote, message)`
33+
and check if the decision process ended: `if evaluator.is_decided():`
34+
Once decided, get the result and the failure explanations using:
35+
`grant, explanations = evaluator.grant()`
36+
"""
37+
2138
def __init__(
2239
self,
2340
decision_strategy: DecisionStrategy,
@@ -49,15 +66,33 @@ def __init__(
4966
)
5067

5168
def is_decided(self) -> bool:
69+
"""
70+
Returns:
71+
bool: `True` when the decision process completed (e.g. we added as many votes as specified in the `num_of_voters` creation argument).
72+
"""
5273
return self.grant_decision is not None
5374

5475
def grant(self) -> tuple[bool, list[str]]:
76+
"""
77+
Returns:
78+
tuple[bool, list[str]]: The tuple of decision computation: a `bool` with the computation decision and a `list[str]` with the
79+
denial explanations (possibly empty).
80+
"""
5581
logger.info(
5682
f"Decided grant is {self.grant_decision}, explanations={self.explanations}"
5783
)
5884
return bool(self.grant_decision), self.explanations
5985

60-
def add_grant(self, label, grant: bool, explanation: str):
86+
def add_grant(self, grant: bool, explanation: str):
87+
"""
88+
Add a single vote to the decision computation, with a possible denial reason.
89+
If the evalluation process already ended, additional votes are discarded.
90+
91+
Args:
92+
grant: `True` is the decision is accepted, `False` otherwise.
93+
explanation: Denial reason (not considered when `vote` is `True`).
94+
"""
95+
6196
if self.is_decided():
6297
logger.warning("Grant decision already decided, discarding vote")
6398
return
@@ -71,6 +106,6 @@ def add_grant(self, label, grant: bool, explanation: str):
71106
self.grant_decision = True
72107
if self.deny_count >= self.deny_quorum:
73108
self.grant_decision = False
74-
logger.info(
75-
f"After {label}: grants={self.grant_count}, deny_count={self.deny_count}, grant_decision={self.grant_decision}"
109+
logger.debug(
110+
f"After new grant: grants={self.grant_count}, deny_count={self.deny_count}, grant_decision={self.grant_decision}"
76111
)

‎sdk/python/feast/permissions/decorator.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@
1010

1111
def require_permissions(actions: Union[list[AuthzedAction], AuthzedAction]):
1212
"""
13-
A decorator to define the actions that are executed from within the current class method and that must be protected
13+
A decorator to define the actions that are executed from the decorated class method and that must be protected
1414
against unauthorized access.
1515
1616
The first parameter of the protected method must be `self`
17+
Args:
18+
actions: The list of actions that must be permitted to the current user.
1719
"""
1820

1921
def require_permissions_decorator(func):
@@ -26,7 +28,7 @@ def permission_checker(*args, **kwargs):
2628
)
2729

2830
return assert_permissions(
29-
resource=resource,
31+
resources=resource,
3032
actions=actions,
3133
)
3234
logger.debug(
+34-25
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
from typing import Union
23

34
from feast.feast_object import FeastObject
45
from feast.permissions.decision import DecisionEvaluator
@@ -15,48 +16,56 @@ def enforce_policy(
1516
role_manager: RoleManager,
1617
permissions: list[Permission],
1718
user: str,
18-
resource: FeastObject,
19+
resources: Union[list[FeastObject], FeastObject],
1920
actions: list[AuthzedAction],
2021
) -> tuple[bool, str]:
2122
"""
22-
Defines the logic to apply the configured permissions when a given action is requested on
23+
Define the logic to apply the configured permissions when a given action is requested on
2324
a protected resource.
2425
26+
If no permissions are defined, the result is to allow the execution.
27+
2528
Args:
2629
role_manager: The `RoleManager` instance.
2730
permissions: The configured set of `Permission`.
2831
user: The current user.
29-
resource: The resource for which we need to enforce authorized permission.
32+
resources: The resources for which we need to enforce authorized permission.
3033
actions: The requested actions to be authorized.
34+
Returns:
35+
tuple[bool, str]: a boolean with the result of the authorization check (`True` stands for allowed) and string to explain
36+
the reason for denying execution.
3137
"""
32-
3338
if not permissions:
3439
return (True, "")
3540

36-
matching_permissions = [
37-
p
38-
for p in permissions
39-
if p.match_resource(resource) and p.match_actions(actions)
40-
]
41-
42-
if matching_permissions:
43-
evaluator = DecisionEvaluator(
44-
Permission.get_global_decision_strategy(), len(matching_permissions)
41+
_resources = resources if isinstance(resources, list) else [resources]
42+
for resource in _resources:
43+
logger.debug(
44+
f"Enforcing permission policies for {type(resource)}:{resource.name} to execute {actions}"
4545
)
46-
for p in matching_permissions:
47-
permission_grant, permission_explanation = p.policy.validate_user(
48-
user=user, role_manager=role_manager
49-
)
50-
evaluator.add_grant(
51-
f"Permission ({p.name})",
52-
permission_grant,
53-
f"Permission {p.name} denied access: {permission_explanation}",
46+
matching_permissions = [
47+
p
48+
for p in permissions
49+
if p.match_resource(resource) and p.match_actions(actions)
50+
]
51+
52+
if matching_permissions:
53+
evaluator = DecisionEvaluator(
54+
Permission.get_global_decision_strategy(), len(matching_permissions)
5455
)
56+
for p in matching_permissions:
57+
permission_grant, permission_explanation = p.policy.validate_user(
58+
user=user, role_manager=role_manager
59+
)
60+
evaluator.add_grant(
61+
permission_grant,
62+
f"Permission {p.name} denied access: {permission_explanation}",
63+
)
5564

56-
if evaluator.is_decided():
57-
grant, explanations = evaluator.grant()
58-
return grant, ",".join(explanations)
65+
if evaluator.is_decided():
66+
grant, explanations = evaluator.grant()
67+
return grant, ",".join(explanations)
5968
else:
60-
message = f"No permissions defined to manage {actions} on {type(resource)}:{resource.name}."
69+
message = f"No permissions defined to manage {actions} on {resources}."
6170
logger.info(f"**PERMISSION GRANTED**: {message}")
6271
return (True, "")

‎sdk/python/feast/permissions/matcher.py

+36
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
"""
2+
This module provides utility matching functions.
3+
"""
4+
15
import logging
26
import re
37
from typing import Any, Optional, get_args
@@ -10,6 +14,14 @@
1014

1115

1216
def is_a_feast_object(resource: Any):
17+
"""
18+
A matcher to verify that a given object is one of the Feast objects defined in the `FeastObject` type.
19+
20+
Args:
21+
resource: An object instance to verify.
22+
Returns:
23+
`True` if the given object is one of the types in the FeastObject alias or a subclass of one of them.
24+
"""
1325
for t in get_args(FeastObject):
1426
# Use isinstance to pass Mock validation
1527
if isinstance(resource, t):
@@ -36,6 +48,19 @@ def resource_match_config(
3648
name_pattern: Optional[str] = None,
3749
required_tags: Optional[dict[str, str]] = None,
3850
) -> bool:
51+
"""
52+
Match a given Feast object against the configured type, name and tags in a permission configuration.
53+
54+
Args:
55+
resource: A FeastObject instance to match agains the permission.
56+
expected_types: The list of object types configured in the permission.
57+
with_subclasses: `True` if the type match includes sub-classes, `False` if the type match is exact.
58+
name_pattern: The optional name pattern filter configured in the permission.
59+
required_tags: The optional dicstionary of required tags configured in the permission.
60+
61+
Returns:
62+
bool: `True` if the resource matches the configured permission filters.
63+
"""
3964
if resource is None:
4065
logger.warning(f"None passed to {resource_match_config.__name__}")
4166
return False
@@ -118,6 +143,17 @@ def actions_match_config(
118143
actions: list[AuthzedAction],
119144
allowed_actions: list[AuthzedAction],
120145
) -> bool:
146+
"""
147+
Match a list of actions against the actions defined in a permission configuration.
148+
149+
Args:
150+
actions: Alist of actions to be executed.
151+
allowed_actions: The list of actions configured in the permission.
152+
153+
Returns:
154+
bool: `True` if all the given `actions` are defined in the `allowed_actions`.
155+
Whatever the requested `actions`, it returns `True` if `allowed_actions` includes `AuthzedAction.ALL`
156+
"""
121157
if AuthzedAction.ALL in allowed_actions:
122158
return True
123159

‎sdk/python/feast/permissions/permission.py

+20-7
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,14 @@ class Permission(ABC):
2323
requested on the matching resources.
2424
2525
Attributes:
26-
name: The permission name (can be duplicated, used for logging troubleshooting)
27-
types: The list of protected resource types as defined by the `FeastObject` type. Defaults to all managed types (e.g. the `ALL_RESOURCE_TYPES` constant)
28-
with_subclasses: If `True`, it includes subclasses of the given types in the match, otherwise only precise type match is applied. Defaults to `True`.
29-
name_pattern: a regex to match the resource name. Defaults to None, meaning that no name filtering is applied
30-
required_tags: dictionary of key-value pairs that must match the resource tags. All these required_tags must be present as resource
31-
tags with the given value. Defaults to None, meaning that no tags filtering is applied.
26+
name: The permission name (can be duplicated, used for logging troubleshooting).
27+
types: The list of protected resource types as defined by the `FeastObject` type.
28+
Defaults to all managed types (e.g. the `ALL_RESOURCE_TYPES` constant)
29+
with_subclasses: If `True`, it includes sub-classes of the given types in the match, otherwise only exact type match is applied.
30+
Defaults to `True`.
31+
name_pattern: A regex to match the resource name. Defaults to None, meaning that no name filtering is applied
32+
required_tags: Dictionary of key-value pairs that must match the resource tags. All these required_tags must
33+
be present in a resource tags with the given value. Defaults to None, meaning that no tags filtering is applied.
3234
actions: The actions authorized by this permission. Defaults to `AuthzedAction.ALL`.
3335
policy: The policy to be applied to validate a client request.
3436
"""
@@ -72,12 +74,15 @@ def __init__(
7274

7375
@staticmethod
7476
def get_global_decision_strategy() -> DecisionStrategy:
77+
"""
78+
The global decision strategy to be applied when multiple permissions match an execution request.
79+
"""
7580
return Permission._global_decision_strategy
7681

7782
@staticmethod
7883
def set_global_decision_strategy(global_decision_strategy: DecisionStrategy):
7984
"""
80-
Defines the global decision strategy to be applied if multiple permissions match the same resource.
85+
Define the global decision strategy to be applied when multiple permissions match an execution request.
8186
"""
8287
Permission._global_decision_strategy = global_decision_strategy
8388

@@ -110,6 +115,10 @@ def policy(self) -> Policy:
110115
return self._policy
111116

112117
def match_resource(self, resource: FeastObject) -> bool:
118+
"""
119+
Returns:
120+
`True` when the given resource matches the type, name and tags filters defined in the permission.
121+
"""
113122
return resource_match_config(
114123
resource=resource,
115124
expected_types=self.types,
@@ -119,6 +128,10 @@ def match_resource(self, resource: FeastObject) -> bool:
119128
)
120129

121130
def match_actions(self, actions: list[AuthzedAction]) -> bool:
131+
"""
132+
Returns:
133+
`True` when the given actions are included in the permitted actions.
134+
"""
122135
return actions_match_config(
123136
allowed_actions=self.actions,
124137
actions=actions,

‎sdk/python/feast/permissions/policy.py

+14-10
Original file line numberDiff line numberDiff line change
@@ -11,26 +11,25 @@ class Policy(ABC):
1111
@abstractmethod
1212
def validate_user(self, user: str, **kwargs) -> tuple[bool, str]:
1313
"""
14-
Converts data source config in protobuf spec to a DataSource class object.
14+
Validate the given user against the configured policy.
1515
1616
Args:
17-
data_source: A protobuf representation of a DataSource.
17+
user: The current user.
18+
kwargs: The list of keyword args to be passed to the actual implementation.
1819
1920
Returns:
20-
A DataSource class object.
21-
22-
Raises:
23-
ValueError: The type of DataSource could not be identified.
21+
bool: `True` if the user matches the policy criteria, `False` otherwise.
22+
str: A possibly empty explanation of the reason for not matching the configured policy.
2423
"""
2524
raise NotImplementedError
2625

2726

2827
class RoleBasedPolicy(Policy):
2928
"""
30-
An Policy class where the user roles must be enforced to grant access to the requested action.
31-
All the configured roles must be granted to the current user in order to allow the execution.
29+
A `Policy` implementation where the user roles must be enforced to grant access to the requested action.
30+
At least one of the configured roles must be granted to the current user in order to allow the execution of the secured operation.
3231
33-
The `role_manager` keywork argument must be present in the `kwargs` optional key-value arguments.
32+
E.g., if the policy enforces roles `a` and `b`, the user must have at least one of them in order to satisfy the policy.
3433
"""
3534

3635
def __init__(
@@ -43,6 +42,11 @@ def get_roles(self) -> list[str]:
4342
return self.roles
4443

4544
def validate_user(self, user: str, **kwargs) -> tuple[bool, str]:
45+
"""
46+
Validate the given `user` against the configured roles.
47+
48+
The `role_manager` keywork argument must be present in the `kwargs` optional key-value arguments.
49+
"""
4650
if "role_manager" not in kwargs:
4751
raise ValueError("Missing keywork argument 'role_manager'")
4852
if not isinstance(kwargs["role_manager"], RoleManager):
@@ -51,7 +55,7 @@ def validate_user(self, user: str, **kwargs) -> tuple[bool, str]:
5155
)
5256
rm = kwargs.get("role_manager")
5357
if isinstance(rm, RoleManager):
54-
result = rm.has_roles_for_user(user, self.roles)
58+
result = rm.user_has_matching_role(user, self.roles)
5559
explain = "" if result else f"Requires roles {self.roles}"
5660
return (result, explain)
5761

0 commit comments

Comments
 (0)