From 80e50affc1b36925c4188e8f7405e1bf4e9e5c38 Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Thu, 3 Sep 2026 18:16:57 +0200 Subject: [PATCH 1/2] fix(feature_flags): missing context key never satisfies a condition Conditions read the context with context.get(key), so an absent key was compared as None. Negative actions (NOT_EQUALS, NOT_IN, KEY_NOT_IN_VALUE, VALUE_NOT_IN_KEY) therefore matched requests that carried no such key at all, firing rules meant for one segment on anonymous or partially populated traffic. This is a regression from ab9078c5 (#2052). Until v2.11.0 _match_by_action returned False for any falsy context value, so a missing key never matched. That guard was removed to allow falsy values such as 0 and "" to be compared, and allowing absent keys to match was an unintended side effect. Restore the original semantics for missing keys by checking key presence before invoking the comparator, while keeping the #2051 behaviour for keys that are present with a falsy or None value. Time based actions are unaffected since they never read the user context. Document the behaviour in the rule actions section. Fixes #8424 --- .../utilities/feature_flags/feature_flags.py | 14 +++- docs/utilities/feature_flags.md | 2 + .../_boto3/test_feature_flags.py | 65 +++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py index 19e96a8641d..487e9c3b9a6 100644 --- a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py +++ b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py @@ -115,7 +115,7 @@ def _evaluate_conditions( return False for condition in conditions: - context_value = context.get(condition.get(schema.CONDITION_KEY, "")) + cond_key = condition.get(schema.CONDITION_KEY, "") cond_action = condition.get(schema.CONDITION_ACTION, "") cond_value = condition.get(schema.CONDITION_VALUE) @@ -125,7 +125,17 @@ def _evaluate_conditions( schema.RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value, schema.RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value, ): - context_value = condition.get(schema.CONDITION_KEY) # e.g., CURRENT_TIME + context_value = cond_key # e.g., CURRENT_TIME + elif cond_key not in context: + # A key absent from the context never satisfies a condition. Without this guard, `None` + # would be compared as a regular value and negative actions (NOT_EQUALS, NOT_IN, ...) would match. + self.logger.debug( + f"rule did not match, context key not found, rule_name={rule_name}, " + f"rule_value={rule_match_value}, name={feature_name}, key={cond_key}", + ) + return False + else: + context_value = context[cond_key] if not self._match_by_action(action=cond_action, condition_value=cond_value, context_value=context_value): self.logger.debug( diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 2d95e025b06..22e7d623bda 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -444,6 +444,8 @@ The `action` configuration can have the following values, where the expressions ???+ info The `key` and `value` will be compared to the input from the `context` parameter. + 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. + ???+ "Time based keys" 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. diff --git a/tests/functional/feature_flags/_boto3/test_feature_flags.py b/tests/functional/feature_flags/_boto3/test_feature_flags.py index a4d271aba57..824bb75a2ba 100644 --- a/tests/functional/feature_flags/_boto3/test_feature_flags.py +++ b/tests/functional/feature_flags/_boto3/test_feature_flags.py @@ -883,6 +883,71 @@ def test_flags_not_equal_match(mocker, config): assert toggle == expected_value +@pytest.mark.parametrize( + "action, value", + [ + (RuleAction.NOT_EQUALS.value, "premium"), + (RuleAction.NOT_IN.value, ["premium", "enterprise"]), + (RuleAction.KEY_NOT_IN_VALUE.value, ["premium", "enterprise"]), + (RuleAction.VALUE_NOT_IN_KEY.value, "premium"), + ], +) +def test_flags_negative_action_no_match_when_context_key_missing(mocker, config, action, value): + # GIVEN a rule with a negative action on key "tier" + mocked_app_config_schema = { + "my_feature": { + "default": False, + "rules": { + "non premium users": { + "when_match": True, + "conditions": [ + { + "action": action, + "key": "tier", + "value": value, + }, + ], + }, + }, + }, + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + + # WHEN evaluating with a context that doesn't carry "tier" at all + toggle = feature_flags.evaluate(name="my_feature", context={"username": "a"}, default=False) + + # THEN the rule must not match; a missing key never satisfies a condition + assert toggle is False + + +def test_flags_not_equal_match_when_context_key_is_none(mocker, config): + # GIVEN a NOT_EQUALS rule on key "tier" + mocked_app_config_schema = { + "my_feature": { + "default": False, + "rules": { + "non premium users": { + "when_match": True, + "conditions": [ + { + "action": RuleAction.NOT_EQUALS.value, + "key": "tier", + "value": "premium", + }, + ], + }, + }, + }, + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + + # WHEN the key is present but explicitly set to None + toggle = feature_flags.evaluate(name="my_feature", context={"tier": None}, default=False) + + # THEN the value is still compared as usual (None != "premium"), so the rule matches + assert toggle is True + + # Test less than def test_flags_less_than_no_match_1(mocker, config): expected_value = False From 95094f96b8b6cbfbc5d023aaadf13be8d14127fb Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Thu, 3 Sep 2026 18:54:49 +0200 Subject: [PATCH 2/2] fix(feature_flags): keep exception handlers reachable for missing context keys The missing-key guard returned False before the comparator ran, so a comparator that raises for a None context value (ANY_IN_VALUE, ALL_IN_VALUE, NONE_IN_VALUE) never reached a registered validation_exception_handler. That was a regression from develop, where the handler was called and its result used. Run the comparator first and only then apply the missing-key rule. Any exception still flows through the existing handler lookup unchanged; the only difference from develop is that a comparator returning True for an absent key now yields False. --- .../utilities/feature_flags/feature_flags.py | 41 ++++++++---- .../_boto3/test_feature_flags.py | 64 +++++++++++++++++++ 2 files changed, 92 insertions(+), 13 deletions(-) diff --git a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py index 487e9c3b9a6..30e1b77872b 100644 --- a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py +++ b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py @@ -82,10 +82,26 @@ def __init__(self, store: StoreProvider, logger: logging.Logger | Logger | None self.logger = logger or logging.getLogger(__name__) self._exception_handlers: dict[Exception, Callable] = {} - def _match_by_action(self, action: str, condition_value: Any, context_value: Any) -> bool: + def _match_by_action( + self, + action: str, + condition_value: Any, + context_value: Any, + context_key_present: bool = True, + ) -> bool: try: func = RULE_ACTION_MAPPING.get(action, lambda a, b: False) - return func(context_value, condition_value) + matched = func(context_value, condition_value) + + if not context_key_present: + # A key absent from the context never satisfies a condition. Without this, `None` would be + # compared as a regular value and negative actions (NOT_EQUALS, NOT_IN, ...) would match. + # We still run the comparator first so that any exception it raises (e.g. ANY_IN_VALUE on a + # non-list) reaches registered validation exception handlers exactly as it did before. + self.logger.debug(f"context key not present, condition does not match: action={action}") + return False + + return matched except Exception as exc: self.logger.debug(f"caught exception while matching action: action={action}, exception={str(exc)}") @@ -118,6 +134,7 @@ def _evaluate_conditions( cond_key = condition.get(schema.CONDITION_KEY, "") cond_action = condition.get(schema.CONDITION_ACTION, "") cond_value = condition.get(schema.CONDITION_VALUE) + context_key_present = True # time based rule actions have no user context. the context is the condition key if cond_action in ( @@ -126,18 +143,16 @@ def _evaluate_conditions( schema.RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value, ): context_value = cond_key # e.g., CURRENT_TIME - elif cond_key not in context: - # A key absent from the context never satisfies a condition. Without this guard, `None` - # would be compared as a regular value and negative actions (NOT_EQUALS, NOT_IN, ...) would match. - self.logger.debug( - f"rule did not match, context key not found, rule_name={rule_name}, " - f"rule_value={rule_match_value}, name={feature_name}, key={cond_key}", - ) - return False else: - context_value = context[cond_key] - - if not self._match_by_action(action=cond_action, condition_value=cond_value, context_value=context_value): + context_key_present = cond_key in context + context_value = context.get(cond_key) + + if not self._match_by_action( + action=cond_action, + condition_value=cond_value, + context_value=context_value, + context_key_present=context_key_present, + ): self.logger.debug( f"rule did not match action, rule_name={rule_name}, rule_value={rule_match_value}, " f"name={feature_name}, context_value={str(context_value)} ", diff --git a/tests/functional/feature_flags/_boto3/test_feature_flags.py b/tests/functional/feature_flags/_boto3/test_feature_flags.py index 824bb75a2ba..db221c08739 100644 --- a/tests/functional/feature_flags/_boto3/test_feature_flags.py +++ b/tests/functional/feature_flags/_boto3/test_feature_flags.py @@ -1767,3 +1767,67 @@ def catch_exception(exc): context={"tenant_id": "not a list value"}, default=False, ) + + +def test_flags_missing_context_key_still_invokes_validation_exception_handler(mocker, config): + # GIVEN an ANY_IN_VALUE rule and a handler registered for the ValueError the comparator raises + # when the context value is not a list + mocked_app_config_schema = { + "my_feature": { + "default": False, + "rules": { + "tenant_id is in allowed list": { + "when_match": True, + "conditions": [ + { + "action": RuleAction.ANY_IN_VALUE.value, + "key": "tenant_id", + "value": ["Akua", "John", "Maria", "Pat"], + }, + ], + }, + }, + }, + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + handled = [] + + @feature_flags.validation_exception_handler(ValueError) + def handle_invalid_context(exc): + handled.append(exc) + return True + + # WHEN the context does not carry the key at all + toggle = feature_flags.evaluate(name="my_feature", context={}, default=False) + + # THEN the handler is still called and its result is honoured, as it was before missing keys were guarded + assert len(handled) == 1 + assert toggle is True + + +def test_flags_missing_context_key_no_match_without_handler_for_raising_action(mocker, config): + # GIVEN an ANY_IN_VALUE rule and no exception handler registered + mocked_app_config_schema = { + "my_feature": { + "default": False, + "rules": { + "tenant_id is in allowed list": { + "when_match": True, + "conditions": [ + { + "action": RuleAction.ANY_IN_VALUE.value, + "key": "tenant_id", + "value": ["Akua", "John", "Maria", "Pat"], + }, + ], + }, + }, + }, + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + + # WHEN the context does not carry the key at all + toggle = feature_flags.evaluate(name="my_feature", context={}, default=False) + + # THEN the rule does not match + assert toggle is False