Skip to content

Commit 7b9fdd8

Browse files
authored
Merge pull request feast-dev#18 from dmartinol/feast-rbac
Parametrized tests
2 parents 8cbb18b + 648b064 commit 7b9fdd8

7 files changed

Lines changed: 314 additions & 290 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import logging
2+
from typing import Union
3+
4+
from feast.permissions.permission import AuthzedAction, is_of_expected_type
5+
from feast.permissions.security_manager import get_security_manager
6+
7+
logger = logging.getLogger(__name__)
8+
9+
10+
def require_permissions(actions: Union[list[AuthzedAction], AuthzedAction]):
11+
"""
12+
A decorator to define the actions that are executed from within the current class method and that must be protected
13+
against unauthorized access.
14+
15+
The first parameter of the protected method must be `self`
16+
"""
17+
18+
def require_permissions_decorator(func):
19+
def permission_checker(*args, **kwargs):
20+
logger.debug(f"permission_checker for {args}, {kwargs}")
21+
resource = args[0]
22+
if not is_of_expected_type(resource):
23+
raise NotImplementedError(
24+
f"The first argument is not of a managed type but {type(resource)}"
25+
)
26+
27+
sm = get_security_manager()
28+
if sm is None:
29+
return True
30+
31+
sm.assert_permissions(
32+
resource=resource,
33+
actions=actions,
34+
)
35+
logger.debug(
36+
f"User {sm.current_user} can invoke {actions} on {resource.name}:{type(resource)} "
37+
)
38+
result = func(*args, **kwargs)
39+
return result
40+
41+
return permission_checker
42+
43+
return require_permissions_decorator

sdk/python/feast/permissions/security_manager.py

Lines changed: 1 addition & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,48 +4,12 @@
44

55
from feast.feast_object import FeastObject
66
from feast.permissions.enforcer import enforce_policy
7-
from feast.permissions.permission import AuthzedAction, Permission, is_of_expected_type
7+
from feast.permissions.permission import AuthzedAction, Permission
88
from feast.permissions.role_manager import RoleManager
99

1010
logger = logging.getLogger(__name__)
1111

1212

