This is an automated email from the ASF dual-hosted git repository.

o-nikolas 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 63b615b2525 Scope plugin macros to their team in multi-team mode 
(#73224)
63b615b2525 is described below

commit 63b615b252589ef49b8504f2dbe2942b543ec896
Author: Niko Oliveira <[email protected]>
AuthorDate: Tue Sep 22 09:43:16 2026 -0700

    Scope plugin macros to their team in multi-team mode (#73224)
    
    A team-scoped plugin's macros were reachable from every task's templates,
    so any team could call macros written to talk to another team's systems.
    
    The worker decides this from a new ``multi_team`` flag on the run context
    rather than from its own config, which is not guaranteed to match the
    scheduler's (misconfigured or intentional attempt to read macros from
    other teams): reading it as disabled while it is in fact enabled would drop
    scoping and apply a team's macros everywhere. The existing ``team_name``
    cannot carry that signal, being ``None`` both for a teamless task and for
    every task when multi-team is off, so a plugin declaring a team would
    otherwise lose its macros in single-team deployments.
    
    Scoping is applied at attribute access rather than when macros are loaded,
    because the loader mutates a process-wide module and a worker process can
    serve tasks from more than one team.
---
 .../execution_api/datamodels/taskinstance.py       | 11 ++++
 .../execution_api/routes/task_instances.py         |  1 +
 .../api_fastapi/execution_api/versions/__init__.py |  8 ++-
 .../execution_api/versions/v2026_10_30.py          | 13 ++++
 .../versions/head/test_task_instances.py           | 43 +++++++++++++
 .../versions/v2025_04_28/test_task_instances.py    |  2 +
 .../src/tests_common/test_utils/mock_plugins.py    |  1 +
 .../src/airflow/sdk/api/datamodels/_generated.py   |  1 +
 task-sdk/src/airflow/sdk/execution_time/context.py | 21 ++++++
 .../airflow/sdk/execution_time/schema/schema.json  |  5 ++
 .../src/airflow/sdk/execution_time/task_runner.py  |  6 +-
 task-sdk/src/airflow/sdk/plugins_manager.py        | 13 ++++
 .../tests/task_sdk/execution_time/test_context.py  | 74 ++++++++++++++++++++++
 .../task_sdk/execution_time/test_task_runner.py    | 33 ++++++++++
 ts-sdk/src/generated/supervisor.ts                 |  2 +
 15 files changed, 232 insertions(+), 2 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/execution_api/datamodels/taskinstance.py 
b/airflow-core/src/airflow/api_fastapi/execution_api/datamodels/taskinstance.py
index ddf31db9718..a0f0f5e3553 100644
--- 
a/airflow-core/src/airflow/api_fastapi/execution_api/datamodels/taskinstance.py
+++ 
b/airflow-core/src/airflow/api_fastapi/execution_api/datamodels/taskinstance.py
@@ -447,6 +447,17 @@ class TIRunContext(BaseModel):
     ``None`` for regular tasks and for stub tasks that declare no parameters.
     """
 
+    multi_team: bool = False
+    """
+    Whether the deployment runs in multi-team mode.
+
+    Sent explicitly because a worker cannot read ``core.multi_team`` itself: 
its config is
+    not guaranteed to match the scheduler's, and reading it as disabled while 
it is in fact
+    enabled would drop team scoping and apply a team's plugins to every task. 
``team_name``
+    cannot stand in for this, being ``None`` both for a teamless task and for 
every task
+    when multi-team is off.
+    """
+
 
 class PrevSuccessfulDagRunResponse(BaseModel):
     """Schema for response with previous successful DagRun information for 
Task Template Context."""
diff --git 
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py 
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
index 14910a4d866..3dd4bc11a0b 100644
--- 
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
+++ 
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
@@ -314,6 +314,7 @@ def ti_run(
             connections=[],
             xcom_keys_to_clear=xcom_keys,
             should_retry=_is_eligible_to_retry(previous_state, ti.try_number, 
ti.max_tries),
+            multi_team=conf.getboolean("core", "multi_team"),
         )
 
         # Only set for lang-SDK (foreign-runtime) tasks with a captured 
TaskFlow arg
diff --git 
a/airflow-core/src/airflow/api_fastapi/execution_api/versions/__init__.py 
b/airflow-core/src/airflow/api_fastapi/execution_api/versions/__init__.py
index 70ea9be2be6..79f3f1833fc 100644
--- a/airflow-core/src/airflow/api_fastapi/execution_api/versions/__init__.py
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/versions/__init__.py
@@ -55,11 +55,17 @@ from airflow.api_fastapi.execution_api.versions.v2026_06_30 
import (
 from airflow.api_fastapi.execution_api.versions.v2026_10_30 import (
     AddArgBindingsToTIRunContext,
     AddCallbackRunEndpoint,
+    AddMultiTeamToTIRunContext,
 )
 
 bundle = VersionBundle(
     HeadVersion(),
-    Version("2026-10-30", AddArgBindingsToTIRunContext, 
AddCallbackRunEndpoint),
+    Version(
+        "2026-10-30",
+        AddArgBindingsToTIRunContext,
+        AddCallbackRunEndpoint,
+        AddMultiTeamToTIRunContext,
+    ),
     Version(
         "2026-06-30",
         AddVariableKeysEndpoint,
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 0620053b3e2..6d5730a71ab 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
@@ -52,3 +52,16 @@ class AddCallbackRunEndpoint(VersionChange):
     instructions_to_migrate_to_previous_version = (
         endpoint("/callbacks/{callback_id}/run", ["PATCH"]).didnt_exist,
     )
+
+
+class AddMultiTeamToTIRunContext(VersionChange):
+    """Add ``multi_team`` so a worker can determine multi-team (e.g. for 
plugin scoping) without needing to trust its own config."""
+
+    description = __doc__
+
+    instructions_to_migrate_to_previous_version = 
(schema(TIRunContext).field("multi_team").didnt_exist,)
+
+    @convert_response_to_previous_version_for(TIRunContext)  # type: 
ignore[arg-type]
+    def remove_multi_team_field(response: ResponseInfo) -> None:  # type: 
ignore[misc]
+        """Strip ``multi_team`` from the run context for older clients."""
+        response.body.pop("multi_team", None)
diff --git 
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
 
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
index 80278c28d07..8ecf7b1b5dd 100644
--- 
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
+++ 
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
@@ -316,6 +316,7 @@ class TestTIRunState:
             "variables": [],
             "connections": [],
             "xcom_keys_to_clear": [],
+            "multi_team": False,
         }
         # upstream_map_indexes is now computed by Task SDK, not returned by 
the server in HEAD version
         assert "upstream_map_indexes" not in result
@@ -831,6 +832,7 @@ class TestTIRunState:
             "variables": [],
             "connections": [],
             "xcom_keys_to_clear": [],
+            "multi_team": False,
             "next_method": "execute_complete",
             "next_kwargs": expected_next_kwargs,
             "start_date": None,
@@ -906,6 +908,7 @@ class TestTIRunState:
             "variables": [],
             "connections": [],
             "xcom_keys_to_clear": [],
+            "multi_team": False,
             "next_method": "execute_complete",
             "next_kwargs": expected_next_kwargs,
         }
@@ -1116,6 +1119,46 @@ class TestTIRunState:
         assert dag_run["run_id"] == "test"
         assert dag_run["state"] == "running"
 
+    @pytest.mark.parametrize(
+        ("multi_team_enabled", "expected"),
+        [
+            pytest.param("False", False, id="multi-team-disabled"),
+            pytest.param("True", True, id="multi-team-enabled"),
+        ],
+    )
+    def test_ti_run_reports_multi_team(
+        self, client, session, create_task_instance, time_machine, 
multi_team_enabled, expected
+    ):
+        """The worker cannot read ``core.multi_team`` itself, so the run 
context carries it."""
+        instant_str = "2024-09-30T12:00:00Z"
+        instant = timezone.parse(instant_str)
+        time_machine.move_to(instant, tick=False)
+
+        ti = create_task_instance(
+            task_id="test_ti_run_reports_multi_team",
+            state=State.QUEUED,
+            dagrun_state=DagRunState.RUNNING,
+            session=session,
+            start_date=instant,
+            dag_id=str(uuid4()),
+        )
+        session.commit()
+
+        with conf_vars({("core", "multi_team"): multi_team_enabled}):
+            response = client.patch(
+                f"/execution/task-instances/{ti.id}/run",
+                json={
+                    "state": "running",
+                    "hostname": "random-hostname",
+                    "unixname": "random-unixname",
+                    "pid": 100,
+                    "start_date": instant_str,
+                },
+            )
+
+        assert response.status_code == 200
+        assert response.json()["multi_team"] is expected
+
     @pytest.mark.parametrize(
         ("multi_team_enabled", "expect_team"),
         [
diff --git 
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/v2025_04_28/test_task_instances.py
 
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/v2025_04_28/test_task_instances.py
index efa29338d92..40954978cc8 100644
--- 
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/v2025_04_28/test_task_instances.py
+++ 
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/v2025_04_28/test_task_instances.py
@@ -96,3 +96,5 @@ class TestTIUpdateState:
         assert result["task_reschedule_count"] == 0
         assert result["max_tries"] == 0
         assert result["should_retry"] is False
+        # Added in 2026-10-30; older clients must not see it.
+        assert "multi_team" not in result
diff --git a/devel-common/src/tests_common/test_utils/mock_plugins.py 
b/devel-common/src/tests_common/test_utils/mock_plugins.py
index fb3c674dbf0..cc100747dd0 100644
--- a/devel-common/src/tests_common/test_utils/mock_plugins.py
+++ b/devel-common/src/tests_common/test_utils/mock_plugins.py
@@ -99,6 +99,7 @@ def mock_plugin_manager(plugins=None, **kwargs):
             plugins_manager.get_priority_weight_strategy_plugins.cache_clear()
 
             sdk_plugins_manager.integrate_macros_plugins.cache_clear()
+            sdk_plugins_manager.get_macro_plugin_teams.cache_clear()
             sdk_plugins_manager.get_hook_lineage_readers_plugins.cache_clear()
 
             if plugins is not None or "import_errors" in kwargs:
diff --git a/task-sdk/src/airflow/sdk/api/datamodels/_generated.py 
b/task-sdk/src/airflow/sdk/api/datamodels/_generated.py
index de4b2ebe420..a435be7e165 100644
--- a/task-sdk/src/airflow/sdk/api/datamodels/_generated.py
+++ b/task-sdk/src/airflow/sdk/api/datamodels/_generated.py
@@ -829,3 +829,4 @@ class TIRunContext(BaseModel):
     should_retry: Annotated[bool | None, Field(title="Should Retry")] = False
     start_date: Annotated[AwareDatetime | None, Field(title="Start Date")] = 
None
     arg_bindings: Annotated[list[TaskArgBinding] | None, Field(title="Arg 
Bindings")] = None
+    multi_team: Annotated[bool | None, Field(title="Multi Team")] = False
diff --git a/task-sdk/src/airflow/sdk/execution_time/context.py 
b/task-sdk/src/airflow/sdk/execution_time/context.py
index 5a14238b088..fdb696ec906 100644
--- a/task-sdk/src/airflow/sdk/execution_time/context.py
+++ b/task-sdk/src/airflow/sdk/execution_time/context.py
@@ -995,6 +995,14 @@ class MacrosAccessor:
     """Wrapper to access Macros module lazily."""
 
     _macros_module = None
+    # Class-level defaults so a plain ``MacrosAccessor()`` keeps working and 
attribute
+    # lookup never falls through to ``__getattr__`` (which would recurse).
+    _team_name: str | None = None
+    _multi_team: bool = False
+
+    def __init__(self, team_name: str | None = None, multi_team: bool = False) 
-> None:
+        self._team_name = team_name
+        self._multi_team = multi_team
 
     def __getattr__(self, item: str) -> Any:
         # Lazily load Macros module
@@ -1002,6 +1010,19 @@ class MacrosAccessor:
             import airflow.sdk.execution_time.macros
 
             self._macros_module = airflow.sdk.execution_time.macros
+
+        if self._multi_team:
+            from airflow.sdk.plugins_manager import get_macro_plugin_teams
+
+            owning_team = get_macro_plugin_teams().get(item)
+            # ``None`` covers both a global plugin and an attribute that is 
not a plugin
+            # module at all, such as a built-in macro.
+            if owning_team is not None and owning_team != self._team_name:
+                raise AttributeError(
+                    f"Macros of plugin {item!r} belong to team {owning_team!r} 
and are not "
+                    f"available to this task."
+                )
+
         return getattr(self._macros_module, item)
 
     def __repr__(self) -> str:
diff --git a/task-sdk/src/airflow/sdk/execution_time/schema/schema.json 
b/task-sdk/src/airflow/sdk/execution_time/schema/schema.json
index dc6a8c053ce..7ae8b5db6e6 100644
--- a/task-sdk/src/airflow/sdk/execution_time/schema/schema.json
+++ b/task-sdk/src/airflow/sdk/execution_time/schema/schema.json
@@ -5121,6 +5121,11 @@
           ],
           "default": null,
           "title": "Arg Bindings"
+        },
+        "multi_team": {
+          "default": false,
+          "title": "Multi Team",
+          "type": "boolean"
         }
       },
       "required": [
diff --git a/task-sdk/src/airflow/sdk/execution_time/task_runner.py 
b/task-sdk/src/airflow/sdk/execution_time/task_runner.py
index 8460d6bbb5e..cacd6ae87ab 100644
--- a/task-sdk/src/airflow/sdk/execution_time/task_runner.py
+++ b/task-sdk/src/airflow/sdk/execution_time/task_runner.py
@@ -303,8 +303,12 @@ class RuntimeTaskInstance(TaskInstance):
         integrate_macros_plugins()
 
         dag_run_conf: dict[str, Any] | None = None
+        macros_accessor = MacrosAccessor()
         if from_server := self._ti_context_from_server:
             dag_run_conf = from_server.dag_run.conf or dag_run_conf
+            macros_accessor = MacrosAccessor(
+                team_name=from_server.dag_run.team_name, 
multi_team=bool(from_server.multi_team)
+            )
 
         validated_params = process_params(self.task.dag, self.task, 
dag_run_conf, suppress_exception=False)
 
@@ -323,7 +327,7 @@ class RuntimeTaskInstance(TaskInstance):
                 "ti": self,
                 "outlet_events": OutletEventAccessors(),
                 "inlet_events": InletEventsAccessors(self.task.inlets),
-                "macros": MacrosAccessor(),
+                "macros": macros_accessor,
                 "params": validated_params,
                 # TODO: Make this go through Public API longer term.
                 # "test_mode": task_instance.test_mode,
diff --git a/task-sdk/src/airflow/sdk/plugins_manager.py 
b/task-sdk/src/airflow/sdk/plugins_manager.py
index 3934e54ef5e..f5acbe60858 100644
--- a/task-sdk/src/airflow/sdk/plugins_manager.py
+++ b/task-sdk/src/airflow/sdk/plugins_manager.py
@@ -132,6 +132,19 @@ def integrate_macros_plugins() -> None:
     )
 
 
+@cache
+def get_macro_plugin_teams() -> dict[str, str | None]:
+    """
+    Map the name of each plugin contributing macros to the team owning it.
+
+    Macros are attached to one module per plugin name, so this is what lets a 
task be
+    offered its own team's and the global plugins' macros but not another 
team's. Only
+    plugins that actually contribute macros get a module, hence a submodule to 
hide.
+    """
+    plugins, _ = _get_plugins()
+    return {plugin.name: plugin.team_name for plugin in plugins if plugin.name 
and plugin.macros}
+
+
 def integrate_listener_plugins(listener_manager: ListenerManager) -> None:
     """Add listeners from plugins."""
     plugins, _ = _get_plugins()
diff --git a/task-sdk/tests/task_sdk/execution_time/test_context.py 
b/task-sdk/tests/task_sdk/execution_time/test_context.py
index cbc579c1f15..3c8c5924423 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_context.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_context.py
@@ -86,6 +86,7 @@ from airflow.sdk.execution_time.context import (
     AssetStateStoreAccessors,
     ConnectionAccessor,
     InletEventsAccessors,
+    MacrosAccessor,
     OutletEventAccessor,
     OutletEventAccessors,
     TaskStateStoreAccessor,
@@ -104,6 +105,7 @@ from airflow.sdk.execution_time.secrets import 
ExecutionAPISecretsBackend
 from airflow.sdk.state import BaseStoreBackend
 
 from tests_common.test_utils.config import conf_vars
+from tests_common.test_utils.mock_plugins import mock_plugin_manager
 
 if TYPE_CHECKING:
     from pydantic import JsonValue
@@ -2538,3 +2540,75 @@ class TestAssetStateStoreAccessorWithCustomBackend:
         result = await 
AssetStateStoreAccessor(name=self.ASSET_NAME).aget("watermark")
 
         assert result == "2026-05-01"
+
+
+class TestMacrosAccessorTeamScoping:
+    """A task may use its own team's and the global plugins' macros, but not 
another team's."""
+
+    @staticmethod
+    def _plugins():
+        from airflow.sdk.plugins_manager import AirflowPlugin
+
+        def team_a_macro():
+            return "team-a"
+
+        def shared_macro():
+            return "shared"
+
+        class TeamAPlugin(AirflowPlugin):
+            name = "team_a_macros"
+            team_name = "team-a"
+            macros = [team_a_macro]
+
+        class GlobalPlugin(AirflowPlugin):
+            name = "global_macros"
+            macros = [shared_macro]
+
+        return [TeamAPlugin, GlobalPlugin]
+
+    @pytest.fixture
+    def integrated_macros(self):
+        from airflow.sdk.plugins_manager import integrate_macros_plugins
+
+        with mock_plugin_manager(plugins=self._plugins()):
+            integrate_macros_plugins()
+            yield
+
+    @pytest.mark.parametrize(
+        ("team_name", "reachable"),
+        [
+            pytest.param("team-a", True, id="owning-team"),
+            pytest.param("team-b", False, id="other-team"),
+            pytest.param(None, False, id="teamless-task"),
+        ],
+    )
+    def test_team_macros_reachable_only_by_their_team(self, integrated_macros, 
team_name, reachable):
+        accessor = MacrosAccessor(team_name=team_name, multi_team=True)
+
+        if reachable:
+            assert accessor.team_a_macros.team_a_macro() == "team-a"
+        else:
+            with pytest.raises(AttributeError, match="belong to team 
'team-a'"):
+                accessor.team_a_macros
+
+    @pytest.mark.parametrize(
+        "team_name",
+        [pytest.param("team-a", id="team-task"), pytest.param(None, 
id="teamless-task")],
+    )
+    def test_global_plugin_macros_stay_reachable(self, integrated_macros, 
team_name):
+        accessor = MacrosAccessor(team_name=team_name, multi_team=True)
+
+        assert accessor.global_macros.shared_macro() == "shared"
+
+    def test_builtin_macros_stay_reachable(self, integrated_macros):
+        """Only plugin submodules are scoped; the macros module's own contents 
are not."""
+        accessor = MacrosAccessor(team_name="team-b", multi_team=True)
+
+        assert accessor.ds_add("2026-01-01", 1) == "2026-01-02"
+
+    def test_nothing_is_hidden_when_multi_team_is_off(self, integrated_macros):
+        """A plugin declaring a team in a single-team deployment keeps working 
as before."""
+        accessor = MacrosAccessor()
+
+        assert accessor.team_a_macros.team_a_macro() == "team-a"
+        assert accessor.global_macros.shared_macro() == "shared"
diff --git a/task-sdk/tests/task_sdk/execution_time/test_task_runner.py 
b/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
index 17ba0709ca0..faceabb5c72 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
@@ -2272,6 +2272,39 @@ class TestRuntimeTaskInstance:
             "ti": runtime_ti,
         }
 
+    def test_macros_in_context_are_scoped_to_the_tasks_team(self, 
create_runtime_ti, mock_supervisor_comms):
+        """The accessor placed in the context must carry the team the server 
reported."""
+        from airflow.sdk.plugins_manager import AirflowPlugin
+
+        from tests_common.test_utils.mock_plugins import mock_plugin_manager
+
+        def team_a_macro():
+            return "team-a"
+
+        class TeamAPlugin(AirflowPlugin):
+            name = "team_a_macros"
+            team_name = "team-a"
+            macros = [team_a_macro]
+
+        runtime_ti = create_runtime_ti(task=BaseOperator(task_id="hello"), 
dag_id="basic_task")
+        # Stand in for a multi-team server handing this task to a worker as 
team-b's.
+        runtime_ti._ti_context_from_server.multi_team = True
+        runtime_ti._ti_context_from_server.dag_run.team_name = "team-b"
+
+        dr = runtime_ti._ti_context_from_server.dag_run
+        mock_supervisor_comms.send.return_value = PrevSuccessfulDagRunResult(
+            data_interval_end=dr.logical_date - timedelta(hours=1),
+            data_interval_start=dr.logical_date - timedelta(hours=2),
+            start_date=dr.start_date - timedelta(hours=1),
+            end_date=dr.start_date,
+        )
+
+        with mock_plugin_manager(plugins=[TeamAPlugin]):
+            macros = runtime_ti.get_template_context()["macros"]
+
+            with pytest.raises(AttributeError, match="belong to team 
'team-a'"):
+                macros.team_a_macros
+
     def test_get_context_with_ti_context_from_server(self, create_runtime_ti, 
mock_supervisor_comms):
         """Test the context keys are added when sent from API server 
(mocked)"""
 
diff --git a/ts-sdk/src/generated/supervisor.ts 
b/ts-sdk/src/generated/supervisor.ts
index 9b89cbf804c..263dfa4c50b 100644
--- a/ts-sdk/src/generated/supervisor.ts
+++ b/ts-sdk/src/generated/supervisor.ts
@@ -257,6 +257,7 @@ export type TaskId1 = string;
 export type Name9 = string;
 export type Kind1 = "literal";
 export type FromDefault = boolean;
+export type MultiTeam = boolean;
 export type Type13 = "TaskCallbackRequest";
 export type Filepath2 = string;
 export type BundleName3 = string;
@@ -1032,6 +1033,7 @@ export interface TIRunContext {
   should_retry?: ShouldRetry;
   start_date?: StartDate2;
   arg_bindings?: ArgBindings;
+  multi_team?: MultiTeam;
 }
 /**
  * Variable schema for responses with fields that are needed for Runtime.

Reply via email to