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 bd8cad6b729 Let non task callers set and delete variables over 
execution API (#76375)
bd8cad6b729 is described below

commit bd8cad6b729c1680cbd1ebf271000a2bb9c8e746
Author: Amogh Desai <[email protected]>
AuthorDate: Wed Nov 4 16:43:56 2026 +0530

    Let non task callers set and delete variables over execution API (#76375)
    
    * Let non task callers set and delete variables over execution API
    
    * review from jed
---
 .../api_fastapi/execution_api/routes/variables.py  |  3 +
 .../airflow/api_fastapi/execution_api/security.py  | 11 +++
 .../execution_api/versions/v2026_10_30.py          |  2 +-
 .../api_fastapi/execution_api/test_security.py     | 98 ++++++++++++----------
 4 files changed, 68 insertions(+), 46 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/variables.py 
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/variables.py
index 7f44b1dfc19..5a0f20beda2 100644
--- a/airflow-core/src/airflow/api_fastapi/execution_api/routes/variables.py
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/routes/variables.py
@@ -34,6 +34,7 @@ from airflow.api_fastapi.execution_api.security import (
     ExecutionAPIRoute,
     ExecutionOrDagParseToken,
     ExecutionOrProcessorSecretsToken,
+    allow_non_ti_writer,
     get_team_name_dep,
 )
 from airflow.models.variable import Variable
@@ -147,6 +148,7 @@ def get_variable(
         status.HTTP_403_FORBIDDEN: {"description": "The caller does not have 
access to the variable"},
     },
 )
+@allow_non_ti_writer
 def put_variable(
     variable_key: Annotated[str, Path(min_length=1)],
     body: VariablePostBody,
@@ -167,6 +169,7 @@ def put_variable(
         status.HTTP_403_FORBIDDEN: {"description": "The caller does not have 
access to the variable"},
     },
 )
+@allow_non_ti_writer
 def delete_variable(
     variable_key: Annotated[str, Path(min_length=1)],
     team_name: Annotated[str | None, Depends(get_team_name_dep)],
diff --git a/airflow-core/src/airflow/api_fastapi/execution_api/security.py 
b/airflow-core/src/airflow/api_fastapi/execution_api/security.py
index ff6daa52c6a..c76308b6398 100644
--- a/airflow-core/src/airflow/api_fastapi/execution_api/security.py
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/security.py
@@ -145,6 +145,7 @@ _REQUEST_SCOPE_JOB_KEY = "dag_processor_job_id"
 _IN_PROCESS_NON_TI_CALLER = "airflow_in_process_non_ti_caller"
 _IN_PROCESS_TRIGGERER_ID = "airflow_in_process_triggerer_id"
 _SKIP_AUTO_TI_ATTEMPT_LIVE = "skip_auto_ti_attempt_live"
+_ALLOW_NON_TI_WRITER = "allow_non_ti_writer"
 _P = ParamSpec("_P")
 _R = TypeVar("_R")
 
@@ -157,6 +158,12 @@ def skip_auto_ti_attempt_live(endpoint: Callable[_P, _R]) 
-> Callable[_P, _R]:
     return endpoint
 
 
+def allow_non_ti_writer(endpoint: Callable[_P, _R]) -> Callable[_P, _R]:
+    """Mark a route whose data no task attempt owns, so ``require_auth`` skips 
its automatic check."""
+    setattr(endpoint, _ALLOW_NON_TI_WRITER, True)
+    return endpoint
+
+
 class JWTBearer(HTTPBearer):
     """
     Validates JWT tokens for the Execution API.
@@ -288,6 +295,7 @@ async def require_auth(
         and not request.scope.get(_IN_PROCESS_NON_TI_CALLER)
         and not request.scope.get(_REQUEST_SCOPE_LIVE_ATTEMPT_KEY)
         and not getattr(route, _SKIP_AUTO_TI_ATTEMPT_LIVE, False)
+        and not getattr(route, _ALLOW_NON_TI_WRITER, False)
     ):
         # The versions package imports routes, which depend on this module.
         from airflow.api_fastapi.execution_api.versions.v2026_10_30 import 
IdentifyArchivedTaskStateUpdates
@@ -477,12 +485,14 @@ class ExecutionAPIRoute(APIRoute):
 
     allowed_token_types: frozenset[str]
     skip_auto_ti_attempt_live: bool
+    allow_non_ti_writer: bool
 
     requires_open_job: bool
 
     def __init__(self, *args: Any, **kwargs: Any) -> None:
         super().__init__(*args, **kwargs)
         self.skip_auto_ti_attempt_live = bool(getattr(self.endpoint, 
_SKIP_AUTO_TI_ATTEMPT_LIVE, False))
+        self.allow_non_ti_writer = bool(getattr(self.endpoint, 
_ALLOW_NON_TI_WRITER, False))
 
         all_scopes: set[str] = set()
         for dep in self.dependencies:
@@ -537,6 +547,7 @@ def _team_name_for_ti_stmt(ti_id):
         .join(DagBundleModel, DagBundleModel.name == DagModel.bundle_name)
         .join(DagBundleModel.teams)
         .where(TaskInstance.id == ti_id)
+        .execution_options(include_all_attempts=True)
     )
 
 
diff --git 
a/airflow-core/src/airflow/api_fastapi/execution_api/versions/v2026_10_30.py 
b/airflow-core/src/airflow/api_fastapi/execution_api/versions/v2026_10_30.py
index 1f7780ee321..1dbebc7fb41 100644
--- a/airflow-core/src/airflow/api_fastapi/execution_api/versions/v2026_10_30.py
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/versions/v2026_10_30.py
@@ -72,7 +72,7 @@ class AddStoppedTaskReport(VersionChange):
 
 
 class IdentifyArchivedTaskStateUpdates(VersionChangeWithSideEffects):
-    """Reject every mutation from an archived attempt with 410; older clients 
keep each endpoint's own response."""
+    """Reject mutations from an archived attempt with 410, except Variable 
writes; older clients keep each endpoint's own response."""
 
     description = __doc__
     instructions_to_migrate_to_previous_version = ()
diff --git a/airflow-core/tests/unit/api_fastapi/execution_api/test_security.py 
b/airflow-core/tests/unit/api_fastapi/execution_api/test_security.py
index 6839530c6b5..77c2b604366 100644
--- a/airflow-core/tests/unit/api_fastapi/execution_api/test_security.py
+++ b/airflow-core/tests/unit/api_fastapi/execution_api/test_security.py
@@ -1104,18 +1104,49 @@ class TestAttemptLiveness:
             assert client.delete(path).status_code == 410
             assert client.get(path).status_code == 410
 
-    def test_unknown_execution_identity_cannot_write(self, client, caller):
-        _, token = caller
+    def test_unknown_execution_identity_cannot_write_xcom(self, client, 
caller, session):
+        ti, token = caller
         token.id = uuid4()
+        path = f"/execution/xcoms/{ti.dag_id}/{ti.run_id}/{ti.task_id}/result"
+
+        assert client.post(path, json="unowned").status_code == 404
+        assert session.scalar(select(XComModel).where(XComModel.dag_id == 
ti.dag_id)) is None
+
+    @pytest.mark.parametrize("identity", ["unknown", "archived"])
+    def test_non_ti_identity_can_write_variables(self, client, caller, 
session, identity):
+        ti, token = caller
+        if identity == "archived":
+            ti.prepare_db_for_next_try(session)
+            session.commit()
+        else:
+            token.id = uuid4()
         key = f"attempt-liveness-{uuid4()}"
         try:
-            response = client.put(f"/execution/variables/{key}", 
json={"value": "unowned"})
-            assert response.status_code == 404
+            assert client.put(f"/execution/variables/{key}", json={"value": 
"unowned"}).status_code == 201
+            assert Variable.get(key) == "unowned"
+            assert client.delete(f"/execution/variables/{key}").status_code == 
204
             with pytest.raises(KeyError):
                 Variable.get(key)
         finally:
             Variable.delete(key)
 
+    def test_archived_attempt_writes_variable_to_its_team(self, client, 
caller, session):
+        ti, _ = caller
+        bundle_name = 
session.scalar(select(DagModel.bundle_name).where(DagModel.dag_id == ti.dag_id))
+        bundle = session.get(DagBundleModel, bundle_name)
+        bundle.teams.append(Team(name="team_a"))
+        ti.prepare_db_for_next_try(session)
+        session.commit()
+        key = f"attempt-liveness-{uuid4()}"
+        try:
+            with conf_vars({("core", "multi_team"): "True"}):
+                response = client.put(f"/execution/variables/{key}", 
json={"value": "zombie"})
+            assert response.status_code == 201
+            assert 
session.scalar(select(Variable.team_name).where(Variable.key == key)) == 
"team_a"
+        finally:
+            Variable.delete(key)
+            clear_db_teams()
+
     ARCHIVED_ROUTES = [
         ("put", "/execution/store/ti/{ti_id}/key", {"value": "stale"}),
         ("delete", "/execution/store/ti/{ti_id}/key", None),
@@ -1127,8 +1158,6 @@ class TestAttemptLiveness:
         ("delete", "/execution/store/asset/by-name/clear?name=asset", None),
         ("post", "/execution/dag-runs/target/run", {}),
         ("post", "/execution/dag-runs/target/run/clear", None),
-        ("put", "/execution/variables/key", {"value": "stale"}),
-        ("delete", "/execution/variables/key", None),
         ("post", "/execution/xcoms/{dag_id}/{run_id}/{task_id}/key", "late"),
     ]
 
@@ -1146,8 +1175,8 @@ class TestAttemptLiveness:
 
         assert response.status_code == 410, response.text
 
-    def test_callback_execution_token_can_mutate_variable(self, client, 
caller, session):
-        _, token = caller
+    def test_callback_execution_token_can_write_xcom(self, client, caller, 
session):
+        ti, token = caller
         callback = Callback()
         callback.fetch_method = CallbackFetchMethod.IMPORT_PATH
         callback.state = CallbackState.QUEUED
@@ -1155,7 +1184,6 @@ class TestAttemptLiveness:
         session.commit()
         token.id = callback.id
         token.claims.scope = "callback"
-        key = f"attempt-liveness-{uuid4()}"
         try:
             response = client.patch(f"/execution/callbacks/{callback.id}/run")
             assert response.status_code == 204
@@ -1163,10 +1191,11 @@ class TestAttemptLiveness:
             token.id = UUID(claims["sub"])
             token.claims = TIClaims(**claims)
 
-            assert client.put(f"/execution/variables/{key}", json={"value": 
"callback"}).status_code == 201
-            assert Variable.get(key) == "callback"
+            response = client.post(
+                f"/execution/xcoms/{ti.dag_id}/{ti.run_id}/{ti.task_id}/key", 
json="callback"
+            )
+            assert response.status_code == 201, response.text
         finally:
-            Variable.delete(key)
             session.delete(callback)
             session.commit()
 
@@ -1190,15 +1219,12 @@ class TestAttemptLiveness:
             session.delete(connection_test)
             session.commit()
 
-    def test_trusted_in_process_caller_without_ti_can_write(self):
-        key = f"attempt-liveness-{uuid4()}"
-        try:
-            with TestClient(InProcessExecutionAPI().app) as client:
-                response = client.put(f"/variables/{key}", json={"value": 
"watcher"})
-            assert response.status_code == 201
-            assert Variable.get(key) == "watcher"
-        finally:
-            Variable.delete(key)
+    def test_trusted_in_process_caller_without_ti_can_write(self, caller, 
session):
+        ti, _ = caller
+        with TestClient(InProcessExecutionAPI().app) as client:
+            response = 
client.post(f"/xcoms/{ti.dag_id}/{ti.run_id}/{ti.task_id}/key", json="watcher")
+        assert response.status_code == 201, response.text
+        assert XComModelV2.get_for_attempt(ti.id, "key", 
session=session).value == "watcher"
 
     @pytest.mark.parametrize("delete_rejected", [False, True], 
ids=["accepted", "archived-token-rejected"])
     def test_trusted_in_process_xcom_uses_current_owner_without_attempt_token(
@@ -1273,14 +1299,12 @@ class TestAttemptLiveness:
         )
         assert XComModelV2.get_for_attempt(archived.id, "key", 
session=session).value == "archived"
 
-    @pytest.mark.parametrize("operation", ["xcom_write", "xcom_delete", 
"rtif_write", "variable_write"])
+    @pytest.mark.parametrize("operation", ["xcom_write", "xcom_delete", 
"rtif_write"])
     def test_admitted_child_mutation_stays_with_archived_uuid(
-        self, client, caller, session, monkeypatch, request, operation
+        self, client, caller, session, monkeypatch, operation
     ):
         attempt, _ = caller
         old_id = attempt.id
-        variable_key = f"attempt-liveness-{uuid4()}"
-        request.addfinalizer(lambda: Variable.delete(variable_key))
         xcom_path = 
f"/execution/xcoms/{attempt.dag_id}/{attempt.run_id}/{attempt.task_id}/value"
         XComModel.set_for_attempt(
             task_instance_id=old_id, key="value", value="original", 
serialize=False, session=session
@@ -1307,19 +1331,9 @@ class TestAttemptLiveness:
                 )
             )
         session.commit()
-        path = {
-            "rtif_write": f"/execution/task-instances/{old_id}/rtif",
-            "variable_write": f"/execution/variables/{variable_key}",
-        }.get(operation, xcom_path)
-        method = {
-            "xcom_write": "POST",
-            "xcom_delete": "DELETE",
-            "rtif_write": "PUT",
-            "variable_write": "PUT",
-        }[operation]
-        body = {"rtif_write": {"field": "late"}, "variable_write": {"value": 
"in flight"}}.get(
-            operation, "late"
-        )
+        path = {"rtif_write": 
f"/execution/task-instances/{old_id}/rtif"}.get(operation, xcom_path)
+        method = {"xcom_write": "POST", "xcom_delete": "DELETE", "rtif_write": 
"PUT"}[operation]
+        body = {"rtif_write": {"field": "late"}}.get(operation, "late")
         admitted, finish = Event(), Event()
         check = security._require_live_attempt
 
@@ -1348,9 +1362,7 @@ class TestAttemptLiveness:
             finally:
                 finish.set()
             response = pending.result(timeout=10)
-        expected_status = {"xcom_write": 201, "xcom_delete": 200, 
"rtif_write": 410, "variable_write": 201}[
-            operation
-        ]
+        expected_status = {"xcom_write": 201, "xcom_delete": 200, 
"rtif_write": 410}[operation]
         assert response.status_code == expected_status, response.text
         session.expire_all()
         assert XComModelV2.get_for_attempt(successor.id, "value", 
session=session).value == "successor"
@@ -1365,10 +1377,6 @@ class TestAttemptLiveness:
                 session.get(XComModelV1, (attempt.dag_run.id, attempt.task_id, 
attempt.map_index, "value"))
                 is None
             )
-        elif operation == "variable_write":
-            assert Variable.get(variable_key) == "in flight"
-            assert client.put(path, json={"value": "too late"}).status_code == 
410
-            assert Variable.get(variable_key) == "in flight"
         else:
             assert RenderedTaskInstanceFields.get_for_attempt(old_id, 
session=session) is None
 

Reply via email to