Skip to content

Commit 237f4db

Browse files
authored
fix(feature_flags): missing context key never satisfies a condition (#8429)
1 parent 3fff06f commit 237f4db

3 files changed

Lines changed: 162 additions & 6 deletions

File tree

‎aws_lambda_powertools/utilities/feature_flags/feature_flags.py‎

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,26 @@ def __init__(self, store: StoreProvider, logger: logging.Logger | Logger | None
8585
# recycled by a different object, which lets us safely skip re-validation on store cache hits.
8686
self._last_validated_config: dict | None = None
8787

88-
def _match_by_action(self, action: str, condition_value: Any, context_value: Any) -> bool:
88+
def _match_by_action(
89+
self,
90+
action: str,
91+
condition_value: Any,
92+
context_value: Any,
93+
context_key_present: bool = True,
94+
) -> bool:
8995
try:
9096
func = RULE_ACTION_MAPPING.get(action, lambda a, b: False)
91-
return func(context_value, condition_value)
97+
matched = func(context_value, condition_value)
98+
99+
if not context_key_present:
100+
# A key absent from the context never satisfies a condition. Without this, `None` would be
101+
# compared as a regular value and negative actions (NOT_EQUALS, NOT_IN, ...) would match.
102+
# We still run the comparator first so that any exception it raises (e.g. ANY_IN_VALUE on a
103+
# non-list) reaches registered validation exception handlers exactly as it did before.
104+
self.logger.debug(f"context key not present, condition does not match: action={action}")
105+
return False
106+
107+
return matched
92108
except Exception as exc:
93109
self.logger.debug(f"caught exception while matching action: action={action}, exception={str(exc)}")
94110

@@ -118,19 +134,28 @@ def _evaluate_conditions(
118134
return False
119135

120136
for condition in conditions:
121-
context_value = context.get(condition.get(schema.CONDITION_KEY, ""))
137+
cond_key = condition.get(schema.CONDITION_KEY, "")
122138
cond_action = condition.get(schema.CONDITION_ACTION, "")
123139
cond_value = condition.get(schema.CONDITION_VALUE)
140+
context_key_present = True
124141

125142
# time based rule actions have no user context. the context is the condition key
126143
if cond_action in (
127144
schema.RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value,
128145
schema.RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value,
129146
schema.RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value,
130147
):
131-
context_value = condition.get(schema.CONDITION_KEY) # e.g., CURRENT_TIME
132-
133-
if not self._match_by_action(action=cond_action, condition_value=cond_value, context_value=context_value):
148+
context_value = cond_key # e.g., CURRENT_TIME
149+
else:
150+
context_key_present = cond_key in context
151+
context_value = context.get(cond_key)
152+
153+
if not self._match_by_action(
154+
action=cond_action,
155+
condition_value=cond_value,
156+
context_value=context_value,
157+
context_key_present=context_key_present,
158+
):
134159
self.logger.debug(
135160
f"rule did not match action, rule_name={rule_name}, rule_value={rule_match_value}, "
136161
f"name={feature_name}, context_value={str(context_value)} ",

‎docs/utilities/feature_flags.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,8 @@ The `action` configuration can have the following values, where the expressions
447447
???+ info
448448
The `key` and `value` will be compared to the input from the `context` parameter.
449449

450+
If a condition's `key` is not present in `context`, the condition never matches, regardless of the action. For example, a `NOT_EQUALS` rule on `tier` will not match a request that carries no `tier` at all.
451+
450452
???+ "Time based keys"
451453

452454
For time based keys, we provide a list of predefined keys. These will automatically get converted to the corresponding timestamp on each invocation of your Lambda function.

‎tests/functional/feature_flags/_boto3/test_feature_flags.py‎

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -883,6 +883,71 @@ def test_flags_not_equal_match(mocker, config):
883883
assert toggle == expected_value
884884

885885

886+
@pytest.mark.parametrize(
887+
"action, value",
888+
[
889+
(RuleAction.NOT_EQUALS.value, "premium"),
890+
(RuleAction.NOT_IN.value, ["premium", "enterprise"]),
891+
(RuleAction.KEY_NOT_IN_VALUE.value, ["premium", "enterprise"]),
892+
(RuleAction.VALUE_NOT_IN_KEY.value, "premium"),
893+
],
894+
)
895+
def test_flags_negative_action_no_match_when_context_key_missing(mocker, config, action, value):
896+
# GIVEN a rule with a negative action on key "tier"
897+
mocked_app_config_schema = {
898+
"my_feature": {
899+
"default": False,
900+
"rules": {
901+
"non premium users": {
902+
"when_match": True,
903+
"conditions": [
904+
{
905+
"action": action,
906+
"key": "tier",
907+
"value": value,
908+
},
909+
],
910+
},
911+
},
912+
},
913+
}
914+
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
915+
916+
# WHEN evaluating with a context that doesn't carry "tier" at all
917+
toggle = feature_flags.evaluate(name="my_feature", context={"username": "a"}, default=False)
918+
919+
# THEN the rule must not match; a missing key never satisfies a condition
920+
assert toggle is False
921+
922+
923+
def test_flags_not_equal_match_when_context_key_is_none(mocker, config):
924+
# GIVEN a NOT_EQUALS rule on key "tier"
925+
mocked_app_config_schema = {
926+
"my_feature": {
927+
"default": False,
928+
"rules": {
929+
"non premium users": {
930+
"when_match": True,
931+
"conditions": [
932+
{
933+
"action": RuleAction.NOT_EQUALS.value,
934+
"key": "tier",
935+
"value": "premium",
936+
},
937+
],
938+
},
939+
},
940+
},
941+
}
942+
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
943+
944+
# WHEN the key is present but explicitly set to None
945+
toggle = feature_flags.evaluate(name="my_feature", context={"tier": None}, default=False)
946+
947+
# THEN the value is still compared as usual (None != "premium"), so the rule matches
948+
assert toggle is True
949+
950+
886951
# Test less than
887952
def test_flags_less_than_no_match_1(mocker, config):
888953
expected_value = False
@@ -1704,6 +1769,70 @@ def catch_exception(exc):
17041769
)
17051770

17061771

1772+
def test_flags_missing_context_key_still_invokes_validation_exception_handler(mocker, config):
1773+
# GIVEN an ANY_IN_VALUE rule and a handler registered for the ValueError the comparator raises
1774+
# when the context value is not a list
1775+
mocked_app_config_schema = {
1776+
"my_feature": {
1777+
"default": False,
1778+
"rules": {
1779+
"tenant_id is in allowed list": {
1780+
"when_match": True,
1781+
"conditions": [
1782+
{
1783+
"action": RuleAction.ANY_IN_VALUE.value,
1784+
"key": "tenant_id",
1785+
"value": ["Akua", "John", "Maria", "Pat"],
1786+
},
1787+
],
1788+
},
1789+
},
1790+
},
1791+
}
1792+
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
1793+
handled = []
1794+
1795+
@feature_flags.validation_exception_handler(ValueError)
1796+
def handle_invalid_context(exc):
1797+
handled.append(exc)
1798+
return True
1799+
1800+
# WHEN the context does not carry the key at all
1801+
toggle = feature_flags.evaluate(name="my_feature", context={}, default=False)
1802+
1803+
# THEN the handler is still called and its result is honoured, as it was before missing keys were guarded
1804+
assert len(handled) == 1
1805+
assert toggle is True
1806+
1807+
1808+
def test_flags_missing_context_key_no_match_without_handler_for_raising_action(mocker, config):
1809+
# GIVEN an ANY_IN_VALUE rule and no exception handler registered
1810+
mocked_app_config_schema = {
1811+
"my_feature": {
1812+
"default": False,
1813+
"rules": {
1814+
"tenant_id is in allowed list": {
1815+
"when_match": True,
1816+
"conditions": [
1817+
{
1818+
"action": RuleAction.ANY_IN_VALUE.value,
1819+
"key": "tenant_id",
1820+
"value": ["Akua", "John", "Maria", "Pat"],
1821+
},
1822+
],
1823+
},
1824+
},
1825+
},
1826+
}
1827+
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
1828+
1829+
# WHEN the context does not carry the key at all
1830+
toggle = feature_flags.evaluate(name="my_feature", context={}, default=False)
1831+
1832+
# THEN the rule does not match
1833+
assert toggle is False
1834+
1835+
17071836
# Test schema validation is performed once per fetched document (#8426)
17081837
def test_schema_validated_once_for_cached_document(mocker, config):
17091838
# GIVEN a store that serves the same document object on every call (e.g. a Parameters cache hit)

0 commit comments

Comments
 (0)