This is an automated email from the ASF dual-hosted git repository.
ferruzzi 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 fc6c8b1b7ca Check Variable write conflicts against the team being
written (#72042)
fc6c8b1b7ca is described below
commit fc6c8b1b7caa804be2ce59042a3b12a1f94eeaa9
Author: D. Ferruzzi <[email protected]>
AuthorDate: Wed Aug 26 17:11:21 2026 -0700
Check Variable write conflicts against the team being written (#72042)
---
airflow-core/newsfragments/72042.bugfix.rst | 1 +
airflow-core/src/airflow/models/variable.py | 12 +++--
airflow-core/tests/unit/models/test_variable.py | 62 +++++++++++++++++++++++++
3 files changed, 71 insertions(+), 4 deletions(-)
diff --git a/airflow-core/newsfragments/72042.bugfix.rst
b/airflow-core/newsfragments/72042.bugfix.rst
new file mode 100644
index 00000000000..49651487d55
--- /dev/null
+++ b/airflow-core/newsfragments/72042.bugfix.rst
@@ -0,0 +1 @@
+The write-conflict check performed when setting or updating a team-scoped
Variable now resolves against that team rather than the global scope, so a
secrets backend shadowing the key within the team is detected and a global-only
definition no longer warns about a conflict that would not shadow the read.
diff --git a/airflow-core/src/airflow/models/variable.py
b/airflow-core/src/airflow/models/variable.py
index 9493cab60aa..bcbc07a9703 100644
--- a/airflow-core/src/airflow/models/variable.py
+++ b/airflow-core/src/airflow/models/variable.py
@@ -258,7 +258,7 @@ class Variable(Base, LoggingMixin):
# check if the secret exists in the custom secrets' backend.
from airflow.sdk import SecretCache
- Variable.check_for_write_conflict(key=key)
+ Variable.check_for_write_conflict(key=key, team_name=team_name)
if serialize_json:
stored_value = json.dumps(value, indent=2)
else:
@@ -342,7 +342,7 @@ class Variable(Base, LoggingMixin):
"Multi-team mode is not configured in the Airflow environment.
To assign a team to a variable, multi-mode must be enabled."
)
- Variable.check_for_write_conflict(key=key)
+ Variable.check_for_write_conflict(key=key, team_name=team_name)
if Variable.get_variable_from_secrets(key=key, team_name=team_name) is
None:
raise KeyError(f"Variable {key} does not exist")
@@ -430,7 +430,7 @@ class Variable(Base, LoggingMixin):
self._val = fernet.rotate(self._val.encode("utf-8")).decode()
@staticmethod
- def check_for_write_conflict(key: str) -> None:
+ def check_for_write_conflict(key: str, team_name: str | None = None) ->
None:
"""
Log a warning if a variable exists outside the metastore.
@@ -439,11 +439,15 @@ class Variable(Base, LoggingMixin):
subsequent reads will not read the set value.
:param key: Variable Key
+ :param team_name: Team name the variable is being written for, so the
check resolves
+ against the same scope the write will use
"""
for secrets_backend in ensure_secrets_loaded():
if not isinstance(secrets_backend, MetastoreBackend):
try:
- var_val = secrets_backend.get_variable(key=key)
+ var_val = call_secrets_backend_method(
+ secrets_backend.get_variable, team_name=team_name,
key=key
+ )
if var_val is not None:
_backend_name = type(secrets_backend).__name__
log.warning(
diff --git a/airflow-core/tests/unit/models/test_variable.py
b/airflow-core/tests/unit/models/test_variable.py
index 0bc32373a30..cc2f9f94b93 100644
--- a/airflow-core/tests/unit/models/test_variable.py
+++ b/airflow-core/tests/unit/models/test_variable.py
@@ -28,6 +28,7 @@ from sqlalchemy import select
from airflow.models import Variable, crypto, variable
from airflow.sdk import SecretCache
+from airflow.secrets import BaseSecretsBackend
from airflow.secrets.metastore import MetastoreBackend
from tests_common.test_utils import db
@@ -41,6 +42,30 @@ if TYPE_CHECKING:
pytestmark = pytest.mark.db_test
+class _TeamUnawareVariableBackend(BaseSecretsBackend):
+ """A custom backend whose ``get_variable`` override predates the
``team_name`` keyword."""
+
+ def __init__(self):
+ self.was_called = False
+
+ # The signature mismatch with the base class is the point of this fixture,
so mypy's
+ # override check has to be waived here rather than fixed.
+ def get_variable(self, key: str) -> str | None: # type: ignore[override]
+ self.was_called = True
+ return "secret_val"
+
+
+class _TeamAwareVariableBackend(BaseSecretsBackend):
+ """A custom backend whose ``get_variable`` override accepts
``team_name``."""
+
+ def __init__(self):
+ self.received_team_name: str | None = None
+
+ def get_variable(self, key: str, team_name: str | None = None) -> str |
None:
+ self.received_team_name = team_name
+ return "secret_val"
+
+
class TestVariable:
@pytest.fixture(autouse=True)
def setup_test_cases(self):
@@ -192,6 +217,43 @@ class TestVariable:
)
Variable.delete(key="key", session=session)
+ @mock.patch("airflow.models.variable.ensure_secrets_loaded")
+ def test_write_conflict_check_forwards_team_name(self,
mock_ensure_secrets):
+ """The check must resolve against the scope being written, not the
global one."""
+ backend = _TeamAwareVariableBackend()
+ mock_ensure_secrets.return_value = [backend, MetastoreBackend()]
+
+ Variable.check_for_write_conflict(key="key", team_name="team_a")
+
+ assert backend.received_team_name == "team_a"
+
+ @mock.patch("airflow.models.variable.ensure_secrets_loaded")
+ def test_write_conflict_check_tolerates_team_unaware_backend(self,
mock_ensure_secrets):
+ """A backend whose override predates ``team_name`` must still be
consulted, not error out."""
+ backend = _TeamUnawareVariableBackend()
+ mock_ensure_secrets.return_value = [backend, MetastoreBackend()]
+
+ Variable.check_for_write_conflict(key="key", team_name="team_a")
+
+ assert backend.was_called
+
+ @conf_vars({("core", "multi_team"): "True"})
+ @mock.patch.object(Variable, "check_for_write_conflict")
+ def test_set_forwards_team_name_to_write_conflict_check(self, mock_check,
testing_team, session):
+ Variable.set(key="key", value="db-value", team_name=testing_team.name,
session=session)
+
+ assert mock_check.call_args.kwargs["team_name"] == testing_team.name
+
+ @conf_vars({("core", "multi_team"): "True"})
+ @mock.patch.object(Variable, "check_for_write_conflict")
+ def test_update_forwards_team_name_to_write_conflict_check(self,
mock_check, testing_team, session):
+ Variable.set(key="key", value="db-value", team_name=testing_team.name,
session=session)
+ SecretCache.invalidate_variable("key")
+
+ Variable.update(key="key", value="new-value",
team_name=testing_team.name, session=session)
+
+ assert mock_check.call_args.kwargs["team_name"] == testing_team.name
+
def test_variable_set_get_round_trip_json(self):
value = {"a": 17, "b": 47}
Variable.set(key="tested_var_set_id", value=value, serialize_json=True)