This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 2a274ab3c93 Refuse nested secret ids looked up with no team in 
multi-team mode (#73789)
2a274ab3c93 is described below

commit 2a274ab3c93b2101b281820332adb77fd6d4bd7d
Author: Jarek Potiuk <[email protected]>
AuthorDate: Mon Oct 5 01:14:15 2026 +0200

    Refuse nested secret ids looked up with no team in multi-team mode (#73789)
    
    In multi-team mode with team-scoped paths, the Vault and Akeyless
    secrets backends build a team's secret names under the same base path
    that a lookup with no team resolves in. A connection or variable id
    containing the path separator, looked up with no team, could therefore
    name a team's secret: ``team1/db_password`` resolved
    ``{base_path}/team1/db_password``.
    
    Refuse such ids for a caller with no team, the same way both backends
    already refuse them for a caller with a team. Akeyless keeps resolving
    them when ``global_secrets_path`` gives secrets used outside any team a
    namespace of their own. The Amazon, Azure and Yandex backends already
    refuse ids containing their team separator for every caller.
    
    Generated-by: Claude Opus 5
---
 providers/akeyless/docs/changelog.rst              |  7 +++
 .../airflow/providers/akeyless/secrets/akeyless.py | 28 +++++------
 .../tests/unit/akeyless/secrets/test_akeyless.py   | 27 +++++++++--
 providers/hashicorp/docs/changelog.rst             |  7 +++
 .../airflow/providers/hashicorp/secrets/vault.py   | 31 +++++++++++-
 .../tests/unit/hashicorp/secrets/test_vault.py     | 55 ++++++++++++++++++++++
 6 files changed, 137 insertions(+), 18 deletions(-)

diff --git a/providers/akeyless/docs/changelog.rst 
b/providers/akeyless/docs/changelog.rst
index 65bc4d5ccfb..83a7ac0deeb 100644
--- a/providers/akeyless/docs/changelog.rst
+++ b/providers/akeyless/docs/changelog.rst
@@ -18,6 +18,13 @@
 Changelog
 =========
 
+.. warning::
+  In multi-team mode with ``use_team_secrets_path`` enabled (the default) and 
no ``global_secrets_path``
+  set, a connection or variable looked up with no team is no longer resolved 
when its id contains the
+  path separator. Such an id resolves under the same base path that 
team-scoped secrets are stored
+  under, so it could name a team's secret. Set ``global_secrets_path`` to give 
secrets used outside any
+  team a namespace of their own, in which nested ids keep working.
+
 0.3.1
 .....
 
diff --git 
a/providers/akeyless/src/airflow/providers/akeyless/secrets/akeyless.py 
b/providers/akeyless/src/airflow/providers/akeyless/secrets/akeyless.py
index e40b9318203..f045554a4ce 100644
--- a/providers/akeyless/src/airflow/providers/akeyless/secrets/akeyless.py
+++ b/providers/akeyless/src/airflow/providers/akeyless/secrets/akeyless.py
@@ -197,34 +197,34 @@ class AkeylessBackend(BaseSecretsBackend, LoggingMixin):
         execution API variables route is declared with a ``:path`` converter, 
so a separator
         survives the round trip.
 
+        A caller with no ``team_name`` crosses the same boundary directly. 
Without a
+        ``global_secrets_path`` it resolves ``<base path><sep><key>``, the 
same prefix the team
+        paths are built under, so the key ``alpha<sep>db_password`` read with 
no team reaches
+        team ``alpha``'s secret without any fallback involved.
+
         The refusal is deliberately narrow, because in this backend the 
separator is the
         ordinary path separator and nested keys are a legitimate, documented 
layout. It
-        applies only when this backend actually builds a team-scoped path and 
can fall back
-        past it:
+        applies only when team-scoped paths exist to be reached:
 
         * ``use_team_secrets_path=False`` disables team-scoped lookup 
entirely, so no team
           path is constructed and no boundary is crossed -- nested keys keep 
working.
-        * A caller with no ``team_name`` resolves in the shared namespace 
directly rather
-          than falling back into it. Whether a global-scope caller should be 
able to name a
-          team's namespace is a separate question about global scope, not this 
fallback, and
-          is left alone here.
+        * A caller with no ``team_name`` is refused only when no 
``global_secrets_path`` is
+          set. With one, it resolves under ``<base path><sep><global path>``, 
a namespace of
+          its own that team paths are not built under, and nested keys keep 
working.
         * Outside multi-team mode there are no team namespaces at all.
 
         The key is never parsed to work out *which* team it names, because it 
cannot be:
         nothing distinguishes a nested key in the shared namespace from one 
naming a team.
         """
-        return (
-            self._multi_team_enabled()
-            and self.use_team_secrets_path
-            and team_name is not None
-            and self.sep in key
-        )
+        if not (self._multi_team_enabled() and self.use_team_secrets_path and 
self.sep in key):
+            return False
+        return team_name is not None or self.global_secrets_path is None
 
     def _log_refusal(self, kind: str, key: str) -> None:
         self.log.warning(
             "%s id %r contains %r, which separates path segments in an 
Akeyless secret name. "
-            "Looked up for a team, such an id can resolve another team's 
namespace through "
-            "the team-agnostic fallback, so it is not looked up. Returning 
None.",
+            "In multi-team mode such an id can resolve another team's 
namespace, so it is not "
+            "looked up. Returning None.",
             kind.capitalize(),
             key,
             self.sep,
diff --git a/providers/akeyless/tests/unit/akeyless/secrets/test_akeyless.py 
b/providers/akeyless/tests/unit/akeyless/secrets/test_akeyless.py
index 7336f78f091..39ce4fb4506 100644
--- a/providers/akeyless/tests/unit/akeyless/secrets/test_akeyless.py
+++ b/providers/akeyless/tests/unit/akeyless/secrets/test_akeyless.py
@@ -470,14 +470,35 @@ class TestAkeylessBackend:
         assert val == "nested-val"
 
     @patch(f"{BACKEND_MODULE}.akeyless")
-    def test_nested_keys_still_resolve_for_a_caller_with_no_team(self, 
mock_sdk):
-        """With no team_name the lookup resolves in the shared namespace 
directly."""
+    def test_a_caller_with_no_team_cannot_reach_a_teams_namespace(self, 
mock_sdk):
+        """With no team_name and no global path, ``{base}/{key}`` is where 
team paths are built.
+
+        The backend is wired so that team alpha's secret *would* come back, so 
the assertion
+        is that it does not, not merely that some guard ran.
+        """
         api = mock_sdk.V2Api.return_value
         api.auth.return_value = MagicMock(token="t")
-        api.get_secret_value.return_value = 
{"/airflow/variables/nested/my_var": "nested-val"}
+        mock_sdk.ApiException = Exception
+        api.get_secret_value.return_value = 
{"/airflow/variables/alpha/db_password": "alpha-secret"}
 
         with conf_vars({("core", "multi_team"): "True"}):
             backend = _backend()
+            val = backend.get_variable("alpha/db_password")
+            conn = backend.get_connection("alpha/db_password")
+
+        assert val is None
+        assert conn is None
+        api.get_secret_value.assert_not_called()
+
+    @patch(f"{BACKEND_MODULE}.akeyless")
+    def 
test_nested_keys_still_resolve_for_a_caller_with_no_team_under_a_global_path(self,
 mock_sdk):
+        """A global path is a namespace of its own, so nested keys keep 
working under it."""
+        api = mock_sdk.V2Api.return_value
+        api.auth.return_value = MagicMock(token="t")
+        api.get_secret_value.return_value = 
{"/airflow/variables/global/nested/my_var": "nested-val"}
+
+        with conf_vars({("core", "multi_team"): "True"}):
+            backend = _backend(global_secrets_path="global")
             val = backend.get_variable("nested/my_var")
 
         assert val == "nested-val"
diff --git a/providers/hashicorp/docs/changelog.rst 
b/providers/hashicorp/docs/changelog.rst
index 4506a259a5b..21c14ce6ff8 100644
--- a/providers/hashicorp/docs/changelog.rst
+++ b/providers/hashicorp/docs/changelog.rst
@@ -27,6 +27,13 @@
 Changelog
 ---------
 
+.. warning::
+  In multi-team mode with ``use_team_secrets_path`` enabled (the default), a 
connection or variable
+  looked up with no team is no longer resolved when its id contains ``/`` 
below the mount point. Such an
+  id resolves under the same base path that team-scoped secrets are stored 
under, so it could name a
+  team's secret. Store secrets used outside any team under ids without ``/``, 
or set
+  ``use_team_secrets_path=False`` if the deployment does not use team-scoped 
Vault paths.
+
 4.8.2
 .....
 
diff --git 
a/providers/hashicorp/src/airflow/providers/hashicorp/secrets/vault.py 
b/providers/hashicorp/src/airflow/providers/hashicorp/secrets/vault.py
index ca106e3c74a..b51d1360f86 100644
--- a/providers/hashicorp/src/airflow/providers/hashicorp/secrets/vault.py
+++ b/providers/hashicorp/src/airflow/providers/hashicorp/secrets/vault.py
@@ -242,6 +242,35 @@ class VaultBackend(BaseSecretsBackend, LoggingMixin):
             secret_path=(mount_point + "/" if mount_point else "") + 
secret_path
         )
 
+    def _names_a_team_namespace(self, key: str, team_name: str | None) -> bool:
+        """
+        Whether a lookup of ``key`` with no team could resolve a team's secret.
+
+        In multi-team mode with team-scoped paths, a team's secrets sit under
+        ``{base_path}/{team_name}/{key}`` while a caller with no team reads 
``{base_path}/{key}``.
+        A key whose path part itself contains ``/`` therefore reaches into a 
team's namespace: the
+        key ``team1/db_password`` read with no team resolves 
``{base_path}/team1/db_password``. Such a
+        key is refused for a caller with no team.
+
+        A caller with a team never reaches outside 
``{base_path}/{team_name}``, and outside
+        multi-team mode, or with ``use_team_secrets_path=False``, there are no 
team namespaces to
+        reach. The key is never parsed to work out *which* team it names, 
because it cannot be:
+        nothing distinguishes a nested key in the shared namespace from one 
naming a team.
+        """
+        if team_name is not None or not self.use_team_secrets_path:
+            return False
+        if not conf.getboolean("core", "multi_team", fallback=False):
+            return False
+        _, key_part = self._parse_path(key)
+        if key_part is None or "/" not in key_part:
+            return False
+        self.log.warning(
+            "Secret id %r contains '/' and is looked up with no team. In 
multi-team mode it can "
+            "resolve a team's secret under the base path, so it is not looked 
up. Returning None.",
+            key,
+        )
+        return True
+
     def _get_secret(self, base_paths: list[str] | None, team_name: str | None, 
key: str):
         """
         Get a secret, trying each of ``base_paths`` in order until one yields 
a value.
@@ -253,7 +282,7 @@ class VaultBackend(BaseSecretsBackend, LoggingMixin):
         :param team_name: Team name associated to the task trying to access 
the secret (if any).
         :param key: Secret key.
         """
-        if not base_paths:
+        if not base_paths or self._names_a_team_namespace(key, team_name):
             return None
         multi_team_enabled = conf.getboolean("core", "multi_team", 
fallback=False)
         for base_path in base_paths:
diff --git a/providers/hashicorp/tests/unit/hashicorp/secrets/test_vault.py 
b/providers/hashicorp/tests/unit/hashicorp/secrets/test_vault.py
index ca61c0753f5..67315f92652 100644
--- a/providers/hashicorp/tests/unit/hashicorp/secrets/test_vault.py
+++ b/providers/hashicorp/tests/unit/hashicorp/secrets/test_vault.py
@@ -946,3 +946,58 @@ class TestVaultSecrets:
             ]
         )
         assert connection.get_uri() == 
"postgres://airflow:airflow@host:5432/airflow?foo=bar&baz=taz"
+
+    @pytest.mark.parametrize(
+        ("mount_point", "key"),
+        [
+            pytest.param("airflow", "team1/db_password", id="mount-point-set"),
+            pytest.param(None, "airflow/team1/db_password", 
id="mount-point-in-key"),
+        ],
+    )
+    @conf_vars({("core", "multi_team"): "True"})
+    
@mock.patch("airflow.providers.hashicorp._internal_client.vault_client.hvac")
+    def test_caller_with_no_team_cannot_reach_a_teams_namespace(self, 
mock_hvac, mount_point, key):
+        """With no team, ``{base}/{key}`` is where team paths are built, so a 
nested key is refused."""
+        mock_client = mock.MagicMock()
+        mock_hvac.Client.return_value = mock_client
+        mock_client.secrets.kv.v2.read_secret_version.return_value = {"data": 
{"data": {"value": "x"}}}
+
+        test_client = VaultBackend(
+            connections_path="connections",
+            variables_path="variables",
+            mount_point=mount_point,
+            auth_type="token",
+            url="http://127.0.0.1:8200";,
+            token="s.7AU0I51yv1Q1lxOIg1F3ZRAS",
+        )
+
+        assert test_client.get_variable(key) is None
+        assert test_client.get_conn_value(key) is None
+        mock_client.secrets.kv.v2.read_secret_version.assert_not_called()
+
+    @pytest.mark.parametrize(
+        ("multi_team", "extra_kwargs", "team_name"),
+        [
+            pytest.param("False", {}, None, id="multi-team-off"),
+            pytest.param("True", {"use_team_secrets_path": False}, None, 
id="team-paths-disabled"),
+            pytest.param("True", {}, "team1", id="caller-with-a-team"),
+        ],
+    )
+    
@mock.patch("airflow.providers.hashicorp._internal_client.vault_client.hvac")
+    def test_nested_keys_still_resolve_where_no_team_namespace_is_reachable(
+        self, mock_hvac, multi_team, extra_kwargs, team_name
+    ):
+        mock_client = mock.MagicMock()
+        mock_hvac.Client.return_value = mock_client
+        mock_client.secrets.kv.v2.read_secret_version.return_value = {"data": 
{"data": {"value": "nested"}}}
+
+        with conf_vars({("core", "multi_team"): multi_team}):
+            test_client = VaultBackend(
+                variables_path="variables",
+                mount_point="airflow",
+                auth_type="token",
+                url="http://127.0.0.1:8200";,
+                token="s.7AU0I51yv1Q1lxOIg1F3ZRAS",
+                **extra_kwargs,
+            )
+            assert test_client.get_variable("nested/my_var", team_name) == 
"nested"

Reply via email to