This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 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 22096909486 Only refuse team scoped like secret ids when multi_team is
on (#71078)
22096909486 is described below
commit 22096909486c68604d8917b5d2bb80f823046a57
Author: Amogh Desai <[email protected]>
AuthorDate: Tue Aug 4 17:27:20 2026 +0530
Only refuse team scoped like secret ids when multi_team is on (#71078)
---
.../amazon/aws/secrets/secrets_manager.py | 6 +++++
.../amazon/aws/secrets/systems_manager.py | 6 +++++
.../amazon/aws/secrets/test_secrets_manager.py | 26 ++++++++++++++++++++++
.../amazon/aws/secrets/test_systems_manager.py | 22 ++++++++++++++++++
.../google/cloud/secrets/secret_manager.py | 7 +++++-
.../google/cloud/secrets/test_secret_manager.py | 22 ++++++++++++++++++
.../providers/microsoft/azure/secrets/key_vault.py | 6 +++++
.../unit/microsoft/azure/secrets/test_key_vault.py | 20 +++++++++++++++++
.../airflow/providers/yandex/secrets/lockbox.py | 6 +++++
.../tests/unit/yandex/secrets/test_lockbox.py | 26 ++++++++++++++++++++++
10 files changed, 146 insertions(+), 1 deletion(-)
diff --git
a/providers/amazon/src/airflow/providers/amazon/aws/secrets/secrets_manager.py
b/providers/amazon/src/airflow/providers/amazon/aws/secrets/secrets_manager.py
index 415fc945910..bf17d613238 100644
---
a/providers/amazon/src/airflow/providers/amazon/aws/secrets/secrets_manager.py
+++
b/providers/amazon/src/airflow/providers/amazon/aws/secrets/secrets_manager.py
@@ -25,6 +25,7 @@ from functools import cached_property
from typing import Any
from airflow.providers.amazon.aws.utils import trim_none_values
+from airflow.providers.common.compat.sdk import conf
from airflow.secrets import BaseSecretsBackend
from airflow.utils.log.logging_mixin import LoggingMixin
@@ -335,7 +336,12 @@ class SecretsManagerBackend(BaseSecretsBackend,
LoggingMixin):
the caller's own team builds looks equivalent and is not -- a caller
in team ``a`` would
match ``a--b``'s namespace on the prefix and read its secrets. Only
the caller's own
namespace is ever constructed, never parsed.
+
+ Only checked in multi-team mode: ``team_name`` is never non-``None``
otherwise, so no
+ team scoped secret can exist to collide with.
"""
+ if not conf.getboolean("core", "multi_team", fallback=False):
+ return False
return TEAM_SEP in secret_id
def _log_refusal(self, kind: str, secret_id: str) -> None:
diff --git
a/providers/amazon/src/airflow/providers/amazon/aws/secrets/systems_manager.py
b/providers/amazon/src/airflow/providers/amazon/aws/secrets/systems_manager.py
index 5cae97670b7..8563d5ce9f6 100644
---
a/providers/amazon/src/airflow/providers/amazon/aws/secrets/systems_manager.py
+++
b/providers/amazon/src/airflow/providers/amazon/aws/secrets/systems_manager.py
@@ -23,6 +23,7 @@ import re
from functools import cached_property
from airflow.providers.amazon.aws.utils import trim_none_values
+from airflow.providers.common.compat.sdk import conf
from airflow.secrets import BaseSecretsBackend
from airflow.utils.log.logging_mixin import LoggingMixin
@@ -214,7 +215,12 @@ class
SystemsManagerParameterStoreBackend(BaseSecretsBackend, LoggingMixin):
the caller's own team builds looks equivalent and is not -- a caller
in team ``a`` would
match ``a--b``'s namespace on the prefix and read its secrets. Only
the caller's own
namespace is ever constructed, never parsed.
+
+ Only checked in multi-team mode: ``team_name`` is never non-``None``
otherwise, so no
+ team scoped secret can exist to collide with.
"""
+ if not conf.getboolean("core", "multi_team", fallback=False):
+ return False
return TEAM_SEP in secret_id
def _log_refusal(self, kind: str, secret_id: str) -> None:
diff --git
a/providers/amazon/tests/unit/amazon/aws/secrets/test_secrets_manager.py
b/providers/amazon/tests/unit/amazon/aws/secrets/test_secrets_manager.py
index ab345a9f38d..3967405d649 100644
--- a/providers/amazon/tests/unit/amazon/aws/secrets/test_secrets_manager.py
+++ b/providers/amazon/tests/unit/amazon/aws/secrets/test_secrets_manager.py
@@ -24,6 +24,10 @@ from moto import mock_aws
from airflow.providers.amazon.aws.secrets.secrets_manager import TEAM_SEP,
SecretsManagerBackend
+from tests_common.test_utils.config import conf_vars
+
+multi_team_enabled = conf_vars({("core", "multi_team"): "True"})
+
class TestSecretsManagerBackend:
@mock.patch("airflow.providers.amazon.aws.secrets.secrets_manager.SecretsManagerBackend.get_conn_value")
@@ -80,6 +84,7 @@ class TestSecretsManagerBackend:
returned_uri =
secrets_manager_backend.get_conn_value(conn_id="test_postgres",
team_name="my_team")
assert returned_uri == "postgresql://airflow:airflow@host:5432/airflow"
+ @multi_team_enabled
@mock_aws
def test_global_caller_cannot_access_team_scoped_connection(self):
secret_id = "airflow/connections/my_team--test_postgres"
@@ -93,6 +98,7 @@ class TestSecretsManagerBackend:
assert
secrets_manager_backend.get_conn_value(conn_id="my_team--test_postgres") is None
+ @multi_team_enabled
@mock_aws
def test_another_teams_secret_is_not_reachable(self):
"""A caller scoped to one team must not reach another team's secret by
naming it."""
@@ -103,6 +109,7 @@ class TestSecretsManagerBackend:
assert backend.get_conn_value(conn_id="my_team--test_postgres",
team_name="other_team") is None
+ @multi_team_enabled
@mock_aws
def test_team_whose_name_extends_the_callers_is_not_reachable(self):
"""A prefix match on the caller's own namespace is not proof of
ownership."""
@@ -113,6 +120,7 @@ class TestSecretsManagerBackend:
assert backend.get_conn_value(conn_id="my_team--prod--test_postgres",
team_name="my_team") is None
+ @multi_team_enabled
@mock_aws
def test_team_scoped_lookup_cannot_reach_a_longer_teams_namespace(self):
"""The team scoped name is not safe by construction -- the id can
extend it.
@@ -129,6 +137,7 @@ class TestSecretsManagerBackend:
assert backend.get_conn_value(conn_id="prod--test_postgres",
team_name="my_team") is None
+ @multi_team_enabled
@mock_aws
def test_refusing_an_ambiguous_id_is_logged(self, caplog):
"""A silent ``None`` is indistinguishable from a missing secret, so
the refusal is logged.
@@ -158,6 +167,22 @@ class TestSecretsManagerBackend:
assert sum(refused_id in r.getMessage() for r in refusals) == 1
assert all(TEAM_SEP in r.getMessage() for r in refusals)
+ @mock_aws
+ def test_ambiguous_id_resolves_when_multi_team_is_disabled(self):
+ """No team scoped secret can exist without multi-team mode, so there
is no ambiguity
+ to refuse -- an ordinary id containing the separator must resolve
normally."""
+ secret_id = "airflow/connections/prod--test_postgres"
+ create_param = {
+ "Name": secret_id,
+ "SecretString": "postgresql://airflow:airflow@host:5432/airflow",
+ }
+ backend = SecretsManagerBackend()
+ backend.client.create_secret(**create_param)
+
+ assert backend.get_conn_value(conn_id="prod--test_postgres") == (
+ "postgresql://airflow:airflow@host:5432/airflow"
+ )
+
@mock_aws
def test_team_caller_falls_back_to_global_connection(self):
secret_id = "airflow/connections/test_postgres"
@@ -209,6 +234,7 @@ class TestSecretsManagerBackend:
assert secrets_manager_backend.get_variable(key="hello",
team_name="my_team") == "world"
+ @multi_team_enabled
@mock_aws
def test_global_caller_cannot_access_team_scoped_variable(self):
secret_id = "airflow/variables/my_team--hello"
diff --git
a/providers/amazon/tests/unit/amazon/aws/secrets/test_systems_manager.py
b/providers/amazon/tests/unit/amazon/aws/secrets/test_systems_manager.py
index 09734819e13..990ac613598 100644
--- a/providers/amazon/tests/unit/amazon/aws/secrets/test_systems_manager.py
+++ b/providers/amazon/tests/unit/amazon/aws/secrets/test_systems_manager.py
@@ -116,6 +116,7 @@ class TestSsmSecrets:
returned_uri = ssm_backend.get_conn_value(conn_id="test_postgres",
team_name="my_team")
assert returned_uri == "postgresql://airflow:airflow@host:5432/airflow"
+ @conf_vars({("core", "multi_team"): "True"})
@mock_aws
def test_global_caller_cannot_access_team_scoped_connection(self):
param = {
@@ -127,6 +128,7 @@ class TestSsmSecrets:
ssm_backend.client.put_parameter(**param)
assert ssm_backend.get_conn_value(conn_id="my_team--test_postgres") is
None
+ @conf_vars({("core", "multi_team"): "True"})
@mock_aws
def test_another_teams_secret_is_not_reachable(self):
"""A caller scoped to one team must not reach another team's parameter
by naming it."""
@@ -140,6 +142,7 @@ class TestSsmSecrets:
assert ssm_backend.get_conn_value(conn_id="my_team--test_postgres",
team_name="other_team") is None
+ @conf_vars({("core", "multi_team"): "True"})
@mock_aws
def test_team_whose_name_extends_the_callers_is_not_reachable(self):
"""A prefix match on the caller's own namespace is not proof of
ownership."""
@@ -153,6 +156,7 @@ class TestSsmSecrets:
assert
ssm_backend.get_conn_value(conn_id="my_team--prod--test_postgres",
team_name="my_team") is None
+ @conf_vars({("core", "multi_team"): "True"})
@mock_aws
def test_team_scoped_lookup_cannot_reach_a_longer_teams_namespace(self):
"""The team scoped name is not safe by construction -- the id can
extend it.
@@ -172,6 +176,7 @@ class TestSsmSecrets:
assert ssm_backend.get_conn_value(conn_id="prod--test_postgres",
team_name="my_team") is None
+ @conf_vars({("core", "multi_team"): "True"})
@mock_aws
def test_refusing_an_ambiguous_id_is_logged(self, caplog):
"""A silent ``None`` is indistinguishable from a missing secret, so
the refusal is logged.
@@ -201,6 +206,22 @@ class TestSsmSecrets:
assert sum(refused_id in r.getMessage() for r in refusals) == 1
assert all(TEAM_SEP in r.getMessage() for r in refusals)
+ @mock_aws
+ def test_ambiguous_id_resolves_when_multi_team_is_disabled(self):
+ """No team scoped parameter can exist without multi-team mode, so
there is no ambiguity
+ to refuse -- an ordinary id containing the separator must resolve
normally."""
+ param = {
+ "Name": "/airflow/connections/prod--test_postgres",
+ "Type": "String",
+ "Value": "postgresql://airflow:airflow@host:5432/airflow",
+ }
+ ssm_backend = SystemsManagerParameterStoreBackend()
+ ssm_backend.client.put_parameter(**param)
+
+ assert ssm_backend.get_conn_value(conn_id="prod--test_postgres") == (
+ "postgresql://airflow:airflow@host:5432/airflow"
+ )
+
@mock_aws
def test_team_caller_falls_back_to_global_connection(self):
param = {
@@ -267,6 +288,7 @@ class TestSsmSecrets:
assert ssm_backend.get_variable(key="hello", team_name="my_team") ==
"world"
+ @conf_vars({("core", "multi_team"): "True"})
@mock_aws
def test_global_caller_cannot_access_team_scoped_variable(self):
param = {"Name": "/airflow/variables/my_team--hello", "Type":
"String", "Value": "world"}
diff --git
a/providers/google/src/airflow/providers/google/cloud/secrets/secret_manager.py
b/providers/google/src/airflow/providers/google/cloud/secrets/secret_manager.py
index 8966e1372ae..8d62de670d8 100644
---
a/providers/google/src/airflow/providers/google/cloud/secrets/secret_manager.py
+++
b/providers/google/src/airflow/providers/google/cloud/secrets/secret_manager.py
@@ -23,7 +23,7 @@ from collections.abc import Sequence
from google.auth.exceptions import DefaultCredentialsError
-from airflow.providers.common.compat.sdk import AirflowException
+from airflow.providers.common.compat.sdk import AirflowException, conf
from airflow.providers.google.cloud._internal_client.secret_manager_client
import _SecretManagerClient
from airflow.providers.google.cloud.utils.credentials_provider import (
_get_target_principal_and_delegates,
@@ -248,7 +248,12 @@ class CloudSecretManagerBackend(BaseSecretsBackend,
LoggingMixin):
backend does, is wrong here: the inherited implementation prepends a
separator to an
empty prefix (``'' -> '-smtp_default'``) and normalizes nothing, so
the guard would
both mis-anchor and miss ids whose separator only appears after
normalization.
+
+ Only checked in multi-team mode: ``team_name`` is never non-``None``
otherwise, so no
+ team scoped secret can exist to collide with.
"""
+ if not conf.getboolean("core", "multi_team", fallback=False):
+ return False
return TEAM_SEP in secret_id
def _get_secret(self, path_prefix: str, secret_id: str, team_name: str |
None = None) -> str | None:
diff --git
a/providers/google/tests/unit/google/cloud/secrets/test_secret_manager.py
b/providers/google/tests/unit/google/cloud/secrets/test_secret_manager.py
index 090f46b150e..0078bf7b300 100644
--- a/providers/google/tests/unit/google/cloud/secrets/test_secret_manager.py
+++ b/providers/google/tests/unit/google/cloud/secrets/test_secret_manager.py
@@ -28,6 +28,10 @@ from airflow.models import Connection
from airflow.providers.common.compat.sdk import AirflowException
from airflow.providers.google.cloud.secrets.secret_manager import TEAM_SEP,
CloudSecretManagerBackend
+from tests_common.test_utils.config import conf_vars
+
+multi_team_enabled = conf_vars({("core", "multi_team"): "True"})
+
CREDENTIALS = "test-creds"
KEY_FILE = "test-file.json"
PROJECT_ID = "test-project-id"
@@ -318,6 +322,7 @@ class TestCloudSecretManagerBackendTeamScope:
assert backend.get_conn_value(conn_id=CONN_ID, team_name=self.TEAM) ==
CONN_URI
+ @multi_team_enabled
@mock.patch(MODULE_NAME + ".get_credentials_and_project_id")
@mock.patch(MODULE_NAME + "._SecretManagerClient")
def test_team_scoped_secret_is_not_resolved_for_another_team(self,
mock_client, mock_get_creds):
@@ -329,6 +334,7 @@ class TestCloudSecretManagerBackendTeamScope:
assert backend.get_conn_value(conn_id=encoded,
team_name=self.OTHER_TEAM) is None
+ @multi_team_enabled
@mock.patch(MODULE_NAME + ".get_credentials_and_project_id")
@mock.patch(MODULE_NAME + "._SecretManagerClient")
def test_team_scoped_secret_is_not_resolved_without_a_team_scope(self,
mock_client, mock_get_creds):
@@ -339,6 +345,7 @@ class TestCloudSecretManagerBackendTeamScope:
assert backend.get_conn_value(conn_id=encoded) is None
+ @multi_team_enabled
@mock.patch(MODULE_NAME + ".get_credentials_and_project_id")
@mock.patch(MODULE_NAME + "._SecretManagerClient")
def test_team_whose_name_extends_the_callers_is_not_readable(self,
mock_client, mock_get_creds):
@@ -362,6 +369,7 @@ class TestCloudSecretManagerBackendTeamScope:
assert backend.get_conn_value(conn_id=CONN_ID) == CONN_URI
assert backend.get_conn_value(conn_id=CONN_ID, team_name=self.TEAM) ==
CONN_URI
+ @multi_team_enabled
@mock.patch(MODULE_NAME + ".get_credentials_and_project_id")
@mock.patch(MODULE_NAME + "._SecretManagerClient")
def test_team_scoped_variable_is_not_resolved_for_another_team(self,
mock_client, mock_get_creds):
@@ -416,6 +424,7 @@ class TestCloudSecretManagerBackendTeamScope:
# the two names must not coincide in the first place
assert backend._build_team_secret_name(CONNECTIONS_PREFIX, self.TEAM,
f"prod__{CONN_ID}") != victim
+ @multi_team_enabled
@mock.patch(MODULE_NAME + ".get_credentials_and_project_id")
@mock.patch(MODULE_NAME + "._SecretManagerClient")
def test_ambiguous_id_is_refused_even_for_its_own_team(self, mock_client,
mock_get_creds):
@@ -432,6 +441,7 @@ class TestCloudSecretManagerBackendTeamScope:
assert backend.get_conn_value(conn_id=ambiguous, team_name=self.TEAM)
is None
+ @multi_team_enabled
@mock.patch(MODULE_NAME + ".get_credentials_and_project_id")
@mock.patch(MODULE_NAME + "._SecretManagerClient")
def test_refusing_an_ambiguous_id_is_logged(self, mock_client,
mock_get_creds, caplog):
@@ -448,6 +458,18 @@ class TestCloudSecretManagerBackendTeamScope:
assert all(r.levelname == "WARNING" for r in refusals)
assert all(ambiguous in r.getMessage() for r in refusals)
+ @mock.patch(MODULE_NAME + ".get_credentials_and_project_id")
+ @mock.patch(MODULE_NAME + "._SecretManagerClient")
+ def test_ambiguous_id_resolves_when_multi_team_is_disabled(self,
mock_client, mock_get_creds):
+ """No team scoped secret can exist without multi-team mode, so there
is no ambiguity
+ to refuse -- an ordinary id containing the separator must resolve
normally."""
+ mock_get_creds.return_value = CREDENTIALS, PROJECT_ID
+ backend, store = self._backend(mock_client)
+ ambiguous = f"prod{TEAM_SEP}{CONN_ID}"
+ store[backend.build_path(CONNECTIONS_PREFIX, ambiguous, SEP)] =
CONN_URI
+
+ assert backend.get_conn_value(conn_id=ambiguous) == CONN_URI
+
@mock.patch(MODULE_NAME + ".get_credentials_and_project_id")
@mock.patch(MODULE_NAME + "._SecretManagerClient")
def test_config_lookup_is_not_team_scoped(self, mock_client,
mock_get_creds):
diff --git
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/secrets/key_vault.py
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/secrets/key_vault.py
index 7162663ca1d..30a20a827e6 100644
---
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/secrets/key_vault.py
+++
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/secrets/key_vault.py
@@ -33,6 +33,7 @@ from azure.core.exceptions import ResourceNotFoundError
from azure.identity import ClientSecretCredential, DefaultAzureCredential
from azure.keyvault.secrets import SecretClient
+from airflow.providers.common.compat.sdk import conf
from airflow.providers.microsoft.azure.utils import
get_sync_default_azure_credential
from airflow.secrets import BaseSecretsBackend
from airflow.utils.log.logging_mixin import LoggingMixin
@@ -247,7 +248,12 @@ class AzureKeyVaultBackend(BaseSecretsBackend,
LoggingMixin):
The id is normalised first because :meth:`build_path` maps ``_`` onto
the separator
everywhere in this backend, so ``b__c`` reaches Key Vault as ``b--c``
and would
otherwise manufacture the team separator from an id that does not
visibly contain it.
+
+ Only checked in multi-team mode: ``team_name`` is never non-``None``
otherwise, so no
+ team scoped secret can exist to collide with.
"""
+ if not conf.getboolean("core", "multi_team", fallback=False):
+ return False
return TEAM_SEP in self.build_path("", secret_id, self.sep)
def _log_refusal(self, kind: str, secret_id: str) -> None:
diff --git
a/providers/microsoft/azure/tests/unit/microsoft/azure/secrets/test_key_vault.py
b/providers/microsoft/azure/tests/unit/microsoft/azure/secrets/test_key_vault.py
index b3f44d2299c..0d869bf8b19 100644
---
a/providers/microsoft/azure/tests/unit/microsoft/azure/secrets/test_key_vault.py
+++
b/providers/microsoft/azure/tests/unit/microsoft/azure/secrets/test_key_vault.py
@@ -24,7 +24,10 @@ from azure.core.exceptions import ResourceNotFoundError
from airflow.providers.microsoft.azure.secrets.key_vault import
AzureKeyVaultBackend
+from tests_common.test_utils.config import conf_vars
+
KEY_VAULT_MODULE = "airflow.providers.microsoft.azure.secrets.key_vault"
+multi_team_enabled = conf_vars({("core", "multi_team"): "True"})
class TestAzureKeyVaultBackend:
@@ -107,6 +110,7 @@ class TestAzureKeyVaultBackend:
assert secret_val == "team-value"
mock_client.get_secret.assert_called_once_with(name="custom-variables-team-a--hello")
+ @multi_team_enabled
@mock.patch(f"{KEY_VAULT_MODULE}.AzureKeyVaultBackend.client")
def
test_get_variable_returns_none_for_team_scoped_key_without_team_name(self,
mock_client):
backend = AzureKeyVaultBackend()
@@ -114,6 +118,7 @@ class TestAzureKeyVaultBackend:
assert backend.get_variable("teama--hello") is None
mock_client.get_secret.assert_not_called()
+ @multi_team_enabled
@mock.patch(f"{KEY_VAULT_MODULE}.AzureKeyVaultBackend.client")
def test_another_teams_secret_is_not_reachable(self, mock_client):
"""A caller scoped to one team must not reach another team's secret by
naming it.
@@ -133,6 +138,7 @@ class TestAzureKeyVaultBackend:
assert backend.get_conn_value("teama--my_db", team_name="teamb") is
None
+ @multi_team_enabled
@mock.patch(f"{KEY_VAULT_MODULE}.AzureKeyVaultBackend.client")
def test_team_whose_name_extends_the_callers_is_not_reachable(self,
mock_client):
"""A prefix match on the caller's own namespace is not proof of
ownership.
@@ -153,6 +159,7 @@ class TestAzureKeyVaultBackend:
assert backend.get_conn_value("teama--prod--my_db", team_name="teama")
is None
+ @multi_team_enabled
@mock.patch(f"{KEY_VAULT_MODULE}.AzureKeyVaultBackend.client")
def test_team_scoped_lookup_cannot_reach_a_longer_teams_namespace(self,
mock_client):
"""The team scoped name is not safe by construction -- the id can
extend it.
@@ -174,6 +181,7 @@ class TestAzureKeyVaultBackend:
assert backend.get_conn_value("prod--my_db", team_name="teama") is None
+ @multi_team_enabled
@mock.patch(f"{KEY_VAULT_MODULE}.AzureKeyVaultBackend.client")
def test_underscores_cannot_manufacture_a_team_namespace(self,
mock_client):
"""``build_path`` maps ``_`` onto the separator, so ``__`` becomes the
team separator.
@@ -193,6 +201,7 @@ class TestAzureKeyVaultBackend:
assert backend.get_conn_value("prod__my_db", team_name="teama") is None
+ @multi_team_enabled
@mock.patch(f"{KEY_VAULT_MODULE}.AzureKeyVaultBackend.client")
def test_refusing_an_ambiguous_id_is_logged(self, mock_client, caplog):
"""A silent ``None`` is indistinguishable from a missing secret, so
the refusal is logged."""
@@ -215,6 +224,17 @@ class TestAzureKeyVaultBackend:
assert sum(refused_id in r.getMessage() for r in refusals) == 1
mock_client.get_secret.assert_not_called()
+ @mock.patch(f"{KEY_VAULT_MODULE}.AzureKeyVaultBackend.client")
+ def test_ambiguous_id_resolves_when_multi_team_is_disabled(self,
mock_client):
+ """No team scoped secret can exist without multi-team mode, so there
is no ambiguity
+ to refuse -- an ordinary id containing the separator must resolve
normally."""
+ mock_client.get_secret.return_value = mock.Mock(value="world")
+ backend = AzureKeyVaultBackend()
+
+ assert backend.get_conn_value("prod--my_db") == "world"
+ assert backend.get_variable("prod__hello") == "world"
+ assert backend.get_config("prod--sql_alchemy_conn") == "world"
+
@mock.patch(f"{KEY_VAULT_MODULE}.AzureKeyVaultBackend._get_secret")
def test_variable_prefix_none_value(self, mock_get_secret):
"""
diff --git a/providers/yandex/src/airflow/providers/yandex/secrets/lockbox.py
b/providers/yandex/src/airflow/providers/yandex/secrets/lockbox.py
index 4cf8f8c2c77..e102044a980 100644
--- a/providers/yandex/src/airflow/providers/yandex/secrets/lockbox.py
+++ b/providers/yandex/src/airflow/providers/yandex/secrets/lockbox.py
@@ -30,6 +30,7 @@ import yandex.cloud.lockbox.v1.secret_service_pb2_grpc as
secret_service_pb_grpc
import yandexcloud
from airflow.models import Connection
+from airflow.providers.common.compat.sdk import conf
from airflow.providers.yandex.utils.credentials import get_credentials
from airflow.providers.yandex.utils.defaults import default_conn_name
from airflow.providers.yandex.utils.fields import get_field_from_extras
@@ -272,7 +273,12 @@ class LockboxSecretBackend(BaseSecretsBackend,
LoggingMixin):
the caller's own team builds looks equivalent and is not -- a caller
in team ``a`` would
match ``a//b``'s namespace on the prefix and read its secrets. Only
the caller's own
namespace is ever constructed, never parsed.
+
+ Only checked in multi-team mode: ``team_name`` is never non-``None``
otherwise, so no
+ team scoped secret can exist to collide with.
"""
+ if not conf.getboolean("core", "multi_team", fallback=False):
+ return False
return self.sep * TEAM_SEP_MULTIPLIER in secret_id
def _log_refusal(self, kind: str, secret_id: str) -> None:
diff --git a/providers/yandex/tests/unit/yandex/secrets/test_lockbox.py
b/providers/yandex/tests/unit/yandex/secrets/test_lockbox.py
index d50a9f9b947..311e364105e 100644
--- a/providers/yandex/tests/unit/yandex/secrets/test_lockbox.py
+++ b/providers/yandex/tests/unit/yandex/secrets/test_lockbox.py
@@ -30,6 +30,10 @@ import yandex.cloud.lockbox.v1.secret_service_pb2 as
secret_service_pb
from airflow.providers.yandex.secrets.lockbox import LockboxSecretBackend
from airflow.providers.yandex.utils.defaults import default_conn_name
+from tests_common.test_utils.config import conf_vars
+
+multi_team_enabled = conf_vars({("core", "multi_team"): "True"})
+
class TestLockboxSecretBackend:
@patch("airflow.providers.yandex.secrets.lockbox.LockboxSecretBackend._get_secret_value")
@@ -305,6 +309,7 @@ class TestLockboxSecretBackend:
assert result == "global-value"
mock_get_payload.assert_called_once_with("456", ANY)
+ @multi_team_enabled
@patch("airflow.providers.yandex.secrets.lockbox.LockboxSecretBackend._get_secrets")
def
test_get_variable_returns_none_for_team_scoped_key_without_team_name(self,
mock_get_secrets):
backend = LockboxSecretBackend()
@@ -312,6 +317,7 @@ class TestLockboxSecretBackend:
assert backend.get_variable("teama//hello") is None
mock_get_secrets.assert_not_called()
+ @multi_team_enabled
@patch("airflow.providers.yandex.secrets.lockbox.LockboxSecretBackend._get_secrets")
def
test_get_conn_value_returns_none_for_team_scoped_id_without_team_name(self,
mock_get_secrets):
backend = LockboxSecretBackend()
@@ -319,6 +325,7 @@ class TestLockboxSecretBackend:
assert backend.get_conn_value("teama//my_db") is None
mock_get_secrets.assert_not_called()
+ @multi_team_enabled
@patch("airflow.providers.yandex.secrets.lockbox.LockboxSecretBackend._get_secrets")
@patch("airflow.providers.yandex.secrets.lockbox.LockboxSecretBackend._get_payload")
def test_another_teams_secret_is_not_reachable(self, mock_get_payload,
mock_get_secrets):
@@ -339,6 +346,7 @@ class TestLockboxSecretBackend:
assert backend.get_conn_value("teama//my_db", team_name="teamb") is
None
mock_get_payload.assert_not_called()
+ @multi_team_enabled
@patch("airflow.providers.yandex.secrets.lockbox.LockboxSecretBackend._get_secrets")
@patch("airflow.providers.yandex.secrets.lockbox.LockboxSecretBackend._get_payload")
def test_team_whose_name_extends_the_callers_is_not_reachable(self,
mock_get_payload, mock_get_secrets):
@@ -355,6 +363,7 @@ class TestLockboxSecretBackend:
assert backend.get_conn_value("teama//prod//my_db", team_name="teama")
is None
mock_get_payload.assert_not_called()
+ @multi_team_enabled
@patch("airflow.providers.yandex.secrets.lockbox.LockboxSecretBackend._get_secrets")
@patch("airflow.providers.yandex.secrets.lockbox.LockboxSecretBackend._get_payload")
def test_team_scoped_lookup_cannot_reach_a_longer_teams_namespace(
@@ -379,6 +388,7 @@ class TestLockboxSecretBackend:
assert backend.get_conn_value("prod//my_db", team_name="teama") is None
mock_get_payload.assert_not_called()
+ @multi_team_enabled
@patch("airflow.providers.yandex.secrets.lockbox.LockboxSecretBackend._get_secrets")
def test_refusing_an_ambiguous_id_is_logged(self, mock_get_secrets,
caplog):
"""A silent ``None`` is indistinguishable from a missing secret, so
the refusal is logged."""
@@ -392,6 +402,22 @@ class TestLockboxSecretBackend:
assert all(r.levelname == "WARNING" for r in refusals)
mock_get_secrets.assert_not_called()
+
@patch("airflow.providers.yandex.secrets.lockbox.LockboxSecretBackend._get_secrets")
+
@patch("airflow.providers.yandex.secrets.lockbox.LockboxSecretBackend._get_payload")
+ def test_ambiguous_id_resolves_when_multi_team_is_disabled(self,
mock_get_payload, mock_get_secrets):
+ """No team scoped secret can exist without multi-team mode, so there
is no ambiguity
+ to refuse -- an ordinary id containing the separator must resolve
normally."""
+ mock_get_secrets.return_value = [
+ secret_pb.Secret(id="123", name="airflow/connections/prod//my_db"),
+ ]
+ mock_get_payload.return_value = payload_pb.Payload(
+ entries=[payload_pb.Payload.Entry(text_value="prod-conn")]
+ )
+
+ backend = LockboxSecretBackend()
+
+ assert backend.get_conn_value("prod//my_db") == "prod-conn"
+
@patch("airflow.providers.yandex.secrets.lockbox.LockboxSecretBackend._get_secrets")
@patch("airflow.providers.yandex.secrets.lockbox.LockboxSecretBackend._get_payload")
def test_yandex_lockbox_secret_backend__get_secret_value(self,
mock_get_payload, mock_get_secrets):