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"})