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

potiuk 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 ae8cdf76eb5 Add support for multi-team in Simple auth manager (#61861)
ae8cdf76eb5 is described below

commit ae8cdf76eb5b39199380ae720ace5e967d86d844
Author: Vincent <[email protected]>
AuthorDate: Mon Feb 16 20:59:04 2026 -0500

    Add support for multi-team in Simple auth manager (#61861)
---
 .../auth/managers/simple/services/login.py         |  5 +-
 .../auth/managers/simple/simple_auth_manager.py    | 60 +++++++++++----
 .../api_fastapi/auth/managers/simple/user.py       | 13 +++-
 .../src/airflow/config_templates/config.yml        |  4 +
 .../auth/managers/simple/services/test_login.py    | 32 +++++---
 .../managers/simple/test_simple_auth_manager.py    | 85 +++++++++++++++++++---
 .../api_fastapi/auth/managers/simple/test_user.py  |  3 -
 airflow-core/tests/unit/api_fastapi/conftest.py    |  4 +-
 .../api_fastapi/core_api/routes/ui/test_teams.py   |  8 +-
 9 files changed, 161 insertions(+), 53 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/services/login.py 
b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/services/login.py
index 3ecfd1cd294..8e64861de1a 100644
--- 
a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/services/login.py
+++ 
b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/services/login.py
@@ -56,7 +56,7 @@ class SimpleAuthManagerLogin:
         found_users = [
             user
             for user in users
-            if user["username"] == body.username and 
passwords[user["username"]] == body.password
+            if user.username == body.username and passwords[user.username] == 
body.password
         ]
 
         if len(found_users) == 0:
@@ -67,7 +67,8 @@ class SimpleAuthManagerLogin:
 
         user = SimpleAuthManagerUser(
             username=body.username,
-            role=found_users[0]["role"],
+            role=found_users[0].role,
+            teams=found_users[0].teams,
         )
 
         return get_auth_manager().generate_jwt(
diff --git 
a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/simple_auth_manager.py
 
b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/simple_auth_manager.py
index 1bdbcc0a3a2..d1e9126b9eb 100644
--- 
a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/simple_auth_manager.py
+++ 
b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/simple_auth_manager.py
@@ -96,9 +96,20 @@ class 
SimpleAuthManager(BaseAuthManager[SimpleAuthManagerUser]):
         return os.path.join(AIRFLOW_HOME, 
"simple_auth_manager_passwords.json.generated")
 
     @staticmethod
-    def get_users() -> list[dict[str, str]]:
-        users = [u.split(":") for u in conf.getlist("core", 
"simple_auth_manager_users")]
-        return [{"username": username, "role": role} for username, role in 
users]
+    def get_users() -> list[SimpleAuthManagerUser]:
+        config_users = [u.split(":") for u in conf.getlist("core", 
"simple_auth_manager_users")]
+        users = []
+        for user in config_users:
+            teams = None
+            if len(user) == 3:
+                if not conf.getboolean("core", "multi_team"):
+                    raise ValueError(
+                        f"The user '{user[0]}' is associated to at least one 
team and multi-team mode is not configured in the Airflow environment."
+                    )
+                teams = user[2].split("|")
+
+            users.append(SimpleAuthManagerUser(username=user[0], role=user[1], 
teams=teams))
+        return users
 
     @staticmethod
     def get_passwords() -> dict[str, str]:
@@ -123,11 +134,11 @@ class 
SimpleAuthManager(BaseAuthManager[SimpleAuthManagerUser]):
                     passwords = self._get_passwords(stream=file)
                     changed = False
                     for user in users:
-                        if user["username"] not in passwords:
+                        if user.username not in passwords:
                             # User does not exist in the file, adding it
-                            passwords[user["username"]] = 
self._generate_password()
+                            passwords[user.username] = 
self._generate_password()
                             self._print_output(
-                                f"Password for user '{user['username']}': 
{passwords[user['username']]}"
+                                f"Password for user '{user.username}': 
{passwords[user.username]}"
                             )
                             changed = True
 
@@ -151,10 +162,14 @@ class 
SimpleAuthManager(BaseAuthManager[SimpleAuthManagerUser]):
         return AUTH_MANAGER_FASTAPI_APP_PREFIX + "/login"
 
     def deserialize_user(self, token: dict[str, Any]) -> SimpleAuthManagerUser:
-        return SimpleAuthManagerUser(username=token["sub"], role=token["role"])
+        return SimpleAuthManagerUser(
+            username=token["sub"],
+            role=token["role"],
+            teams=token.get("teams"),
+        )
 
     def serialize_user(self, user: SimpleAuthManagerUser) -> dict[str, Any]:
-        return {"sub": user.username, "role": user.role}
+        return {"sub": user.username, "role": user.role, "teams": user.teams}
 
     def is_authorized_configuration(
         self,
@@ -177,7 +192,12 @@ class 
SimpleAuthManager(BaseAuthManager[SimpleAuthManagerUser]):
         user: SimpleAuthManagerUser,
         details: ConnectionDetails | None = None,
     ) -> bool:
-        return self._is_authorized(method=method, 
allow_role=SimpleAuthManagerRole.OP, user=user)
+        return self._is_authorized(
+            method=method,
+            allow_role=SimpleAuthManagerRole.OP,
+            user=user,
+            team_name=details.team_name if details else None,
+        )
 
     def is_authorized_dag(
         self,
@@ -192,6 +212,7 @@ class 
SimpleAuthManager(BaseAuthManager[SimpleAuthManagerUser]):
             allow_get_role=SimpleAuthManagerRole.VIEWER,
             allow_role=SimpleAuthManagerRole.USER,
             user=user,
+            team_name=details.team_name if details else None,
         )
 
     def is_authorized_asset(
@@ -234,6 +255,7 @@ class 
SimpleAuthManager(BaseAuthManager[SimpleAuthManagerUser]):
             allow_get_role=SimpleAuthManagerRole.VIEWER,
             allow_role=SimpleAuthManagerRole.OP,
             user=user,
+            team_name=details.team_name if details else None,
         )
 
     def is_authorized_team(
@@ -243,8 +265,7 @@ class 
SimpleAuthManager(BaseAuthManager[SimpleAuthManagerUser]):
         user: SimpleAuthManagerUser,
         details: TeamDetails | None = None,
     ) -> bool:
-        # Simple auth manager is not multi-team mode compatible but to ease 
development, allow all users to see all teams
-        return True
+        return details.name in user.teams if details else False
 
     def is_authorized_variable(
         self,
@@ -253,7 +274,12 @@ class 
SimpleAuthManager(BaseAuthManager[SimpleAuthManagerUser]):
         user: SimpleAuthManagerUser,
         details: VariableDetails | None = None,
     ) -> bool:
-        return self._is_authorized(method=method, 
allow_role=SimpleAuthManagerRole.OP, user=user)
+        return self._is_authorized(
+            method=method,
+            allow_role=SimpleAuthManagerRole.OP,
+            user=user,
+            team_name=details.team_name if details else None,
+        )
 
     def is_authorized_view(self, *, access_view: AccessView, user: 
SimpleAuthManagerUser) -> bool:
         return self._is_authorized(method="GET", 
allow_role=SimpleAuthManagerRole.VIEWER, user=user)
@@ -337,6 +363,7 @@ class 
SimpleAuthManager(BaseAuthManager[SimpleAuthManagerUser]):
         allow_role: SimpleAuthManagerRole,
         user: SimpleAuthManagerUser,
         allow_get_role: SimpleAuthManagerRole | None = None,
+        team_name: str | None = None,
     ):
         """
         Return whether the user is authorized to access a given resource.
@@ -347,16 +374,19 @@ class 
SimpleAuthManager(BaseAuthManager[SimpleAuthManagerUser]):
         :param user: the user to check the authorization for
         :param allow_get_role: minimal role giving access to the resource, if 
the user's role is greater or
             equal than this role, they have access. If not provided, 
``allow_role`` is used
+        :param team_name: team associated to the resource (if any)
         """
-        user_role = user.get_role()
-        if not user_role:
+        if not user.role:
             return False
 
-        role_str = user_role.upper()
+        role_str = user.role.upper()
         role = SimpleAuthManagerRole[role_str]
         if role == SimpleAuthManagerRole.ADMIN:
             return True
 
+        if team_name and team_name not in user.teams:
+            return False
+
         if not allow_get_role:
             allow_get_role = allow_role
 
diff --git a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/user.py 
b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/user.py
index 83987fe593b..0bfd1d8de0f 100644
--- a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/user.py
+++ b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/user.py
@@ -25,11 +25,13 @@ class SimpleAuthManagerUser(BaseUser):
 
     :param username: The username
     :param role: The role associated to the user. If not provided, the user 
has no permission
+    :param teams: The list of teams associated to the user
     """
 
-    def __init__(self, *, username: str, role: str | None) -> None:
+    def __init__(self, *, username: str, role: str | None, teams: list[str] | 
None = None) -> None:
         self.username = username
         self.role = role
+        self.teams = teams or []
 
     def get_id(self) -> str:
         return self.username
@@ -37,5 +39,10 @@ class SimpleAuthManagerUser(BaseUser):
     def get_name(self) -> str:
         return self.username
 
-    def get_role(self) -> str | None:
-        return self.role
+    def __eq__(self, other):
+        if not isinstance(other, SimpleAuthManagerUser):
+            return False
+        return self.username == other.username and self.role == other.role and 
self.teams == other.teams
+
+    def __hash__(self):
+        return hash(self.username)
diff --git a/airflow-core/src/airflow/config_templates/config.yml 
b/airflow-core/src/airflow/config_templates/config.yml
index 3daed0edbaf..627399b0d86 100644
--- a/airflow-core/src/airflow/config_templates/config.yml
+++ b/airflow-core/src/airflow/config_templates/config.yml
@@ -90,6 +90,10 @@ core:
 
         List of user-role delimited with a comma. Each user-role is a colon 
delimited couple of username and
         role. Roles are predefined in simple auth managers: viewer, user, op, 
admin.
+
+        You can also associate users to teams by appending the list of teams 
(separated by "|") as third item
+        in the user-role definition. Example: ``bob:admin:marketing|hr``.
+        This can only be used if ``[core] multi_team`` is set to True.
       version_added: 3.0.0
       type: string
       example: "bob:admin,peter:viewer"
diff --git 
a/airflow-core/tests/unit/api_fastapi/auth/managers/simple/services/test_login.py
 
b/airflow-core/tests/unit/api_fastapi/auth/managers/simple/services/test_login.py
index e6a843c75cc..7c1a3f95677 100644
--- 
a/airflow-core/tests/unit/api_fastapi/auth/managers/simple/services/test_login.py
+++ 
b/airflow-core/tests/unit/api_fastapi/auth/managers/simple/services/test_login.py
@@ -18,7 +18,7 @@
 
 from __future__ import annotations
 
-from unittest.mock import patch
+from unittest.mock import Mock, patch
 
 import pytest
 from fastapi import HTTPException
@@ -32,36 +32,48 @@ TEST_USER_1 = "test1"
 TEST_ROLE_1 = "viewer"
 TEST_USER_2 = "test2"
 TEST_ROLE_2 = "admin"
+TEST_USER_3 = "test3"
+TEST_ROLE_3 = "admin"
+TEST_TEAMS_3 = "test|marketing"
 
 
 @pytest.mark.db_test
 class TestLogin:
+    @conf_vars({("core", "multi_team"): "true"})
     @pytest.mark.parametrize(
-        "test_user",
+        ("user", "role", "teams"),
         [
-            TEST_USER_1,
-            TEST_USER_2,
+            (TEST_USER_1, TEST_ROLE_1, []),
+            (TEST_USER_2, TEST_ROLE_2, []),
+            (TEST_USER_3, TEST_ROLE_3, ["test", "marketing"]),
         ],
     )
     
@patch("airflow.api_fastapi.auth.managers.simple.services.login.get_auth_manager")
-    def test_create_token(self, get_auth_manager, auth_manager, test_user):
-        get_auth_manager.return_value = auth_manager
+    def test_create_token(self, get_auth_manager, auth_manager, user, role, 
teams):
+        mock_am = Mock(wraps=auth_manager)
+        get_auth_manager.return_value = mock_am
 
         with conf_vars(
             {
                 (
                     "core",
                     "simple_auth_manager_users",
-                ): f"{TEST_USER_1}:{TEST_ROLE_1},{TEST_USER_2}:{TEST_ROLE_2}",
+                ): 
f"{TEST_USER_1}:{TEST_ROLE_1},{TEST_USER_2}:{TEST_ROLE_2},{TEST_USER_3}:{TEST_ROLE_3}:{TEST_TEAMS_3}",
             }
         ):
             auth_manager.init()
             passwords = auth_manager.get_passwords()
-            result = SimpleAuthManagerLogin.create_token(
-                body=LoginBody(username=test_user, 
password=passwords.get(test_user, "invalid_password")),
+            SimpleAuthManagerLogin.create_token(
+                body=LoginBody(username=user, password=passwords.get(user, 
"invalid_password")),
                 expiration_time_in_seconds=1,
             )
-            assert result if test_user in [TEST_USER_1, TEST_USER_2] else True
+
+            mock_am.generate_jwt.assert_called_once()
+            args, kwargs = mock_am.generate_jwt.call_args
+            user_obj = kwargs.get("user")
+            assert user_obj.username == user
+            assert user_obj.role == role
+            assert user_obj.teams == teams
 
     @pytest.mark.parametrize(
         "json_body",
diff --git 
a/airflow-core/tests/unit/api_fastapi/auth/managers/simple/test_simple_auth_manager.py
 
b/airflow-core/tests/unit/api_fastapi/auth/managers/simple/test_simple_auth_manager.py
index 1356a9fe1e8..4de8b0ba024 100644
--- 
a/airflow-core/tests/unit/api_fastapi/auth/managers/simple/test_simple_auth_manager.py
+++ 
b/airflow-core/tests/unit/api_fastapi/auth/managers/simple/test_simple_auth_manager.py
@@ -23,7 +23,14 @@ from unittest import mock
 import pytest
 
 from airflow.api_fastapi.app import AUTH_MANAGER_FASTAPI_APP_PREFIX
-from airflow.api_fastapi.auth.managers.models.resource_details import 
AccessView
+from airflow.api_fastapi.auth.managers.models.resource_details import (
+    AccessView,
+    ConnectionDetails,
+    DagDetails,
+    PoolDetails,
+    TeamDetails,
+    VariableDetails,
+)
 from airflow.api_fastapi.auth.managers.simple.user import SimpleAuthManagerUser
 from airflow.api_fastapi.common.types import MenuItem
 
@@ -31,14 +38,19 @@ from tests_common.test_utils.config import conf_vars
 
 
 class TestSimpleAuthManager:
+    @conf_vars(
+        {
+            ("core", "multi_team"): "true",
+            ("core", "simple_auth_manager_users"): 
"test1:viewer,test2:viewer,test3:viewer:test|marketing",
+        }
+    )
     def test_get_users(self, auth_manager):
-        with conf_vars(
-            {
-                ("core", "simple_auth_manager_users"): 
"test1:viewer,test2:viewer",
-            }
-        ):
-            users = auth_manager.get_users()
-            assert users == [{"role": "viewer", "username": "test1"}, {"role": 
"viewer", "username": "test2"}]
+        users = auth_manager.get_users()
+        assert users == [
+            SimpleAuthManagerUser(username="test1", role="viewer", teams=None),
+            SimpleAuthManagerUser(username="test2", role="viewer", teams=None),
+            SimpleAuthManagerUser(username="test3", role="viewer", 
teams=["test", "marketing"]),
+        ]
 
     @pytest.mark.parametrize(
         ("file_content", "expected"),
@@ -121,11 +133,12 @@ class TestSimpleAuthManager:
         result = auth_manager.deserialize_user({"sub": "test", "role": 
"admin"})
         assert result.username == "test"
         assert result.role == "admin"
+        assert result.teams == []
 
     def test_serialize_user(self, auth_manager):
         user = SimpleAuthManagerUser(username="test", role="admin")
         result = auth_manager.serialize_user(user)
-        assert result == {"sub": "test", "role": "admin"}
+        assert result == {"sub": "test", "role": "admin", "teams": []}
 
     @pytest.mark.parametrize(
         "api",
@@ -155,6 +168,43 @@ class TestSimpleAuthManager:
             is result
         )
 
+    @pytest.mark.parametrize(
+        ("api", "details"),
+        [
+            ("is_authorized_connection", ConnectionDetails(team_name="test")),
+            ("is_authorized_connection", None),
+            ("is_authorized_dag", DagDetails(team_name="test")),
+            ("is_authorized_dag", None),
+            ("is_authorized_pool", PoolDetails(team_name="test")),
+            ("is_authorized_pool", None),
+            ("is_authorized_variable", VariableDetails(team_name="test")),
+            ("is_authorized_variable", None),
+        ],
+    )
+    @pytest.mark.parametrize(
+        ("role", "method", "user_teams", "result"),
+        [
+            ("ADMIN", "GET", ["test", "marketing"], True),
+            ("ADMIN", "GET", [], True),
+            ("OP", "GET", [], False),
+            ("OP", "GET", ["marketing"], False),
+            ("OP", "GET", ["test"], True),
+            ("OP", "GET", ["test", "marketing"], True),
+        ],
+    )
+    def test_is_authorized_methods_with_teams(
+        self, auth_manager, api, details, role, method, user_teams, result
+    ):
+        assert (
+            getattr(auth_manager, api)(
+                method=method,
+                user=SimpleAuthManagerUser(username="test", role=role, 
teams=user_teams),
+                details=details,
+            )
+            is result
+            or not details
+        )
+
     @pytest.mark.parametrize(
         ("api", "kwargs"),
         [
@@ -256,11 +306,22 @@ class TestSimpleAuthManager:
             is result
         )
 
-    def test_is_authorized_team(self, auth_manager):
+    @pytest.mark.parametrize(
+        ("user_teams", "team", "expected"),
+        [
+            (None, None, False),
+            (["test"], "marketing", False),
+            (["test"], "test", True),
+            (["test", "marketing"], "test", True),
+        ],
+    )
+    def test_is_authorized_team(self, auth_manager, user_teams, team, 
expected):
         result = auth_manager.is_authorized_team(
-            method="GET", user=SimpleAuthManagerUser(username="test", 
role=None)
+            method="GET",
+            user=SimpleAuthManagerUser(username="test", role=None, 
teams=user_teams),
+            details=TeamDetails(name=team),
         )
-        assert result is True
+        assert expected is result
 
     def test_filter_authorized_menu_items(self, auth_manager):
         items = [MenuItem.ASSETS]
diff --git 
a/airflow-core/tests/unit/api_fastapi/auth/managers/simple/test_user.py 
b/airflow-core/tests/unit/api_fastapi/auth/managers/simple/test_user.py
index 6c34067a3ef..09b06f0d046 100644
--- a/airflow-core/tests/unit/api_fastapi/auth/managers/simple/test_user.py
+++ b/airflow-core/tests/unit/api_fastapi/auth/managers/simple/test_user.py
@@ -23,6 +23,3 @@ class TestSimpleAuthManagerUser:
 
     def test_get_name(self, test_admin):
         assert test_admin.get_name() == "test"
-
-    def test_get_role(self, test_admin):
-        assert test_admin.get_role() == "admin"
diff --git a/airflow-core/tests/unit/api_fastapi/conftest.py 
b/airflow-core/tests/unit/api_fastapi/conftest.py
index 6d57f90a77e..aace17f8a14 100644
--- a/airflow-core/tests/unit/api_fastapi/conftest.py
+++ b/airflow-core/tests/unit/api_fastapi/conftest.py
@@ -74,7 +74,9 @@ def test_client(request):
             token = auth_manager._get_token_signer(
                 expiration_time_in_seconds=(time_after - 
time_very_before).total_seconds()
             ).generate(
-                
auth_manager.serialize_user(SimpleAuthManagerUser(username="test", 
role="admin")),
+                auth_manager.serialize_user(
+                    SimpleAuthManagerUser(username="test", role="admin", 
teams=["team1"])
+                ),
             )
         with 
mock.patch("airflow.models.revoked_token.RevokedToken.is_revoked", 
return_value=False):
             yield TestClient(
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_teams.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_teams.py
index 5874b9845bd..b8c54b35ddc 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_teams.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_teams.py
@@ -55,14 +55,8 @@ class TestListTeams:
                 {
                     "name": "team1",
                 },
-                {
-                    "name": "team2",
-                },
-                {
-                    "name": "team3",
-                },
             ],
-            "total_entries": 3,
+            "total_entries": 1,
         }
 
     @conf_vars({("core", "multi_team"): "true"})

Reply via email to