Skip to content

Commit fc6c8b1

Browse files
authored
Check Variable write conflicts against the team being written (#72042)
1 parent dcb63a4 commit fc6c8b1

3 files changed

Lines changed: 71 additions & 4 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

‎airflow-core/src/airflow/models/variable.py‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ def set(
258258
# check if the secret exists in the custom secrets' backend.
259259
from airflow.sdk import SecretCache
260260

261-
Variable.check_for_write_conflict(key=key)
261+
Variable.check_for_write_conflict(key=key, team_name=team_name)
262262
if serialize_json:
263263
stored_value = json.dumps(value, indent=2)
264264
else:
@@ -342,7 +342,7 @@ def update(
342342
"Multi-team mode is not configured in the Airflow environment. To assign a team to a variable, multi-mode must be enabled."
343343
)
344344

345-
Variable.check_for_write_conflict(key=key)
345+
Variable.check_for_write_conflict(key=key, team_name=team_name)
346346

347347
if Variable.get_variable_from_secrets(key=key, team_name=team_name) is None:
348348
raise KeyError(f"Variable {key} does not exist")
@@ -430,7 +430,7 @@ def rotate_fernet_key(self):
430430
self._val = fernet.rotate(self._val.encode("utf-8")).decode()
431431

432432
@staticmethod
433-
def check_for_write_conflict(key: str) -> None:
433+
def check_for_write_conflict(key: str, team_name: str | None = None) -> None:
434434
"""
435435
Log a warning if a variable exists outside the metastore.
436436
@@ -439,11 +439,15 @@ def check_for_write_conflict(key: str) -> None:
439439
subsequent reads will not read the set value.
440440
441441
:param key: Variable Key
442+
:param team_name: Team name the variable is being written for, so the check resolves
443+
against the same scope the write will use
442444
"""
443445
for secrets_backend in ensure_secrets_loaded():
444446
if not isinstance(secrets_backend, MetastoreBackend):
445447
try:
446-
var_val = secrets_backend.get_variable(key=key)
448+
var_val = call_secrets_backend_method(
449+
secrets_backend.get_variable, team_name=team_name, key=key
450+
)
447451
if var_val is not None:
448452
_backend_name = type(secrets_backend).__name__
449453
log.warning(

‎airflow-core/tests/unit/models/test_variable.py‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
from airflow.models import Variable, crypto, variable
3030
from airflow.sdk import SecretCache
31+
from airflow.secrets import BaseSecretsBackend
3132
from airflow.secrets.metastore import MetastoreBackend
3233

3334
from tests_common.test_utils import db
@@ -41,6 +42,30 @@
4142
pytestmark = pytest.mark.db_test
4243

4344

45+
class _TeamUnawareVariableBackend(BaseSecretsBackend):
46+
"""A custom backend whose ``get_variable`` override predates the ``team_name`` keyword."""
47+
48+
def __init__(self):
49+
self.was_called = False
50+
51+
# The signature mismatch with the base class is the point of this fixture, so mypy's
52+
# override check has to be waived here rather than fixed.
53+
def get_variable(self, key: str) -> str | None: # type: ignore[override]
54+
self.was_called = True
55+
return "secret_val"
56+
57+
58+
class _TeamAwareVariableBackend(BaseSecretsBackend):
59+
"""A custom backend whose ``get_variable`` override accepts ``team_name``."""
60+
61+
def __init__(self):
62+
self.received_team_name: str | None = None
63+
64+
def get_variable(self, key: str, team_name: str | None = None) -> str | None:
65+
self.received_team_name = team_name
66+
return "secret_val"
67+
68+
4469
class TestVariable:
4570
@pytest.fixture(autouse=True)
4671
def setup_test_cases(self):
@@ -192,6 +217,43 @@ def test_variable_set_with_extra_secret_backend(self, mock_ensure_secrets, caplo
192217
)
193218
Variable.delete(key="key", session=session)
194219

220+
@mock.patch("airflow.models.variable.ensure_secrets_loaded")
221+
def test_write_conflict_check_forwards_team_name(self, mock_ensure_secrets):
222+
"""The check must resolve against the scope being written, not the global one."""
223+
backend = _TeamAwareVariableBackend()
224+
mock_ensure_secrets.return_value = [backend, MetastoreBackend()]
225+
226+
Variable.check_for_write_conflict(key="key", team_name="team_a")
227+
228+
assert backend.received_team_name == "team_a"
229+
230+
@mock.patch("airflow.models.variable.ensure_secrets_loaded")
231+
def test_write_conflict_check_tolerates_team_unaware_backend(self, mock_ensure_secrets):
232+
"""A backend whose override predates ``team_name`` must still be consulted, not error out."""
233+
backend = _TeamUnawareVariableBackend()
234+
mock_ensure_secrets.return_value = [backend, MetastoreBackend()]
235+
236+
Variable.check_for_write_conflict(key="key", team_name="team_a")
237+
238+
assert backend.was_called
239+
240+
@conf_vars({("core", "multi_team"): "True"})
241+
@mock.patch.object(Variable, "check_for_write_conflict")
242+
def test_set_forwards_team_name_to_write_conflict_check(self, mock_check, testing_team, session):
243+
Variable.set(key="key", value="db-value", team_name=testing_team.name, session=session)
244+
245+
assert mock_check.call_args.kwargs["team_name"] == testing_team.name
246+
247+
@conf_vars({("core", "multi_team"): "True"})
248+
@mock.patch.object(Variable, "check_for_write_conflict")
249+
def test_update_forwards_team_name_to_write_conflict_check(self, mock_check, testing_team, session):
250+
Variable.set(key="key", value="db-value", team_name=testing_team.name, session=session)
251+
SecretCache.invalidate_variable("key")
252+
253+
Variable.update(key="key", value="new-value", team_name=testing_team.name, session=session)
254+
255+
assert mock_check.call_args.kwargs["team_name"] == testing_team.name
256+
195257
def test_variable_set_get_round_trip_json(self):
196258
value = {"a": 17, "b": 47}
197259
Variable.set(key="tested_var_set_id", value=value, serialize_json=True)

0 commit comments

Comments
 (0)