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

eladkal 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 cf76cd0c8e1 Refuse Akeyless secret ids that address another team's 
namespace (#72646)
cf76cd0c8e1 is described below

commit cf76cd0c8e1592c26ee3fe8414a2e4ea5025f5ec
Author: Jarek Potiuk <[email protected]>
AuthorDate: Thu Sep 10 06:51:44 2026 +0200

    Refuse Akeyless secret ids that address another team's namespace (#72646)
    
    Secret names are built by joining <base path><sep><key>, and the key was not
    validated. In multi-team mode the team-scoped lookup is tried first and, 
when
    it misses, the team-agnostic fallback resolves <base path><sep><key> -- the
    prefix under which every other team's secrets are stored. A caller in team
    alpha requesting the key 'beta/db_password' therefore reached team beta's
    secret. The key is Dag-author controlled and the execution API variables 
route
    is declared with a ':path' converter, so a separator survives the round 
trip.
    
    Refuse such a key only where this backend actually crosses a namespace: when
    multi-team mode is on, team-scoped paths are in use, and a team name was
    supplied. The separator here is the ordinary path separator and nested keys
    are a documented layout, so the refusal is kept as narrow as the defect:
    
    * use_team_secrets_path=False builds no team path, so nothing is refused and
      a flat nested layout keeps working under multi-team mode.
    * get_config takes no team name and Airflow does not perform team-scoped
      config lookups through a secrets backend, so that path has no boundary to
      cross and is not guarded; subfolder config layouts keep resolving.
    * A caller with no team name resolves in the shared namespace directly 
rather
      than falling back into it. Whether global scope should be able to name a
      team's namespace is a separate question and is not decided here.
    
    The key is never parsed to determine which team it names, because it cannot
    be -- nothing distinguishes a nested key in the shared namespace from one
    naming another team.
    
    Also read core.multi_team with getboolean. It was read with conf.get, which
    returns the string 'False' -- truthy -- so the multi-team branches were
    selected even with multi-team disabled.
    
    The escape tests wire the backend so the cross-team path would return a 
value
    and assert it does not come back. Against unpatched sources they fail with
    "assert 'beta-secret' is None".
---
 .../airflow/providers/akeyless/secrets/akeyless.py |  61 ++++++++-
 .../tests/unit/akeyless/secrets/test_akeyless.py   | 136 +++++++++++++++++++++
 2 files changed, 196 insertions(+), 1 deletion(-)

diff --git 
a/providers/akeyless/src/airflow/providers/akeyless/secrets/akeyless.py 
b/providers/akeyless/src/airflow/providers/akeyless/secrets/akeyless.py
index eedc6794810..e40b9318203 100644
--- a/providers/akeyless/src/airflow/providers/akeyless/secrets/akeyless.py
+++ b/providers/akeyless/src/airflow/providers/akeyless/secrets/akeyless.py
@@ -181,6 +181,55 @@ class AkeylessBackend(BaseSecretsBackend, LoggingMixin):
             return cid.generateAzure(self._extra.get("azure_object_id"))
         raise ValueError(f"No cloud-id generator for {self._access_type!r}")
 
+    def _multi_team_enabled(self) -> bool:
+        """Whether the deployment runs in multi-team mode."""
+        return conf.getboolean("core", "multi_team", fallback=False)
+
+    def _escapes_its_namespace(self, key: str, team_name: str | None) -> bool:
+        """
+        Whether looking ``key`` up for ``team_name`` could resolve another 
team's secret.
+
+        Only the team-scoped lookup crosses a namespace boundary. It is tried 
under
+        ``<base path><sep><team><sep><key>`` and, when that misses, falls back 
to
+        ``<base path><sep><key>`` -- the prefix every *other* team's secrets 
sit under. A
+        caller in team ``alpha`` asking for ``beta<sep>db_password`` therefore 
reaches team
+        ``beta``'s secret through the fallback. The key is Dag-author 
controlled and the
+        execution API variables route is declared with a ``:path`` converter, 
so a separator
+        survives the round trip.
+
+        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:
+
+        * ``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.
+        * 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
+        )
+
+    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.",
+            kind.capitalize(),
+            key,
+            self.sep,
+        )
+
     def _get_secret(self, base_path: str | None, key: str) -> str | None:
         if base_path is None:
             return None
@@ -199,7 +248,7 @@ class AkeylessBackend(BaseSecretsBackend, LoggingMixin):
         """Look up a secret with team-scoped path, falling back to global."""
         if base_path is None:
             return None
-        multi_team = conf.get("core", "multi_team", fallback=False)
+        multi_team = self._multi_team_enabled()
         if multi_team and self.use_team_secrets_path and team_name is not None:
             team_path = f"{base_path}{self.sep}{team_name}"
             response = self._get_secret(team_path, key)
@@ -217,6 +266,9 @@ class AkeylessBackend(BaseSecretsBackend, LoggingMixin):
         """Build a ``Connection`` from an Akeyless secret (URI or JSON 
dict)."""
         from airflow.models.connection import Connection
 
+        if self._escapes_its_namespace(conn_id, team_name):
+            self._log_refusal("connection", conn_id)
+            return None
         raw = self._get_team_or_global_secret(self.connections_path, 
team_name, conn_id)
         if raw is None:
             return None
@@ -231,6 +283,9 @@ class AkeylessBackend(BaseSecretsBackend, LoggingMixin):
 
     def get_variable(self, key: str, team_name: str | None = None) -> str | 
None:
         """Retrieve an Airflow Variable from Akeyless."""
+        if self._escapes_its_namespace(key, team_name):
+            self._log_refusal("variable", key)
+            return None
         raw = self._get_team_or_global_secret(self.variables_path, team_name, 
key)
         if raw is None:
             return None
@@ -244,6 +299,10 @@ class AkeylessBackend(BaseSecretsBackend, LoggingMixin):
 
     def get_config(self, key: str) -> str | None:
         """Retrieve an Airflow Configuration option from Akeyless."""
+        # No guard here. Config lookups carry no team_name and Airflow does 
not perform
+        # team-scoped config lookups through a secrets backend, so this path 
never builds a
+        # team-scoped name and has no boundary to cross. Refusing nested ids 
here would only
+        # break subfolder config layouts.
         raw = self._get_secret(self.config_path, key)
         if raw is None:
             return None
diff --git a/providers/akeyless/tests/unit/akeyless/secrets/test_akeyless.py 
b/providers/akeyless/tests/unit/akeyless/secrets/test_akeyless.py
index eb97bfecb38..7336f78f091 100644
--- a/providers/akeyless/tests/unit/akeyless/secrets/test_akeyless.py
+++ b/providers/akeyless/tests/unit/akeyless/secrets/test_akeyless.py
@@ -392,3 +392,139 @@ class TestAkeylessBackend:
         with patch.dict(sys.modules, {"akeyless_cloud_id": None}):
             with pytest.raises(ImportError, match="akeyless_cloud_id"):
                 backend._get_cloud_id()
+
+    # ------------------------------------------------------------------
+    # Cross-team namespace escape
+    # ------------------------------------------------------------------
+
+    @patch(f"{BACKEND_MODULE}.akeyless")
+    def test_get_variable_cannot_reach_another_teams_namespace(self, mock_sdk):
+        """A key containing the separator must not resolve another team's 
secret.
+
+        The team-scoped lookup misses and the team-agnostic fallback resolves
+        ``{base}/{key}`` -- which is the prefix every other team's secrets 
live under.
+        The backend is wired here so that the cross-team path *would* return a 
value,
+        so the assertion is that team beta's secret does not come back, not 
merely
+        that some guard ran.
+        """
+        api = mock_sdk.V2Api.return_value
+        api.auth.return_value = MagicMock(token="t")
+        mock_sdk.ApiException = Exception
+        api.get_secret_value.return_value = 
{"/airflow/variables/beta/db_password": "beta-secret"}
+
+        with conf_vars({("core", "multi_team"): "True"}):
+            backend = _backend()
+            val = backend.get_variable("beta/db_password", team_name="alpha")
+
+        assert val is None
+        api.get_secret_value.assert_not_called()
+
+    @patch(f"{BACKEND_MODULE}.akeyless")
+    def test_get_connection_cannot_reach_another_teams_namespace(self, 
mock_sdk):
+        """The same escape is refused for connections."""
+        api = mock_sdk.V2Api.return_value
+        api.auth.return_value = MagicMock(token="t")
+        mock_sdk.ApiException = Exception
+        api.get_secret_value.return_value = 
{"/airflow/connections/beta/prod_db": "postgres://u:p@h/d"}
+
+        with conf_vars({("core", "multi_team"): "True"}):
+            backend = _backend()
+            conn = backend.get_connection("beta/prod_db", team_name="alpha")
+
+        assert conn is None
+        api.get_secret_value.assert_not_called()
+
+    @patch(f"{BACKEND_MODULE}.akeyless")
+    def test_nested_config_ids_still_resolve_in_multi_team_mode(self, 
mock_sdk):
+        """Config lookups are global and must keep working with subfolder 
layouts.
+
+        ``get_config`` takes no ``team_name`` and Airflow does not do 
team-scoped config
+        lookups through a secrets backend, so there is no boundary for a 
config id to
+        cross. Guarding it would silently break subfolder config layouts on 
upgrade.
+        """
+        api = mock_sdk.V2Api.return_value
+        api.auth.return_value = MagicMock(token="t")
+        api.get_secret_value.return_value = 
{"/airflow/config/db/sql_alchemy_conn": "postgres://x"}
+
+        with conf_vars({("core", "multi_team"): "True"}):
+            backend = _backend()
+            val = backend.get_config("db/sql_alchemy_conn")
+
+        assert val == "postgres://x"
+
+    @patch(f"{BACKEND_MODULE}.akeyless")
+    def test_nested_keys_still_resolve_when_team_paths_are_disabled(self, 
mock_sdk):
+        """``use_team_secrets_path=False`` builds no team path, so nothing is 
refused.
+
+        A deployment can run multi-team for other features while keeping 
Akeyless as a
+        single flat namespace. Banning separators there would be a pure 
regression.
+        """
+        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"}
+
+        with conf_vars({("core", "multi_team"): "True"}):
+            backend = _backend(use_team_secrets_path=False)
+            val = backend.get_variable("nested/my_var", team_name="alpha")
+
+        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."""
+        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"}
+
+        with conf_vars({("core", "multi_team"): "True"}):
+            backend = _backend()
+            val = backend.get_variable("nested/my_var")
+
+        assert val == "nested-val"
+
+    @patch(f"{BACKEND_MODULE}.akeyless")
+    def test_a_team_scoped_key_without_the_separator_still_resolves(self, 
mock_sdk):
+        """The guard must not break ordinary team-scoped lookups."""
+        api = mock_sdk.V2Api.return_value
+        api.auth.return_value = MagicMock(token="t")
+        api.get_secret_value.return_value = 
{"/airflow/variables/alpha/my_var": "team-val"}
+
+        with conf_vars({("core", "multi_team"): "True"}):
+            backend = _backend()
+            val = backend.get_variable("my_var", team_name="alpha")
+
+        assert val == "team-val"
+
+    @patch(f"{BACKEND_MODULE}.akeyless")
+    def test_nested_keys_still_work_outside_multi_team_mode(self, mock_sdk):
+        """Without team namespaces a separator in a key is an ordinary nested 
path."""
+        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"}
+
+        with conf_vars({("core", "multi_team"): "False"}):
+            backend = _backend()
+            val = backend.get_variable("nested/my_var")
+
+        assert val == "nested-val"
+
+    @patch(f"{BACKEND_MODULE}.akeyless")
+    def test_multi_team_disabled_is_read_as_a_boolean(self, mock_sdk):
+        """``multi_team = False`` must not select the multi-team code paths.
+
+        The option was previously read with ``conf.get``, which yields the 
string
+        ``"False"`` -- truthy -- so the global-path branch was taken even with
+        multi-team off.
+        """
+        api = mock_sdk.V2Api.return_value
+        api.auth.return_value = MagicMock(token="t")
+        api.get_secret_value.return_value = {"/airflow/variables/my_var": 
"plain-val"}
+
+        with conf_vars({("core", "multi_team"): "False"}):
+            backend = _backend(global_secrets_path="global")
+            val = backend.get_variable("my_var")
+
+        assert val == "plain-val"
+        # The global-secrets path must not have been consulted at all.
+        requested = [c.kwargs["names"][0] for c in 
mock_sdk.GetSecretValue.call_args_list]
+        assert requested == ["/airflow/variables/my_var"]

Reply via email to