From d6e3415ffbe4e6de3680fd7e889c49bd93adeb9d Mon Sep 17 00:00:00 2001 From: Niklas Zender Date: Tue, 31 Mar 2026 23:56:13 +0200 Subject: [PATCH] fix: prevent early return in PathList lookup when intermediate path fails When `get_path_in_dict` received a PathList (list of lists) and the first path encountered a missing key at an intermediate segment, the function returned `None` immediately instead of trying the remaining paths. The root cause was `return None` inside the inner loop's `not isinstance(r, dict)` guard. This aborted the entire outer loop rather than just the current path. Replaced with `break` and moved the result check into a `for/else` block so that only fully traversed paths are considered. Made-with: Cursor --- synapse_token_authenticator/utils.py | 7 ++-- tests/test_sta_utils.py | 56 ++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/synapse_token_authenticator/utils.py b/synapse_token_authenticator/utils.py index ebaf7e2..cc9a4db 100644 --- a/synapse_token_authenticator/utils.py +++ b/synapse_token_authenticator/utils.py @@ -60,10 +60,11 @@ def get_path_in_dict(path: str | List[str] | List[List[str]], d: Any) -> Optiona r = d for segment in p: if not isinstance(r, dict): - return None + break r = r.get(segment) - if r is not None: - return r + else: + if r is not None: + return r return None diff --git a/tests/test_sta_utils.py b/tests/test_sta_utils.py index 1813f05..64ca37a 100644 --- a/tests/test_sta_utils.py +++ b/tests/test_sta_utils.py @@ -38,6 +38,62 @@ def test_get_path_in_dict(): assert get_path_in_dict([["foo", "loo"], []], {"foo": {"loo": 3}}) == 3 +def test_get_path_in_dict_pathlist_fallback_on_missing_key(): + """When the first path's key is entirely absent, later paths must still be tried.""" + assert ( + get_path_in_dict([["missing", "sub"], ["foo", "bar"]], {"foo": {"bar": 3}}) == 3 + ) + assert ( + get_path_in_dict([["a", "b"], ["c", "d"], ["e", "f"]], {"e": {"f": 42}}) == 42 + ) + assert ( + get_path_in_dict( + [["missing", "sub"], ["also_missing", "sub"]], {"foo": {"bar": 3}} + ) + is None + ) + + +def test_get_path_in_dict_pathlist_non_dict_intermediate(): + """When an intermediate value is a non-dict (e.g. int), later paths must still be tried.""" + assert ( + get_path_in_dict( + [["foo", "bar"], ["baz", "qux"]], {"foo": 42, "baz": {"qux": 7}} + ) + == 7 + ) + assert ( + get_path_in_dict( + [["a", "b", "c"], ["x", "y"]], + {"a": {"b": "not_a_dict"}, "x": {"y": 99}}, + ) + == 99 + ) + + +def test_get_path_in_dict_zitadel_admin_path(): + """Real-world scenario: Zitadel project-scoped role claims with PathList fallback.""" + token = { + "urn:zitadel:iam:org:project:12345:roles": { + "MatrixAdmin": {"org_id": "famedly.localhost"} + }, + } + assert get_path_in_dict( + [ + ["roles", "Admin"], + ["urn:zitadel:iam:org:project:12345:roles", "MatrixAdmin"], + ], + token, + ) == {"org_id": "famedly.localhost"} + assert get_path_in_dict( + [ + ["urn:zitadel:iam:org:project:12345:roles", "MatrixAdmin"], + ["roles", "Admin"], + ], + token, + ) == {"org_id": "famedly.localhost"} + + def test_validate_scopes(): assert validate_scopes("foo boo", "boo foo") assert validate_scopes(["foo", "boo"], "boo foo")