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 8c3a1169709 Add team_name attribute to plugins for multi-team (#69502)
8c3a1169709 is described below

commit 8c3a11697099f46dee05826e12084d80fe2769f0
Author: Niko Oliveira <[email protected]>
AuthorDate: Wed Jul 15 12:24:14 2026 -0700

    Add team_name attribute to plugins for multi-team (#69502)
    
    * Add team_name attribute to plugins for multi-team
    
    First step for Plugin team awareness. This adds a team_name attribute to
    AirflowPlugin so a plugin can be scoped to a team, exposes it through the
    plugins API, and filters the /api/v2/plugins listing so users only see
    global plugins and plugins for teams they are authorized for when
    multi-team mode is enabled. The API server also validates at startup that
    every team-scoped plugin references an existing team.
    
    * Add team_name to expected plugin dict in CLI tests
    
    * Harden assertions on DB test
---
 airflow-core/src/airflow/api_fastapi/app.py        |  3 +
 .../api_fastapi/core_api/datamodels/plugins.py     |  1 +
 .../core_api/openapi/v2-rest-api-generated.yaml    |  5 ++
 .../api_fastapi/core_api/routes/public/plugins.py  | 16 ++++-
 airflow-core/src/airflow/plugins_manager.py        | 39 +++++++++++-
 .../airflow/ui/openapi-gen/requests/schemas.gen.ts | 11 ++++
 .../airflow/ui/openapi-gen/requests/types.gen.ts   |  1 +
 .../core_api/routes/public/test_plugins.py         | 73 ++++++++++++++++++++++
 .../unit/cli/commands/test_plugins_command.py      |  1 +
 .../tests/unit/plugins/test_plugins_manager.py     | 68 ++++++++++++++++++++
 .../src/airflowctl/api/datamodels/generated.py     |  1 +
 .../plugins_manager/plugins_manager.py             |  8 +++
 .../tests/plugins_manager/test_plugins_manager.py  | 16 +++++
 13 files changed, 241 insertions(+), 2 deletions(-)

diff --git a/airflow-core/src/airflow/api_fastapi/app.py 
b/airflow-core/src/airflow/api_fastapi/app.py
index a4dbc3c9b72..6e6bca84389 100644
--- a/airflow-core/src/airflow/api_fastapi/app.py
+++ b/airflow-core/src/airflow/api_fastapi/app.py
@@ -132,8 +132,11 @@ def create_app(apps: str = "all") -> FastAPI:
         app.mount("/execution", task_exec_api_app)
 
     if "all" in apps_list or "core" in apps_list:
+        from airflow import plugins_manager
+
         app.state.dag_bag = dag_bag
         init_plugins(app)
+        plugins_manager.validate_plugin_teams()
         init_auth_manager(app)
         init_flask_plugins(app)
         init_views(app)  # Core views need to be the last routes added - it 
has a catch all route
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/plugins.py 
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/plugins.py
index 2bddb29ac96..ed2e9d6a86f 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/plugins.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/plugins.py
@@ -107,6 +107,7 @@ class PluginResponse(BaseModel):
     """Plugin serializer."""
 
     name: str
+    team_name: str | None = None
     macros: list[str]
     flask_blueprints: list[str]
     fastapi_apps: list[FastAPIAppResponse]
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
index 005da0c6f4b..af18cab351e 100644
--- 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
+++ 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
@@ -15381,6 +15381,11 @@ components:
         name:
           type: string
           title: Name
+        team_name:
+          anyOf:
+          - type: string
+          - type: 'null'
+          title: Team Name
         macros:
           items:
             type: string
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/plugins.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/plugins.py
index 57c27a99b68..f9df579f929 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/plugins.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/plugins.py
@@ -22,6 +22,7 @@ from fastapi import Depends
 from pydantic import ValidationError
 
 from airflow import plugins_manager
+from airflow.api_fastapi.app import get_auth_manager
 from airflow.api_fastapi.auth.managers.models.resource_details import 
AccessView
 from airflow.api_fastapi.common.parameters import QueryLimit, QueryOffset
 from airflow.api_fastapi.common.router import AirflowRouter
@@ -30,7 +31,8 @@ from airflow.api_fastapi.core_api.datamodels.plugins import (
     PluginImportErrorCollectionResponse,
     PluginResponse,
 )
-from airflow.api_fastapi.core_api.security import requires_access_view
+from airflow.api_fastapi.core_api.security import GetUserDep, 
requires_access_view
+from airflow.configuration import conf
 
 logger = structlog.get_logger(__name__)
 
@@ -44,8 +46,20 @@ plugins_router = AirflowRouter(tags=["Plugin"], 
prefix="/plugins")
 def get_plugins(
     limit: QueryLimit,
     offset: QueryOffset,
+    user: GetUserDep,
 ) -> PluginCollectionResponse:
     plugins_info = sorted(plugins_manager.get_plugin_info(), key=lambda x: 
x["name"])
+
+    # In multi-team mode, a team-scoped plugin is only visible to users 
authorized for
+    # its team. Global plugins (team_name is None) remain visible to everyone.
+    if conf.getboolean("core", "multi_team"):
+        authorized_teams = get_auth_manager().get_authorized_teams(user=user)
+        plugins_info = [
+            plugin_dict
+            for plugin_dict in plugins_info
+            if plugin_dict.get("team_name") is None or 
plugin_dict["team_name"] in authorized_teams
+        ]
+
     valid_plugins: list[PluginResponse] = []
     for plugin_dict in plugins_info:
         try:
diff --git a/airflow-core/src/airflow/plugins_manager.py 
b/airflow-core/src/airflow/plugins_manager.py
index 2aa93951e89..f747e8d37f9 100644
--- a/airflow-core/src/airflow/plugins_manager.py
+++ b/airflow-core/src/airflow/plugins_manager.py
@@ -366,7 +366,7 @@ def get_plugin_info(attrs_to_dump: Iterable[str] | None = 
None) -> list[dict[str
         }
     plugins_info = []
     for plugin in _get_plugins()[0]:
-        info: dict[str, Any] = {"name": plugin.name}
+        info: dict[str, Any] = {"name": plugin.name, "team_name": 
plugin.team_name}
         for attr in attrs_to_dump:
             if attr in ("global_operator_extra_links", "operator_extra_links"):
                 info[attr] = [f"<{qualname(d.__class__)} object>" for d in 
getattr(plugin, attr)]
@@ -422,3 +422,40 @@ def get_priority_weight_strategy_plugins() -> dict[str, 
type[PriorityWeightStrat
 def get_import_errors() -> dict[str, str]:
     """Get import errors encountered during plugin loading."""
     return _get_plugins()[1]
+
+
+def validate_plugin_teams() -> None:
+    """
+    Validate that every team-scoped plugin references a team that exists in 
the database.
+
+    Only enforced when multi-team mode is enabled. This must run in a context 
with
+    metadata database access (the API server) — never in the Dag processor, 
triggerer,
+    or workers, which reach the database only through the Execution API.
+
+    Raises ``AirflowConfigException`` if any plugin declares a ``team_name`` 
that is not
+    present in the database, naming the offending plugins and unknown teams so 
the
+    misconfiguration can be fixed quickly.
+    """
+    if not conf.getboolean("core", "multi_team"):
+        return
+
+    from airflow.exceptions import AirflowConfigException
+    from airflow.models.team import Team
+
+    known_teams = Team.get_all_team_names()
+    unknown_by_plugin = {
+        plugin.name: plugin.team_name
+        for plugin in _get_plugins()[0]
+        if plugin.team_name is not None and plugin.team_name not in known_teams
+    }
+    if unknown_by_plugin:
+        lines = [
+            f"  - Plugin '{plugin_name}' is assigned to team '{team_name}', 
which does not exist."
+            for plugin_name, team_name in unknown_by_plugin.items()
+        ]
+        raise AirflowConfigException(
+            "Some plugins are assigned to teams that do not exist:\n"
+            + "\n".join(lines)
+            + "\n\nCreate a team with `airflow teams create <team_name>`, "
+            "or update the plugin to use an existing team."
+        )
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index e1880fdd4ea..a2fc21293c8 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -5802,6 +5802,17 @@ export const $PluginResponse = {
             type: 'string',
             title: 'Name'
         },
+        team_name: {
+            anyOf: [
+                {
+                    type: 'string'
+                },
+                {
+                    type: 'null'
+                }
+            ],
+            title: 'Team Name'
+        },
         macros: {
             items: {
                 type: 'string'
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index 24b1fbe5f2e..fa719ec13df 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -1534,6 +1534,7 @@ export type PluginImportErrorResponse = {
  */
 export type PluginResponse = {
     name: string;
+    team_name?: string | null;
     macros: Array<(string)>;
     flask_blueprints: Array<(string)>;
     fastapi_apps: Array<FastAPIAppResponse>;
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py
index e995cb4b137..1d9ba2175fa 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py
@@ -16,12 +16,17 @@
 # under the License.
 from __future__ import annotations
 
+from unittest import mock
 from unittest.mock import patch
 
 import pytest
 
+from airflow.plugins_manager import AirflowPlugin
+
 from tests_common.test_utils.asserts import assert_queries_count
+from tests_common.test_utils.config import conf_vars
 from tests_common.test_utils.markers import 
skip_if_force_lowest_dependencies_marker
+from tests_common.test_utils.mock_plugins import mock_plugin_manager
 
 pytestmark = pytest.mark.db_test
 
@@ -201,3 +206,71 @@ class TestGetPluginImportErrors:
     def test_should_response_403(self, unauthorized_test_client):
         response = unauthorized_test_client.get("/plugins/importErrors")
         assert response.status_code == 403
+
+
+class _GlobalPlugin(AirflowPlugin):
+    name = "global_plugin"
+
+
+class _TeamAPlugin(AirflowPlugin):
+    name = "team_a_plugin"
+    team_name = "team_a"
+
+
+class _TeamBPlugin(AirflowPlugin):
+    name = "team_b_plugin"
+    team_name = "team_b"
+
+
+@skip_if_force_lowest_dependencies_marker
+class TestGetPluginsTeamFiltering:
+    _plugins = [_GlobalPlugin(), _TeamAPlugin(), _TeamBPlugin()]
+
+    def test_no_filtering_when_multi_team_disabled(self, test_client):
+        """With multi_team off, all plugins are returned regardless of 
team_name."""
+        with mock_plugin_manager(plugins=self._plugins):
+            response = test_client.get("/plugins")
+
+        assert response.status_code == 200
+        names = {plugin["name"] for plugin in response.json()["plugins"]}
+        assert names == {"global_plugin", "team_a_plugin", "team_b_plugin"}
+
+    @conf_vars({("core", "multi_team"): "True"})
+    
@mock.patch("airflow.api_fastapi.core_api.routes.public.plugins.get_auth_manager")
+    def test_filters_to_authorized_teams_and_global(self, 
mock_get_auth_manager, test_client):
+        """A user authorized for team_a sees global + team_a plugins, but not 
team_b."""
+        mock_get_auth_manager.return_value.get_authorized_teams.return_value = 
{"team_a"}
+
+        with mock_plugin_manager(plugins=self._plugins):
+            response = test_client.get("/plugins")
+
+        assert response.status_code == 200
+        body = response.json()
+        names = {plugin["name"] for plugin in body["plugins"]}
+        assert names == {"global_plugin", "team_a_plugin"}
+        assert body["total_entries"] == 2
+
+    @conf_vars({("core", "multi_team"): "True"})
+    
@mock.patch("airflow.api_fastapi.core_api.routes.public.plugins.get_auth_manager")
+    def test_user_with_no_teams_sees_only_global(self, mock_get_auth_manager, 
test_client):
+        mock_get_auth_manager.return_value.get_authorized_teams.return_value = 
set()
+
+        with mock_plugin_manager(plugins=self._plugins):
+            response = test_client.get("/plugins")
+
+        assert response.status_code == 200
+        body = response.json()
+        names = {plugin["name"] for plugin in body["plugins"]}
+        assert names == {"global_plugin"}
+        assert body["total_entries"] == 1
+
+    @conf_vars({("core", "multi_team"): "True"})
+    
@mock.patch("airflow.api_fastapi.core_api.routes.public.plugins.get_auth_manager")
+    def test_team_name_present_in_response(self, mock_get_auth_manager, 
test_client):
+        mock_get_auth_manager.return_value.get_authorized_teams.return_value = 
{"team_a"}
+
+        with mock_plugin_manager(plugins=self._plugins):
+            response = test_client.get("/plugins")
+
+        team_name_by_plugin = {p["name"]: p["team_name"] for p in 
response.json()["plugins"]}
+        assert team_name_by_plugin == {"global_plugin": None, "team_a_plugin": 
"team_a"}
diff --git a/airflow-core/tests/unit/cli/commands/test_plugins_command.py 
b/airflow-core/tests/unit/cli/commands/test_plugins_command.py
index 8a6944483a8..57070c6cd8c 100644
--- a/airflow-core/tests/unit/cli/commands/test_plugins_command.py
+++ b/airflow-core/tests/unit/cli/commands/test_plugins_command.py
@@ -79,6 +79,7 @@ class TestPluginsCommand:
         assert info == [
             {
                 "name": "test_plugin",
+                "team_name": None,
                 "admin_views": [],
                 "macros": ["unit.plugins.test_plugin.plugin_macro"],
                 "menu_links": [],
diff --git a/airflow-core/tests/unit/plugins/test_plugins_manager.py 
b/airflow-core/tests/unit/plugins/test_plugins_manager.py
index 0dcac96fe0a..cccb8d4228b 100644
--- a/airflow-core/tests/unit/plugins/test_plugins_manager.py
+++ b/airflow-core/tests/unit/plugins/test_plugins_manager.py
@@ -433,3 +433,71 @@ class TestWindowPluginRegistration:
 
         assert qualname(MyCustomWindow) in registered
         assert registered[qualname(MyCustomWindow)] is MyCustomWindow
+
+
+class TestPluginTeamName:
+    """``team_name`` exposure through ``get_plugin_info`` (attribute default 
is covered
+    by the shared plugins_manager tests)."""
+
+    def test_get_plugin_info_includes_team_name(self):
+        from airflow import plugins_manager
+
+        class GlobalPlugin(AirflowPlugin):
+            name = "global_plugin"
+
+        class TeamPlugin(AirflowPlugin):
+            name = "team_plugin"
+            team_name = "team_a"
+
+        with mock_plugin_manager(plugins=[GlobalPlugin(), TeamPlugin()]):
+            info_by_name = {info["name"]: info for info in 
plugins_manager.get_plugin_info()}
+
+        assert info_by_name["global_plugin"]["team_name"] is None
+        assert info_by_name["team_plugin"]["team_name"] == "team_a"
+
+
+class TestValidatePluginTeams:
+    """``validate_plugin_teams`` startup validation."""
+
+    def test_no_op_when_multi_team_disabled(self):
+        from airflow import plugins_manager
+
+        class TeamPlugin(AirflowPlugin):
+            name = "team_plugin"
+            team_name = "nonexistent_team"
+
+        # multi_team defaults to False; validation must return early without 
hitting
+        # the database, even for a plugin pointing at a nonexistent team.
+        with mock_plugin_manager(plugins=[TeamPlugin()]):
+            with mock.patch("airflow.models.team.Team.get_all_team_names") as 
mock_get_all_team_names:
+                plugins_manager.validate_plugin_teams()
+        mock_get_all_team_names.assert_not_called()
+
+    @conf_vars({("core", "multi_team"): "True"})
+    @mock.patch("airflow.models.team.Team.get_all_team_names", 
return_value={"team_a"})
+    def test_passes_for_global_and_known_team_plugins(self, 
mock_get_all_team_names):
+        from airflow import plugins_manager
+
+        class GlobalPlugin(AirflowPlugin):
+            name = "global_plugin"
+
+        class TeamPlugin(AirflowPlugin):
+            name = "team_plugin"
+            team_name = "team_a"
+
+        with mock_plugin_manager(plugins=[GlobalPlugin(), TeamPlugin()]):
+            plugins_manager.validate_plugin_teams()
+
+    @conf_vars({("core", "multi_team"): "True"})
+    @mock.patch("airflow.models.team.Team.get_all_team_names", 
return_value={"team_a"})
+    def test_raises_for_unknown_team(self, mock_get_all_team_names):
+        from airflow import plugins_manager
+        from airflow.exceptions import AirflowConfigException
+
+        class TeamPlugin(AirflowPlugin):
+            name = "team_plugin"
+            team_name = "unknown_team"
+
+        with mock_plugin_manager(plugins=[TeamPlugin()]):
+            with pytest.raises(AirflowConfigException, 
match="team_plugin.*unknown_team"):
+                plugins_manager.validate_plugin_teams()
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py 
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index 9bb75d8dd87..5b89aca9712 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -2000,6 +2000,7 @@ class PluginResponse(BaseModel):
     """
 
     name: Annotated[str, Field(title="Name")]
+    team_name: Annotated[str | None, Field(title="Team Name")] = None
     macros: Annotated[list[str], Field(title="Macros")]
     flask_blueprints: Annotated[list[str], Field(title="Flask Blueprints")]
     fastapi_apps: Annotated[list[FastAPIAppResponse], Field(title="Fastapi 
Apps")]
diff --git 
a/shared/plugins_manager/src/airflow_shared/plugins_manager/plugins_manager.py 
b/shared/plugins_manager/src/airflow_shared/plugins_manager/plugins_manager.py
index e8dea0d1294..01f9752718e 100644
--- 
a/shared/plugins_manager/src/airflow_shared/plugins_manager/plugins_manager.py
+++ 
b/shared/plugins_manager/src/airflow_shared/plugins_manager/plugins_manager.py
@@ -89,6 +89,14 @@ class AirflowPlugin:
     """Class used to define AirflowPlugin."""
 
     name: str | None = None
+
+    # The team that owns this plugin when multi-team mode is enabled.
+    # ``None`` means the plugin is global: its listeners, macros, API 
endpoints, and
+    # UI views are available to all teams (the default, backwards-compatible 
behavior).
+    # When set, the plugin's behavior is scoped to the named team. Set at 
code/build
+    # time in the plugin definition; there is no deployment-time override.
+    team_name: str | None = None
+
     source: AirflowPluginSource | None = None
     macros: list[Any] = []
     admin_views: list[Any] = []
diff --git 
a/shared/plugins_manager/tests/plugins_manager/test_plugins_manager.py 
b/shared/plugins_manager/tests/plugins_manager/test_plugins_manager.py
index 723c0ae4b54..414259908ae 100644
--- a/shared/plugins_manager/tests/plugins_manager/test_plugins_manager.py
+++ b/shared/plugins_manager/tests/plugins_manager/test_plugins_manager.py
@@ -25,6 +25,7 @@ from unittest import mock
 import pytest
 
 from airflow_shared.plugins_manager import (
+    AirflowPlugin,
     EntryPointSource,
     PluginsDirectorySource,
     _load_entrypoint_plugins,
@@ -109,3 +110,18 @@ class TestPluginsManager:
                 "test.plugins.test_plugins_manager",
                 "my_fake_module not found",
             ) in import_errors.items()
+
+
+class TestAirflowPluginTeamName:
+    def test_team_name_defaults_to_none(self):
+        class GlobalPlugin(AirflowPlugin):
+            name = "global_plugin"
+
+        assert GlobalPlugin.team_name is None
+
+    def test_team_name_can_be_scoped_to_a_team(self):
+        class TeamPlugin(AirflowPlugin):
+            name = "team_plugin"
+            team_name = "team_a"
+
+        assert TeamPlugin.team_name == "team_a"

Reply via email to