13-
def require_permissions(actions: Union[list[AuthzedAction], AuthzedAction]):
14-
"""
15-
A decorator to define the actions that are executed from within the current class method and that must be protected
16-
against unauthorized access.
17-
18-
The first parameter of the protected method must be `self`
19-
"""
20-
21-
def require_permissions_decorator(func):
22-
def permission_checker(*args, **kwargs):
23-
logger.debug(f"permission_checker for {args}, {kwargs}")
24-
resource = args[0]
25-
if not is_of_expected_type(resource):
26-
raise NotImplementedError(
27-
f"The first argument is not of a managed type but {type(resource)}"
28-
)
29-
30-
sm = get_security_manager()
31-
if sm is None:
32-
return True
33-
34-
sm.assert_permissions(
35-
resource=resource,
36-
actions=actions,
37-
)
38-
logger.debug(
39-
f"User {sm.current_user} can invoke {actions} on {resource.name}:{type(resource)} "
40-
)
41-
result = func(*args, **kwargs)
42-
return result
43-
44-
return permission_checker
45-
46-
return require_permissions_decorator
47-
48-
4913
class SecurityManager:
5014
"""
5115
The security manager holds references to the security components (role manager, policy enforces) and the configured permissions.
Lines changed: 70 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,111 +1,78 @@
11
import assertpy
2+
import pytest
23

34
from feast.permissions.decision import DecisionEvaluator, DecisionStrategy
45

6+
# Each vote is a tuple of `current_vote` and expected output of `is_decided`
57

6-
def test_affirmative():
7-
evaluator = DecisionEvaluator(DecisionStrategy.AFFIRMATIVE, 3)
8-
evaluator.add_grant("vote", True, "")
9-
assertpy.assert_that(evaluator.is_decided()).is_true()
10-
assertpy.assert_that(evaluator.grant()).is_equal_to((True, []))
118

12-
evaluator = DecisionEvaluator(DecisionStrategy.AFFIRMATIVE, 3)
13-
evaluator.add_grant("vote", False, "a message")
14-
assertpy.assert_that(evaluator.is_decided()).is_false()
15-
evaluator.add_grant("vote", False, "a message")
16-
assertpy.assert_that(evaluator.is_decided()).is_false()
17-
evaluator.add_grant("vote", False, "a message")
18-
assertpy.assert_that(evaluator.is_decided()).is_true()
19-
grant, explanations = evaluator.grant()
20-
assertpy.assert_that(grant).is_equal_to(False)
21-
assertpy.assert_that(explanations).is_length(3)
22-
23-
24-
def test_unanimous():
25-
evaluator = DecisionEvaluator(DecisionStrategy.UNANIMOUS, 3)
26-
evaluator.add_grant("vote", True, "")
27-
assertpy.assert_that(evaluator.is_decided()).is_false()
28-
evaluator.add_grant("vote", True, "")
29-
assertpy.assert_that(evaluator.is_decided()).is_false()
30-
evaluator.add_grant("vote", True, "")
31-
assertpy.assert_that(evaluator.is_decided()).is_true()
32-
assertpy.assert_that(evaluator.grant()).is_equal_to((True, []))
33-
34-
evaluator = DecisionEvaluator(DecisionStrategy.UNANIMOUS, 3)
35-
evaluator.add_grant("vote", True, "")
36-
assertpy.assert_that(evaluator.is_decided()).is_false()
37-
evaluator.add_grant("vote", False, "a message")
38-
assertpy.assert_that(evaluator.is_decided()).is_true()
39-
grant, explanations = evaluator.grant()
40-
assertpy.assert_that(grant).is_equal_to(False)
41-
assertpy.assert_that(explanations).is_length(1)
42-
43-
44-
def test_consensus():
45-
evaluator = DecisionEvaluator(DecisionStrategy.CONSENSUS, 1)
46-
evaluator.add_grant("vote", True, "")
47-
assertpy.assert_that(evaluator.is_decided()).is_true()
48-
assertpy.assert_that(evaluator.grant()).is_equal_to((True, []))
49-
50-
evaluator = DecisionEvaluator(DecisionStrategy.CONSENSUS, 1)
51-
evaluator.add_grant("vote", False, "a message")
52-
assertpy.assert_that(evaluator.is_decided()).is_true()
53-
grant, explanations = evaluator.grant()
54-
assertpy.assert_that(grant).is_equal_to(False)
55-
assertpy.assert_that(explanations).is_length(1)
56-
57-
evaluator = DecisionEvaluator(DecisionStrategy.CONSENSUS, 5)
58-
evaluator.add_grant("vote", True, "")
59-
assertpy.assert_that(evaluator.is_decided()).is_false()
60-
evaluator.add_grant("vote", False, "a message")
61-
assertpy.assert_that(evaluator.is_decided()).is_false()
62-
evaluator.add_grant("vote", False, "a message")
63-
assertpy.assert_that(evaluator.is_decided()).is_false()
64-
evaluator.add_grant("vote", True, "")
65-
assertpy.assert_that(evaluator.is_decided()).is_false()
66-
evaluator.add_grant("vote", True, "")
67-
assertpy.assert_that(evaluator.is_decided()).is_true()
68-
grant, explanations = evaluator.grant()
69-
assertpy.assert_that(grant).is_equal_to(True)
70-
assertpy.assert_that(explanations).is_length(2)
71-
72-
evaluator = DecisionEvaluator(DecisionStrategy.CONSENSUS, 5)
73-
evaluator.add_grant("vote", True, "")
74-
assertpy.assert_that(evaluator.is_decided()).is_false()
75-
evaluator.add_grant("vote", False, "a message")
76-
assertpy.assert_that(evaluator.is_decided()).is_false()
77-
evaluator.add_grant("vote", False, "a message")
78-
assertpy.assert_that(evaluator.is_decided()).is_false()
79-
evaluator.add_grant("vote", False, "a message")
80-
assertpy.assert_that(evaluator.is_decided()).is_true()
81-
grant, explanations = evaluator.grant()
82-
assertpy.assert_that(grant).is_equal_to(False)
83-
assertpy.assert_that(explanations).is_length(3)
84-
85-
86-
def test_additional_votes_are_discarded():
87-
evaluator = DecisionEvaluator(DecisionStrategy.UNANIMOUS, 2)
88-
evaluator.add_grant("vote", True, "")
89-
assertpy.assert_that(evaluator.is_decided()).is_false()
90-
evaluator.add_grant("vote", True, "")
91-
assertpy.assert_that(evaluator.is_decided()).is_true()
92-
evaluator.add_grant("vote", False, "a message")
93-
assertpy.assert_that(evaluator.is_decided()).is_true()
94-
evaluator.add_grant("vote", False, "a message")
95-
assertpy.assert_that(evaluator.is_decided()).is_true()
96-
evaluator.add_grant("vote", False, "a message")
97-
assertpy.assert_that(evaluator.is_decided()).is_true()
98-
assertpy.assert_that(evaluator.grant()).is_equal_to((True, []))
9+
@pytest.mark.parametrize(
10+
"evaluator, votes, decision, no_of_explanations",
11+
[
12+
(DecisionEvaluator(DecisionStrategy.AFFIRMATIVE, 3), [(True, True)], True, 0),
13+
(DecisionEvaluator(DecisionStrategy.AFFIRMATIVE, 3), [(True, True)], True, 0),
14+
(
15+
DecisionEvaluator(DecisionStrategy.AFFIRMATIVE, 3),
16+
[(False, False), (False, False), (False, True)],
17+
False,
18+
3,
19+
),
20+
(
21+
DecisionEvaluator(DecisionStrategy.UNANIMOUS, 3),
22+
[(True, False), (True, False), (True, True)],
23+
True,
24+
0,
25+
),
26+
(
27+
DecisionEvaluator(DecisionStrategy.UNANIMOUS, 3),
28+
[(True, False), (False, True)],
29+
False,
30+
1,
31+
),
32+
(DecisionEvaluator(DecisionStrategy.CONSENSUS, 1), [(True, True)], True, 0),
33+
(DecisionEvaluator(DecisionStrategy.CONSENSUS, 1), [(False, True)], False, 1),
34+
(
35+
DecisionEvaluator(DecisionStrategy.CONSENSUS, 5),
36+
[
37+
(True, False),
38+
(False, False),
39+
(False, False),
40+
(True, False),
41+
(True, True),
42+
],
43+
True,
44+
2,
45+
),
46+
(
47+
DecisionEvaluator(DecisionStrategy.CONSENSUS, 5),
48+
[(True, False), (False, False), (False, False), (False, True)],
49+
False,
50+
3,
51+
),
52+
(
53+
DecisionEvaluator(DecisionStrategy.UNANIMOUS, 2),
54+
[(True, False), (True, True), (False, True), (False, True), (False, True)],
55+
True,
56+
0,
57+
),
58+
(
59+
DecisionEvaluator(DecisionStrategy.UNANIMOUS, 2),
60+
[(False, True), (False, True), (False, True), (False, True), (False, True)],
61+
False,
62+
1,
63+
),
64+
],
65+
)
66+
def test_decision_evaluator(evaluator, votes, decision, no_of_explanations):
67+
for v in votes:
68+
vote = v[0]
69+
decided = v[1]
70+
evaluator.add_grant("vote", vote, "" if vote else "a message")
71+
if decided:
72+
assertpy.assert_that(evaluator.is_decided()).is_true()
73+
else:
74+
assertpy.assert_that(evaluator.is_decided()).is_false()
9975

100-
evaluator = DecisionEvaluator(DecisionStrategy.UNANIMOUS, 2)
101-
evaluator.add_grant("vote", False, "a message")
102-
assertpy.assert_that(evaluator.is_decided()).is_true()
103-
evaluator.add_grant("vote", False, "a message")
104-
assertpy.assert_that(evaluator.is_decided()).is_true()
105-
evaluator.add_grant("vote", False, "a message")
106-
assertpy.assert_that(evaluator.is_decided()).is_true()
107-
evaluator.add_grant("vote", False, "a message")
108-
assertpy.assert_that(evaluator.is_decided()).is_true()
10976
grant, explanations = evaluator.grant()
110-
assertpy.assert_that(grant).is_equal_to(False)
111-
assertpy.assert_that(explanations).is_length(1)
77+
assertpy.assert_that(grant).is_equal_to(decision)
78+
assertpy.assert_that(explanations).is_length(no_of_explanations)

sdk/python/tests/unit/permissions/test_security_manager.py renamed to sdk/python/tests/unit/permissions/test_decorator.py

Lines changed: 36 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44
import pytest
55

66
from feast import FeatureView
7+
from feast.permissions.decorator import require_permissions
78
from feast.permissions.permission import AuthzedAction, Permission
89
from feast.permissions.policy import RoleBasedPolicy
910
from feast.permissions.role_manager import RoleManager
1011
from feast.permissions.security_manager import (
1112
SecurityManager,
12-
require_permissions,
1313
set_security_manager,
1414
)
1515

@@ -26,6 +26,10 @@ def __init__(self, name, tags):
2626
def read_protected(self) -> bool:
2727
return True
2828

29+
@require_permissions(actions=[AuthzedAction.WRITE])
30+
def write_protected(self) -> bool:
31+
return True
32+
2933
def unprotected(self) -> bool:
3034
return True
3135

@@ -42,6 +46,15 @@ def security_manager() -> SecurityManager:
4246
actions=[AuthzedAction.READ],
4347
)
4448
)
49+
permissions.append(
50+
Permission(
51+
name="writer",
52+
types=FeatureView,
53+
with_subclasses=True,
54+
policies=[RoleBasedPolicy(roles=["writer"])],
55+
actions=[AuthzedAction.WRITE],
56+
)
57+
)
4558

4659
rm = RoleManager()
4760
rm.add_roles_for_user("r", ["reader"])
@@ -57,34 +70,30 @@ def feature_view() -> FeatureView:
5770
return SecuredFeatureView("secured", {})
5871

5972

60-
def test_no_user(security_manager, feature_view):
61-
fv = feature_view
62-
63-
with pytest.raises(PermissionError):
64-
fv.read_protected()
65-
assertpy.assert_that(fv.unprotected()).is_true()
66-
67-
68-
def test_reader_user(security_manager, feature_view):
73+
@pytest.mark.parametrize(
74+
"user, can_read, can_write",
75+
[
76+
(None, False, False),
77+
("r", True, False),
78+
("w", False, True),
79+
("rw", True, True),
80+
],
81+
)
82+
def test_access_SecuredFeatureView(
83+
security_manager, feature_view, user, can_read, can_write
84+
):
6985
sm = security_manager
7086
fv = feature_view
71-
sm.set_current_user("r")
72-
fv.read_protected()
73-
assertpy.assert_that(fv.unprotected()).is_true()
74-
7587

76-
def test_writer_user(security_manager, feature_view):
77-
sm = security_manager
78-
fv = feature_view
79-
sm.set_current_user("w")
80-
with pytest.raises(PermissionError):
88+
sm.set_current_user(user)
89+
if can_read:
8190
fv.read_protected()
82-
assertpy.assert_that(fv.unprotected()).is_true()
83-
84-
85-
def test_reader_writer_user(security_manager, feature_view):
86-
sm = security_manager
87-
fv = feature_view
88-
sm.set_current_user("rw")
89-
fv.read_protected()
91+
else:
92+
with pytest.raises(PermissionError):
93+
fv.read_protected()
94+
if can_write:
95+
fv.write_protected()
96+
else:
97+
with pytest.raises(PermissionError):
98+
fv.write_protected()
9099
assertpy.assert_that(fv.unprotected()).is_true()

0 commit comments

Comments
 (0)