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"