This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
commit 0de66c7997eac0b886d16bddd36e95588a882885
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Tue Nov 3 11:37:34 2026 +0100
[v3-4-test] Keep masked Variable values when saving an edited Variable
(#76012) (#76308)
* Keep masked Variable values when saving an edited Variable
closes: #76011
* Restore masked values at any depth and keep Variable JSON layout
---------
(cherry picked from commit ffd9a2d126a07d38cbc62f97fb790e4899dcca5c)
Co-authored-by: Hemkumar Chheda <[email protected]>
Co-authored-by: Claude <[email protected]>
---
.../core_api/services/public/variables.py | 29 ++++++
.../core_api/routes/public/test_variables.py | 104 +++++++++++++++++++++
.../secrets_masker/secrets_masker.py | 10 +-
.../tests/secrets_masker/test_secrets_masker.py | 24 +++--
4 files changed, 155 insertions(+), 12 deletions(-)
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/services/public/variables.py
b/airflow-core/src/airflow/api_fastapi/core_api/services/public/variables.py
index b5e63fb2d84..7b9e91a70fb 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/services/public/variables.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/services/public/variables.py
@@ -17,6 +17,7 @@
from __future__ import annotations
+import json
from typing import cast
from fastapi import HTTPException, status
@@ -24,6 +25,7 @@ from fastapi.exceptions import RequestValidationError
from pydantic import ValidationError
from sqlalchemy import select
+from airflow._shared.secrets_masker import merge
from airflow.api_fastapi.core_api.datamodels.common import (
BulkActionNotOnExistence,
BulkActionOnExistence,
@@ -40,6 +42,24 @@ from airflow.api_fastapi.core_api.services.public.common
import BulkService
from airflow.models.variable import Variable
+def _restore_redacted_value(new_value: str, old_value: str | None, key: str)
-> str:
+ """
+ Put the stored secret back wherever ``new_value`` still holds the mask
``VariableResponse`` sent out.
+
+ The UI edit form is filled from the redacted response, so saving it
unchanged would otherwise
+ write ``***`` over every sensitive field.
+ """
+ if old_value is None:
+ return new_value
+ try:
+ new_obj, old_obj = json.loads(new_value), json.loads(old_value)
+ except json.JSONDecodeError:
+ return merge(new_value, old_value, key)
+ merged = merge(new_obj, old_obj, key)
+ # Keep the caller's formatting when there was nothing to restore. indent=2
matches VariableBody.
+ return new_value if merged == new_obj else json.dumps(merged, indent=2)
+
+
def update_orm_from_pydantic(
old_variable: Variable, patch_body: VariableBody, update_mask: list[str] |
None
) -> Variable:
@@ -65,6 +85,15 @@ def update_orm_from_pydantic(
raise RequestValidationError(errors=e.errors())
non_update_fields = {"key"}
+ if (
+ "value" in patch_body.model_fields_set
+ and (not update_mask or "value" in update_mask)
+ and isinstance(patch_body.value, str)
+ ):
+ patch_body = patch_body.model_copy(
+ update={"value": _restore_redacted_value(patch_body.value,
old_variable.val, old_variable.key)}
+ )
+
# Apply patch via utility
return cast(
"Variable",
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py
index 29c1f4a87d5..dee13cd8d1a 100644
---
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py
+++
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py
@@ -628,6 +628,90 @@ class TestPatchVariable(TestVariableEndpoint):
assert response.status_code == 422
assert Variable.get(key, session=session) == "old value"
+ @pytest.mark.enable_redact
+ @pytest.mark.parametrize("update_mask", [None, ["value"]], ids=["no-mask",
"value-mask"])
+ @pytest.mark.parametrize(
+ ("key", "stored_value"),
+ [
+ (TEST_VARIABLE_KEY2, TEST_VARIABLE_VALUE2),
+ (TEST_VARIABLE_KEY3, TEST_VARIABLE_VALUE3),
+ (TEST_VARIABLE_KEY5, TEST_VARIABLE_VALUE5),
+ (TEST_VARIABLE_KEY6, TEST_VARIABLE_VALUE6),
+ ],
+ ids=["sensitive-key", "sensitive-json-field",
"nested-sensitive-json-fields", "sensitive-key-json"],
+ )
+ def test_patch_with_redacted_value_keeps_stored_secrets(
+ self, test_client, key, stored_value, update_mask
+ ):
+ """A value sent back with fields still masked must not write ``***``
over the stored secrets."""
+ self.create_variables()
+ redacted = test_client.get(f"/variables/{key}").json()["value"]
+ assert "***" in redacted
+
+ response = test_client.patch(
+ f"/variables/{key}",
+ json={"key": key, "value": redacted},
+ params={"update_mask": update_mask} if update_mask else None,
+ )
+
+ assert response.status_code == 200
+ saved = Variable.get(key)
+ if stored_value.startswith("{"):
+ # A restored JSON value is re-serialized, so compare content
rather than layout
+ assert json.loads(saved) == json.loads(stored_value)
+ else:
+ assert saved == stored_value
+
+ @pytest.mark.enable_redact
+ @pytest.mark.parametrize("update_mask", [None, ["value"]], ids=["no-mask",
"value-mask"])
+ def test_patch_with_redacted_value_applies_other_changes(self,
test_client, update_mask):
+ """
+ Editing one field of a JSON value in the UI sends ``value`` with
``update_mask=["value"]``.
+
+ The fields the user did not touch are still ``***`` and get their
stored values back.
+ """
+ self.create_variables()
+ edited =
json.loads(test_client.get(f"/variables/{TEST_VARIABLE_KEY5}").json()["value"])
+ edited["config"]["password"] = "new_password"
+ edited["config"]["next"]["added"] = "new_field"
+
+ response = test_client.patch(
+ f"/variables/{TEST_VARIABLE_KEY5}",
+ json={"key": TEST_VARIABLE_KEY5, "value": json.dumps(edited)},
+ params={"update_mask": update_mask} if update_mask else None,
+ )
+
+ assert response.status_code == 200
+ expected = {
+ "config": {
+ "password": "new_password",
+ "next": {
+ "api_key": "some_api_key",
+ "next_again": {"token": "some_token"},
+ "added": "new_field",
+ },
+ }
+ }
+ # Same layout VariableBody uses for a JSON value, whether or not
anything was restored
+ assert Variable.get(TEST_VARIABLE_KEY5) == json.dumps(expected,
indent=2)
+
+ @pytest.mark.enable_redact
+ def test_patch_restores_secret_nested_deeper_than_max_depth(self,
test_client):
+ stored = {"a": {"b": {"c": {"d": {"e": {"f": {"token":
"some_token"}}}}}}, "schedule": "0 7 * * *"}
+ Variable.set(key="deep", value=stored, serialize_json=True)
+ edited = json.loads(test_client.get("/variables/deep").json()["value"])
+ assert edited["a"]["b"]["c"]["d"]["e"]["f"]["token"] == "***"
+ edited["schedule"] = "0 6 * * *"
+
+ response = test_client.patch(
+ "/variables/deep",
+ json={"key": "deep", "value": json.dumps(edited)},
+ params={"update_mask": ["value"]},
+ )
+
+ assert response.status_code == 200
+ assert Variable.get("deep", deserialize_json=True) == {**stored,
"schedule": "0 6 * * *"}
+
@pytest.mark.enable_redact
def test_patch_with_update_mask_description_only(self, test_client,
session):
"""PATCH with update_mask=['description'] should only update
description, keeping value unchanged."""
@@ -1547,6 +1631,26 @@ class TestBulkVariables(TestVariableEndpoint):
assert response.json()["update"] == {"success": [TEST_VARIABLE_KEY],
"errors": []}
assert Variable.get(TEST_VARIABLE_KEY, deserialize_json=True) ==
{"nested": [1, 2, True, None]}
+ @pytest.mark.enable_redact
+ def test_bulk_update_with_redacted_value_keeps_stored_secrets(self,
test_client):
+ self.create_variables()
+ redacted =
test_client.get(f"/variables/{TEST_VARIABLE_KEY5}").json()["value"]
+ actions = {
+ "actions": [
+ {
+ "action": "update",
+ "entities": [{"key": TEST_VARIABLE_KEY5, "value":
redacted}],
+ "action_on_non_existence": "fail",
+ }
+ ]
+ }
+
+ response = test_client.patch("/variables", json=actions)
+
+ assert response.status_code == 200
+ assert response.json()["update"] == {"success": [TEST_VARIABLE_KEY5],
"errors": []}
+ assert Variable.get(TEST_VARIABLE_KEY5, deserialize_json=True) ==
json.loads(TEST_VARIABLE_VALUE5)
+
def test_bulk_variables_should_respond_401(self,
unauthenticated_test_client):
response = unauthenticated_test_client.patch("/variables", json={})
assert response.status_code == 401
diff --git
a/shared/secrets_masker/src/airflow_shared/secrets_masker/secrets_masker.py
b/shared/secrets_masker/src/airflow_shared/secrets_masker/secrets_masker.py
index 178b451c10b..497e8f1ebcf 100644
--- a/shared/secrets_masker/src/airflow_shared/secrets_masker/secrets_masker.py
+++ b/shared/secrets_masker/src/airflow_shared/secrets_masker/secrets_masker.py
@@ -452,11 +452,9 @@ class SecretsMasker(logging.Filter):
replacement: str,
) -> Redacted:
"""Merge a redacted item with its original unredacted counterpart."""
- if depth > max_depth:
- if isinstance(new_item, str) and new_item == "***":
- return old_item
- return new_item
-
+ # Walk containers at any depth, as _redact does, or a deeply nested
secret is saved as "***".
+ # A subtree _redact_all cut off at max_depth arrives as "***" with
force_sensitive set, and the
+ # last branch below restores it.
try:
# Determine if we should treat this as sensitive
is_sensitive = force_sensitive or (name is not None and
self.should_hide_value_for_key(name))
@@ -526,7 +524,7 @@ class SecretsMasker(logging.Filter):
return old_item
return new_item
- except (TypeError, AttributeError, ValueError):
+ except (TypeError, AttributeError, ValueError, RecursionError):
return new_item
def redact(
diff --git a/shared/secrets_masker/tests/secrets_masker/test_secrets_masker.py
b/shared/secrets_masker/tests/secrets_masker/test_secrets_masker.py
index f993a723eba..f1b415bde2c 100644
--- a/shared/secrets_masker/tests/secrets_masker/test_secrets_masker.py
+++ b/shared/secrets_masker/tests/secrets_masker/test_secrets_masker.py
@@ -34,7 +34,6 @@ from airflow_shared.secrets_masker.secrets_masker import (
RedactedIO,
SecretsMasker,
mask_secret,
- merge,
redact,
reset_secrets_masker,
)
@@ -1541,15 +1540,28 @@ class TestSecretsMaskerMerge:
result = self.masker.merge(new_data, old_data)
assert result == expected
- def test_merge_max_depth(self):
+ def test_merge_ignores_max_depth_when_walking(self):
+ """redact() finds sensitive keys at any depth, so merge() has to
restore them at any depth."""
old_data = {"level1": {"level2": {"level3": {"password":
"original_password"}}}}
new_data = {"level1": {"level2": {"level3": {"password": "***"}}}}
- result = merge(new_data, old_data, max_depth=1)
- assert result == new_data
+ assert self.masker.merge(new_data, old_data, max_depth=1) == old_data
+ assert self.masker.merge(new_data, old_data, max_depth=10) == old_data
- result = self.masker.merge(new_data, old_data, max_depth=10)
- assert result["level1"]["level2"]["level3"]["password"] ==
"original_password"
+ @pytest.mark.parametrize(
+ "original",
+ [
+ {"clusters": [{"nodes": [{"conn": {"auth": {"password": "s"}}}]}]},
+ {"a": {"b": {"c": {"d": {"e": {"f": {"token": "s"}}}}}}},
+ {"a": {"b": {"c": {"d": {"password": {"e": {"f": {"g": "s", "h":
["t"]}}}}}}}},
+ ],
+ ids=["lists-of-dicts", "deeper-than-max-depth",
"sensitive-subtree-cut-off-by-redact-all"],
+ )
+ def test_merge_round_trip_deeply_nested(self, original):
+ redacted = self.masker.redact(original)
+ assert redacted != original
+
+ assert self.masker.merge(redacted, original) == original
def test_merge_enum_values(self):
old_enum = MyEnum.testname