This is an automated email from the ASF dual-hosted git repository.
vincbeck 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 0164c9931e3 Show the teams of the signed-in user in multi-team mode
(#71503)
0164c9931e3 is described below
commit 0164c9931e3ef0749600875a7cf2d7eef9f5e9aa
Author: Vincent <[email protected]>
AuthorDate: Fri Aug 14 10:43:30 2026 -0400
Show the teams of the signed-in user in multi-team mode (#71503)
When an Airflow environment runs in multi-team mode, users had no way to
see which
teams they belong to.
`GET /ui/auth/me` now returns a `teams` field, and the UI lists those teams
in the
user menu below the user name. The field is `null`, and the UI section
hidden,
when the environment is not in multi-team mode, so nothing changes for
single-team deployments and auth managers that don't support teams are never
queried.
---
.../api_fastapi/core_api/datamodels/ui/auth.py | 6 ++
.../api_fastapi/core_api/openapi/_private_ui.yaml | 9 +++
.../airflow/api_fastapi/core_api/routes/ui/auth.py | 7 ++
.../airflow/ui/openapi-gen/requests/schemas.gen.ts | 15 ++++
.../airflow/ui/openapi-gen/requests/types.gen.ts | 4 ++
.../airflow/ui/public/i18n/locales/en/common.json | 6 ++
.../ui/src/layouts/Nav/UserSettingsButton.test.tsx | 81 +++++++++++++++++++++-
.../ui/src/layouts/Nav/UserSettingsButton.tsx | 45 +++++++++++-
.../api_fastapi/core_api/routes/ui/test_auth.py | 44 ++++++++++++
9 files changed, 213 insertions(+), 4 deletions(-)
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/auth.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/auth.py
index f5563fbd6c7..3d5d2431400 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/auth.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/auth.py
@@ -19,6 +19,8 @@ from __future__ import annotations
from enum import Enum
+from pydantic import Field
+
from airflow.api_fastapi.common.types import ExtraMenuItem, MenuItem
from airflow.api_fastapi.core_api.base import BaseModel
@@ -35,6 +37,10 @@ class AuthenticatedMeResponse(BaseModel):
id: str
username: str
+ teams: list[str] | None = Field(
+ default=None,
+ description="Teams the user has access to. Null when the environment
does not run in multi-team mode.",
+ )
class TokenType(str, Enum):
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
index 32f0f6f1735..57ff25b664a 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
@@ -2377,6 +2377,15 @@ components:
username:
type: string
title: Username
+ teams:
+ anyOf:
+ - items:
+ type: string
+ type: array
+ - type: 'null'
+ title: Teams
+ description: Teams the user has access to. Null when the environment
does
+ not run in multi-team mode.
type: object
required:
- id
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/auth.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/auth.py
index c7d889fca52..10f88a27728 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/auth.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/auth.py
@@ -22,6 +22,7 @@ import logging
from fastapi import Depends
from airflow.api_fastapi.app import get_auth_manager
+from airflow.api_fastapi.common.db.common import SessionDep
from airflow.api_fastapi.common.router import AirflowRouter
from airflow.api_fastapi.core_api.datamodels.ui.auth import (
AuthenticatedMeResponse,
@@ -55,11 +56,17 @@ def get_auth_menus(
@auth_router.get("/auth/me")
def get_current_user_info(
user: GetUserDep,
+ session: SessionDep,
) -> AuthenticatedMeResponse:
"""Convienently get the current authenticated user information."""
+ teams = None
+ if conf.getboolean("core", "multi_team"):
+ teams = sorted(get_auth_manager().get_authorized_teams(user=user,
session=session))
+
return AuthenticatedMeResponse(
id=user.get_id(),
username=user.get_name(),
+ teams=teams,
)
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 1388b6f37f4..624c749fc6c 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
@@ -8710,6 +8710,21 @@ export const $AuthenticatedMeResponse = {
username: {
type: 'string',
title: 'Username'
+ },
+ teams: {
+ anyOf: [
+ {
+ items: {
+ type: 'string'
+ },
+ type: 'array'
+ },
+ {
+ type: 'null'
+ }
+ ],
+ title: 'Teams',
+ description: 'Teams the user has access to. Null when the
environment does not run in multi-team mode.'
}
},
type: 'object',
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 8136b7c0d56..8f9c0dd3845 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
@@ -2194,6 +2194,10 @@ export type XComUpdateBody = {
export type AuthenticatedMeResponse = {
id: string;
username: string;
+ /**
+ * Teams the user has access to. Null when the environment does not run in
multi-team mode.
+ */
+ teams?: Array<(string)> | null;
};
/**
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index 741495ba110..4648ecd4eac 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -420,6 +420,12 @@
},
"taskInstance_one": "Task Instance",
"taskInstance_other": "Task Instances",
+ "teams": {
+ "more_one": "and {{count}} more",
+ "more_other": "and {{count}} more",
+ "none": "No team",
+ "title": "Teams"
+ },
"timeRange": {
"last12Hours": "Last 12 Hours",
"last24Hours": "Last 24 Hours",
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.test.tsx
b/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.test.tsx
index c6688151b16..6d6b79c9c68 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.test.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.test.tsx
@@ -18,20 +18,97 @@
*/
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type * as OpenapiQueries from "openapi/queries";
import { Wrapper } from "src/utils/Wrapper";
import { UserSettingsButton } from "./UserSettingsButton";
+const mockCurrentUser: { data: { id: string; teams?: Array<string> | null;
username: string } } = {
+ data: { id: "test", teams: null, username: "test" },
+};
+
+vi.mock("openapi/queries", async (importOriginal) => ({
+ ...(await importOriginal<typeof OpenapiQueries>()),
+ useAuthLinksServiceGetCurrentUserInfo: () => mockCurrentUser,
+}));
+
+const openUserMenu = () => fireEvent.click(screen.getByRole("button", { name:
/user/iu }));
+
describe("UserSettingsButton", () => {
+ afterEach(() => {
+ mockCurrentUser.data = { id: "test", teams: null, username: "test" };
+ });
+
it("links to the settings page from the user menu", async () => {
render(<UserSettingsButton externalViews={[]} />, { wrapper: Wrapper });
- fireEvent.click(screen.getByRole("button", { name: /user/iu }));
+ openUserMenu();
const settingsLink = await screen.findByRole("menuitem", { name:
/settings.title/iu });
expect(settingsLink).toHaveAttribute("href", "/settings");
});
+
+ it("lists each team the user belongs to when multi-team is enabled", async
() => {
+ mockCurrentUser.data = { id: "test", teams: ["team-a", "team-b"],
username: "test" };
+ render(<UserSettingsButton externalViews={[]} />, { wrapper: Wrapper });
+
+ openUserMenu();
+
+ expect(await screen.findByText("team-a")).toBeInTheDocument();
+ expect(screen.getByText("team-b")).toBeInTheDocument();
+ });
+
+ it("counts the teams it does not list when the user belongs to many", async
() => {
+ mockCurrentUser.data = {
+ id: "test",
+ teams: Array.from({ length: 8 }, (_, index) => `team-${index}`),
+ username: "test",
+ };
+ render(<UserSettingsButton externalViews={[]} />, { wrapper: Wrapper });
+
+ openUserMenu();
+
+ expect(await screen.findByText("team-4")).toBeInTheDocument();
+ expect(screen.queryByText("team-5")).not.toBeInTheDocument();
+ expect(screen.getByText("teams.more")).toBeInTheDocument();
+ });
+
+ it("reveals the teams it does not list when hovering the overflow count",
async () => {
+ mockCurrentUser.data = {
+ id: "test",
+ teams: Array.from({ length: 8 }, (_, index) => `team-${index}`),
+ username: "test",
+ };
+ render(<UserSettingsButton externalViews={[]} />, { wrapper: Wrapper });
+
+ openUserMenu();
+
+ fireEvent.pointerMove(await screen.findByText("teams.more"), {
pointerType: "mouse" });
+
+ expect(await screen.findByText("team-5")).toBeInTheDocument();
+ expect(screen.getByText("team-6")).toBeInTheDocument();
+ expect(screen.getByText("team-7")).toBeInTheDocument();
+ });
+
+ it("tells the user they belong to no team when multi-team is enabled", async
() => {
+ mockCurrentUser.data = { id: "test", teams: [], username: "test" };
+ render(<UserSettingsButton externalViews={[]} />, { wrapper: Wrapper });
+
+ openUserMenu();
+
+ expect(await screen.findByText("teams.none")).toBeInTheDocument();
+ });
+
+ it("hides teams when the deployment does not run in multi-team mode", async
() => {
+ render(<UserSettingsButton externalViews={[]} />, { wrapper: Wrapper });
+
+ openUserMenu();
+
+ await screen.findByRole("menuitem", { name: /settings.title/iu });
+
+ expect(screen.queryByText("teams.title")).not.toBeInTheDocument();
+ });
});
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx
b/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx
index df41e3912fd..54439c21c0a 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Box, Icon, useDisclosure } from "@chakra-ui/react";
+import { Box, HStack, Icon, useDisclosure, VStack } from "@chakra-ui/react";
import { useTranslation } from "react-i18next";
import {
FiKey,
@@ -33,7 +33,7 @@ import {
} from "react-icons/fi";
import { useAuthLinksServiceGetCurrentUserInfo } from "openapi/queries";
-import { Menu } from "src/components/ui";
+import { Menu, Tooltip } from "src/components/ui";
import { RouterLink } from "src/components/ui/RouterLink";
import { useColorMode } from "src/context/colorMode/useColorMode";
import type { NavItemResponse } from "src/utils/types";
@@ -44,6 +44,9 @@ import { NavButton } from "./NavButton";
import { PluginMenuItem } from "./PluginMenuItem";
import TokenGenerationModal from "./TokenGenerationModal";
+// Beyond a handful of teams the list would push the menu items below the fold.
+const MAX_VISIBLE_TEAMS = 5;
+
const COLOR_MODES = {
DARK: "dark",
LIGHT: "light",
@@ -97,6 +100,44 @@ export const UserSettingsButton = ({ externalViews }: {
readonly externalViews:
<Box fontSize="md" fontWeight="semibold">
{`${currentUser.username} (id: ${currentUser.id})`}
</Box>
+ {Array.isArray(currentUser.teams) ? (
+ <>
+ <Box color="fg.muted" fontSize="sm" mt={2}>
+ {translate("teams.title")}
+ </Box>
+ {currentUser.teams.length ? (
+ <HStack fontSize="sm" gap={2} wrap="wrap">
+ {currentUser.teams.slice(0,
MAX_VISIBLE_TEAMS).map((team) => (
+ <Box key={team}>{team}</Box>
+ ))}
+ {currentUser.teams.length > MAX_VISIBLE_TEAMS ? (
+ <Tooltip
+ closeDelay={0}
+ content={
+ <VStack align="start" gap={0}>
+
{currentUser.teams.slice(MAX_VISIBLE_TEAMS).map((team) => (
+ <Box key={team}>{team}</Box>
+ ))}
+ </VStack>
+ }
+ openDelay={0}
+ portalled
+ >
+ <Box color="fg.muted" textDecoration="underline
dotted">
+ {translate("teams.more", {
+ count: currentUser.teams.length -
MAX_VISIBLE_TEAMS,
+ })}
+ </Box>
+ </Tooltip>
+ ) : undefined}
+ </HStack>
+ ) : (
+ <Box color="fg.muted" fontSize="sm">
+ {translate("teams.none")}
+ </Box>
+ )}
+ </>
+ ) : undefined}
</Box>
<Menu.Separator />
</>
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_auth.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_auth.py
index 287f2f9318b..1601169f81c 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_auth.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_auth.py
@@ -22,6 +22,10 @@ import pytest
from airflow.api_fastapi.auth.managers.simple.simple_auth_manager import
SimpleAuthManager
from airflow.api_fastapi.common.types import ExtraMenuItem, MenuItem
+from airflow.models.team import Team
+
+from tests_common.test_utils.config import conf_vars
+from tests_common.test_utils.db import clear_db_teams
pytestmark = pytest.mark.db_test
@@ -61,6 +65,12 @@ class TestGetAuthLinks:
class TestGetMeResponse:
+ @pytest.fixture(autouse=True)
+ def clean_teams(self):
+ clear_db_teams()
+ yield
+ clear_db_teams()
+
def test_should_response_200_with_authenticated_user(self, test_client):
"""Test /auth/me endpoint with SimpleAuthManager authenticated user."""
response = test_client.get("/auth/me")
@@ -69,8 +79,42 @@ class TestGetMeResponse:
assert response.json() == {
"username": "test",
"id": "test",
+ "teams": None,
}
+ @conf_vars({("core", "multi_team"): "true"})
+ def test_teams_of_user_authorized_on_all_teams(self, test_client, session):
+ session.add_all([Team(name="team2"), Team(name="team1")])
+ session.commit()
+
+ response = test_client.get("/auth/me")
+
+ assert response.status_code == 200
+ assert response.json()["teams"] == ["team1", "team2"]
+
+ @conf_vars({("core", "multi_team"): "true"})
+ @mock.patch.object(SimpleAuthManager, "_is_admin", return_value=False)
+ def test_teams_limited_to_the_teams_the_user_belongs_to(self, _,
test_client, session):
+ session.add_all([Team(name="team1"), Team(name="team2")])
+ session.commit()
+
+ response = test_client.get("/auth/me")
+
+ assert response.status_code == 200
+ # The authenticated test user belongs to ``team1`` only.
+ assert response.json()["teams"] == ["team1"]
+
+ @conf_vars({("core", "multi_team"): "true"})
+ @mock.patch.object(SimpleAuthManager, "_is_admin", return_value=False)
+ def test_teams_empty_when_user_belongs_to_no_team(self, _, test_client,
session):
+ session.add(Team(name="team2"))
+ session.commit()
+
+ response = test_client.get("/auth/me")
+
+ assert response.status_code == 200
+ assert response.json()["teams"] == []
+
def test_with_unauthenticated_user(self, unauthenticated_test_client):
"""Test /auth/me endpoint with no authentication."""
response = unauthenticated_test_client.get("/auth/me")