fix(proxy): report the caller's team edit access on /team/info

The dashboard gated the team settings form on a role it guessed from the
is_* props, the members list and the org list. The org list is premium
gated and empty while loading, so a team admin who is also an org admin
was told team admins cannot edit, although /team/update accepts them as
an org admin

/team/info now returns caller_edit_access, resolved by the same helper
/team/update uses, and TeamInfo keys the form and the toast off that
field. The org list is only read for the organization dropdown now, and
general_settings is read through one validated accessor in both handlers
This commit is contained in:
ryan-crabbe-berri 2026-09-15 16:42:31 -07:00
parent 50f890d02d
commit 66519da9b6
7 changed files with 196 additions and 79 deletions

View file

@ -4470,6 +4470,29 @@ class TeamInfoMember(Member):
user_alias: str | None = None
class TeamEditUnrestricted(BaseModel):
kind: Literal["unrestricted"] = "unrestricted"
class TeamEditAsTeamAdmin(BaseModel):
kind: Literal["team_admin"] = "team_admin"
editable_fields: tuple[str, ...]
class TeamEditAsTeamAdminDisabled(BaseModel):
kind: Literal["team_admin_disabled"] = "team_admin_disabled"
class TeamEditNone(BaseModel):
kind: Literal["none"] = "none"
TeamEditAccess = Annotated[
TeamEditUnrestricted | TeamEditAsTeamAdmin | TeamEditAsTeamAdminDisabled | TeamEditNone,
Field(discriminator="kind"),
]
class TeamInfoResponseObjectTeamTable(LiteLLM_TeamTable):
members_with_roles: tuple[TeamInfoMember, ...] = ()
team_member_budget_table: LiteLLM_BudgetTableFull | None = None
@ -4482,6 +4505,7 @@ class TeamInfoResponseObjectTeamTable(LiteLLM_TeamTable):
# None = no org or not a manager; [] or ["all-proxy-models"] = no ceiling.
organization_models: list[str] | None = None
model_max_budget_usage: Mapping[str, Mapping[str, object]] | None = None
caller_edit_access: TeamEditAccess = Field(default_factory=TeamEditNone)
class TeamInfoResponseObject(TypedDict):

View file

@ -33,8 +33,8 @@ from typing import (
import fastapi
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from pydantic import BaseModel, JsonValue, ValidationError
from typing_extensions import ReadOnly, TypedDict
from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError
from typing_extensions import ReadOnly, TypedDict, assert_never
import litellm
from litellm._logging import verbose_proxy_logger
@ -73,6 +73,11 @@ from litellm.proxy._types import (
SpecialProxyStrings,
TeamAccessGroupModelGrant,
TeamAddMemberResponse,
TeamEditAccess,
TeamEditAsTeamAdmin,
TeamEditAsTeamAdminDisabled,
TeamEditNone,
TeamEditUnrestricted,
TeamInfoMember,
TeamInfoResponseObject,
TeamInfoResponseObjectTeamTable,
@ -495,6 +500,33 @@ async def _verify_team_access(
_raise_team_access_denied()
_GENERAL_SETTINGS: Final = TypeAdapter(dict[str, object])
def _general_settings() -> Mapping[str, object]:
from litellm.proxy import proxy_server
return _GENERAL_SETTINGS.validate_python(cast(object, proxy_server.general_settings))
def _caller_edit_access(role: TeamAccessRole | None, general_settings: Mapping[str, object]) -> TeamEditAccess:
"""What the caller may change on /team/update, reported on /team/info so the dashboard never re-derives it."""
match role:
case "proxy_admin" | "org_admin":
return TeamEditUnrestricted()
case "team_admin":
permitted: Final = resolve_team_admin_editable_fields(
general_settings, SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS
)
if not permitted:
return TeamEditAsTeamAdminDisabled()
return TeamEditAsTeamAdmin(editable_fields=tuple(sorted(permitted)))
case None:
return TeamEditNone()
case _:
assert_never(role)
class TeamMemberBudgetHandler:
"""Helper class to handle team member budget, RPM, and TPM limit operations"""
@ -2124,7 +2156,6 @@ async def update_team(
try:
from litellm.proxy.management_helpers.audit_logs import is_audit_logging_enabled
from litellm.proxy.proxy_server import (
general_settings,
litellm_proxy_admin_name,
llm_router,
premium_user,
@ -2192,7 +2223,7 @@ async def update_team(
data=data,
existing=existing_team,
permitted=resolve_team_admin_editable_fields(
general_settings, SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS
_general_settings(), SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS
),
)
)
@ -4625,10 +4656,9 @@ async def team_info(
)
team_table: Final = LiteLLM_TeamTable.model_validate(team_info.model_dump())
await validate_membership(user_api_key_dict=user_api_key_dict, team_table=team_table)
access_role: Final = await _resolve_team_access(team_obj=team_table, user_api_key_dict=user_api_key_dict)
organization_models: Final[list[str] | None] = (
_parent_organization_models(team_info)
if await _resolve_team_access(team_obj=team_table, user_api_key_dict=user_api_key_dict) is not None
else None
_parent_organization_models(team_info) if access_role is not None else None
)
## GET ALL KEYS ##
@ -4697,6 +4727,7 @@ async def team_info(
model_max_budget=resolved_team_info.model_max_budget,
cache=model_max_budget_limiter.dual_cache,
),
"caller_edit_access": _caller_edit_access(access_role, _general_settings()),
}
)

View file

@ -15122,3 +15122,65 @@ async def test_resolve_team_access_ranks_proxy_admin_then_org_admin_then_team_ad
assert await _resolve_team_access(team_obj=team, user_api_key_dict=outsider) is None
org_lookup.return_value = True
assert await _resolve_team_access(team_obj=team, user_api_key_dict=roster_admin) == "org_admin"
_ROSTER_ADMIN_CALLER = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="admin-1")
_MEMBER_CALLER = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="member-1")
@pytest.mark.parametrize(
"caller, org_admin, enabled_fields, expected",
[
pytest.param(_PROXY_ADMIN_CALLER, False, (), {"kind": "unrestricted"}, id="proxy-admin"),
pytest.param(
UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, user_id="viewer"),
False,
("tpm_limit",),
{"kind": "none"},
id="proxy-admin-viewer",
),
pytest.param(_ROSTER_ADMIN_CALLER, True, (), {"kind": "unrestricted"}, id="org-admin-who-is-also-team-admin"),
pytest.param(_ROSTER_ADMIN_CALLER, False, (), {"kind": "team_admin_disabled"}, id="team-admin-nothing-enabled"),
pytest.param(
_ROSTER_ADMIN_CALLER,
False,
("tpm_limit",),
{"kind": "team_admin", "editable_fields": ["tpm_limit"]},
id="team-admin-field-enabled",
),
pytest.param(_MEMBER_CALLER, False, ("tpm_limit",), {"kind": "none"}, id="plain-member"),
],
)
@pytest.mark.asyncio
async def test_team_info_reports_what_the_caller_may_edit(caller, org_admin, enabled_fields, expected):
"""The dashboard gates its edit form on this field instead of guessing the caller's role from the org list,
which is premium-gated and can be empty for a dual-role org admin."""
from fastapi import Request
from litellm.proxy.management_endpoints import team_endpoints
team_row = LiteLLM_TeamTable(
team_id="team-1",
organization_id="org-1",
members_with_roles=[Member(user_id="admin-1", role="admin"), Member(user_id="member-1", role="user")],
)
mock_prisma = MagicMock()
mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_row)
mock_prisma.db.litellm_usertable.find_many = AsyncMock(return_value=[])
mock_prisma.get_data = AsyncMock(return_value=[])
with (
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), # test-quality-ok: no seam on team_info
patch.object(team_endpoints, "get_all_team_memberships", AsyncMock(return_value=[])), # test-quality-ok: no seam on team_info
patch.object( # test-quality-ok: the org-admin lookup needs a real prisma client this file's MagicMock cannot provide
team_endpoints, "_is_user_org_admin_for_team", AsyncMock(return_value=org_admin)
),
_team_admin_may_edit(*enabled_fields),
):
response = await team_endpoints.team_info(
http_request=MagicMock(spec=Request),
team_id="team-1",
user_api_key_dict=caller,
)
assert response["team_info"].caller_edit_access.model_dump(mode="json") == expected

View file

@ -312,7 +312,7 @@ const seedDefaultMocks = () => {
isError: false,
} as any);
mockUseUISettings.mockReturnValue({
data: { values: { team_admin_editable_team_fields: [] } },
data: { values: {} },
isLoading: false,
} as any);
mockUseKeys.mockReturnValue({
@ -666,19 +666,9 @@ describe("TeamInfoView", () => {
});
});
it("shows edit tabs when the fetched team data marks the session user as team admin, even without the is_team_admin prop", async () => {
it("shows edit tabs when the proxy reports the session user may edit, even without the is_team_admin prop", async () => {
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({
members_with_roles: [
{
user_id: "user-1",
user_email: "admin@test.com",
role: "admin",
spend: 0,
budget_id: "budget1",
},
],
}),
createMockTeamData({ caller_edit_access: { kind: "team_admin_disabled" } }),
);
renderWithProviders(<TeamInfoView {...defaultProps} is_team_admin={false} is_proxy_admin={false} />);
@ -1881,9 +1871,11 @@ describe("TeamInfoView", () => {
authState.userRole = "Internal User";
});
it("tells a team admin to ask a proxy admin when no team field is enabled for them", async () => {
it("tells a team admin to ask a proxy admin when the proxy reports no team field is enabled for them", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData());
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({ caller_edit_access: { kind: "team_admin_disabled" } }),
);
renderWithProviders(<TeamInfoView {...teamAdminProps} />);
@ -1897,13 +1889,26 @@ describe("TeamInfoView", () => {
expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument();
});
it("opens the form for a team admin once a proxy admin has enabled a field", async () => {
mockUseUISettings.mockReturnValue({
data: { values: { team_admin_editable_team_fields: ["tpm_limit"] } },
isLoading: false,
} as any);
it("opens the form for a team admin once the proxy reports an enabled field", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData());
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({ caller_edit_access: { kind: "team_admin", editable_fields: ["tpm_limit"] } }),
);
renderWithProviders(<TeamInfoView {...teamAdminProps} />);
await user.click(await screen.findByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
expect(await screen.findByLabelText("Team Name")).toBeInTheDocument();
expect(toast.error).not.toHaveBeenCalled();
});
it("opens the form when the proxy reports unrestricted access although the props only mark a team admin", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({ caller_edit_access: { kind: "unrestricted" } }),
);
renderWithProviders(<TeamInfoView {...teamAdminProps} />);
@ -1917,7 +1922,9 @@ describe("TeamInfoView", () => {
it("never gates a proxy admin on the team admin field list", async () => {
authState.userRole = "Admin";
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData());
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({ caller_edit_access: { kind: "unrestricted" } }),
);
renderWithProviders(<TeamInfoView {...defaultProps} />);

View file

@ -1,7 +1,6 @@
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useCan from "@/app/(dashboard)/hooks/useCan";
import { organizationKeys, useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import { useQueryClient } from "@tanstack/react-query";
import UserSearchModal from "@/components/common_components/user_search_modal";
import {
@ -50,7 +49,8 @@ import { useFieldArray } from "react-hook-form";
import { z } from "zod/v4";
import GuardrailsSelect from "./GuardrailsSelect";
import {
resolveTeamEditAccess,
type CallerEditAccess,
parseTeamEditAccess,
TEAM_ADMIN_EDITING_DISABLED_DESCRIPTION,
TEAM_ADMIN_EDITING_DISABLED_TITLE,
} from "./teamAdminEditAccess";
@ -303,6 +303,7 @@ export interface TeamData {
guardrails?: string[];
policies?: string[];
object_permission?: ObjectPermission | null;
caller_edit_access?: CallerEditAccess;
team_member_budget_table: {
max_budget: number;
budget_duration: string | null;
@ -321,7 +322,6 @@ export interface TeamInfoProps {
accessToken: string | null;
is_team_admin: boolean;
is_proxy_admin: boolean;
is_org_admin?: boolean;
userModels: string[];
editTeam: boolean;
premiumUser?: boolean;
@ -537,7 +537,6 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
accessToken,
is_team_admin,
is_proxy_admin,
is_org_admin = false,
userModels,
editTeam,
premiumUser = false,
@ -581,25 +580,16 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
const [teamModelMaxBudget, setTeamModelMaxBudget] = useState<ModelMaxBudget>({});
const routerSettingsRef = React.useRef<RouterSettingsAccordionRef>(null);
const [organization, setOrganization] = useState<Organization | null>(null);
const { userRole, userId } = useAuthorized();
const { userRole } = useAuthorized();
const { data: allMcpServers = [], isError: mcpServersFailed, isLoading: mcpServersLoading } = useMCPServers();
const { data: allMcpToolsets = [], isError: mcpToolsetsFailed, isLoading: mcpToolsetsLoading } = useMCPToolsets();
const { data: allAccessGroups = [], isError: accessGroupsFailed, isLoading: accessGroupsLoading } = useAccessGroups();
const canEditTeamEstimates = isProxyAdminRole(userRole);
const teamEstimateTooltip = estimateTooltips(canEditTeamEstimates, "team");
const { data: userOrganizations = [] } = useOrganizations();
const { data: uiSettingsData } = useUISettings();
const { data: teamMetadataSchemaFields = [], isLoading: isTeamMetadataSchemaLoading } = useTeamMetadataSchema();
const queryClient = useQueryClient();
// Check if user is org admin for this team's organization
const isOrgAdminForTeam = useMemo(() => {
const teamOrgId = teamData?.team_info?.organization_id;
if (!teamOrgId || !userId) return false;
const org = userOrganizations.find((o) => o.organization_id === teamOrgId);
return org?.members?.some((m: any) => m.user_id === userId && m.user_role === "org_admin") ?? false;
}, [teamData, userOrganizations, userId]);
// Models currently selected in the team edit form, used to scope the per-model
// rate limit dropdown to models this team actually has access to.
const watchedModels = form.watch("models");
@ -623,22 +613,8 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
return unfurlWildcardModelsInList(selected, userModels);
}, [watchedModels, teamData, userModels]);
const isTeamAdminFromTeamData = useMemo(
() =>
teamData?.team_info?.members_with_roles?.some(
(member) => member.user_id != null && member.user_id === userId && member.role === "admin",
) ?? false,
[teamData, userId],
);
const canEditTeam = is_team_admin || is_proxy_admin || is_org_admin || isOrgAdminForTeam || isTeamAdminFromTeamData;
const viewerIsProxyAdmin = is_proxy_admin || isProxyAdminRole(userRole);
const viewerIsOrgAdmin = is_org_admin || isOrgAdminForTeam;
const editsAsTeamAdmin = canEditTeam && !viewerIsProxyAdmin && !viewerIsOrgAdmin;
const teamEditAccess = useMemo(
() => resolveTeamEditAccess(editsAsTeamAdmin, uiSettingsData?.values),
[editsAsTeamAdmin, uiSettingsData],
);
const teamEditAccess = useMemo(() => parseTeamEditAccess(teamData?.team_info?.caller_edit_access), [teamData]);
const canEditTeam = is_team_admin || is_proxy_admin || teamEditAccess.kind !== "none";
const visibleTabs = useMemo(() => getTeamInfoVisibleTabs(canEditTeam), [canEditTeam]);
const defaultTabKey = useMemo(() => getTeamInfoDefaultTab(editTeam, canEditTeam), [editTeam, canEditTeam]);
const { onTabChange, hasVisited } = useVisitedTabs(defaultTabKey);

View file

@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
import {
parseSupportedTeamAdminEditableFields,
parseTeamAdminEditableFields,
resolveTeamEditAccess,
parseTeamEditAccess,
} from "./teamAdminEditAccess";
describe("parseTeamAdminEditableFields", () => {
@ -48,22 +48,28 @@ describe("parseSupportedTeamAdminEditableFields", () => {
});
});
describe("resolveTeamEditAccess", () => {
it("does not restrict callers who edit as proxy or org admin", () => {
expect(resolveTeamEditAccess(false, { team_admin_editable_team_fields: [] })).toEqual({ kind: "unrestricted" });
describe("parseTeamEditAccess", () => {
it.each([
["unrestricted", { kind: "unrestricted" }],
["team_admin_disabled", { kind: "team_admin_disabled" }],
["none", { kind: "none" }],
])("passes the proxy's %s verdict through", (_kind, verdict) => {
expect(parseTeamEditAccess(verdict)).toEqual(verdict);
});
it("disables editing for a team admin when no field is enabled", () => {
expect(resolveTeamEditAccess(true, { team_admin_editable_team_fields: [] })).toEqual({
kind: "team_admin_disabled",
});
expect(resolveTeamEditAccess(true, undefined)).toEqual({ kind: "team_admin_disabled" });
});
it("hands a team admin the enabled fields", () => {
expect(resolveTeamEditAccess(true, { team_admin_editable_team_fields: ["tpm_limit"] })).toEqual({
it("hands a team admin the fields the proxy enabled", () => {
expect(parseTeamEditAccess({ kind: "team_admin", editable_fields: ["tpm_limit"] })).toEqual({
kind: "team_admin",
editableFields: new Set(["tpm_limit"]),
});
});
it.each([
["the proxy sent nothing", undefined],
["the kind is unknown", { kind: "owner" }],
["a team admin verdict lacks its field list", { kind: "team_admin" }],
["the field list holds a non-string", { kind: "team_admin", editable_fields: [7] }],
])("fails closed to no access when %s", (_label, value) => {
expect(parseTeamEditAccess(value)).toEqual({ kind: "none" });
});
});

View file

@ -6,10 +6,20 @@ export const TEAM_ADMIN_EDITING_DISABLED_TITLE = "Team admins cannot edit team s
export const TEAM_ADMIN_EDITING_DISABLED_DESCRIPTION =
"Ask a proxy admin to enable fields under Settings > UI > Team admin editable fields.";
const callerEditAccessSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("unrestricted") }),
z.object({ kind: z.literal("team_admin"), editable_fields: z.array(z.string()) }),
z.object({ kind: z.literal("team_admin_disabled") }),
z.object({ kind: z.literal("none") }),
]);
export type CallerEditAccess = z.infer<typeof callerEditAccessSchema>;
export type TeamEditAccess =
| { readonly kind: "unrestricted" }
| { readonly kind: "team_admin"; readonly editableFields: ReadonlySet<string> }
| { readonly kind: "team_admin_disabled" };
| { readonly kind: "team_admin_disabled" }
| { readonly kind: "none" };
const fieldListSchema = z.array(z.string()).catch([]);
@ -29,10 +39,11 @@ export const parseSupportedTeamAdminEditableFields = (uiSettingsFieldSchema: unk
return items.success ? fieldListSchema.parse(items.data.enum) : [];
};
export const resolveTeamEditAccess = (editsAsTeamAdmin: boolean, uiSettingsValues: unknown): TeamEditAccess => {
if (!editsAsTeamAdmin) return { kind: "unrestricted" };
const editableFields = parseTeamAdminEditableFields(uiSettingsValues);
return editableFields.length === 0
? { kind: "team_admin_disabled" }
: { kind: "team_admin", editableFields: new Set(editableFields) };
export const parseTeamEditAccess = (callerEditAccess: unknown): TeamEditAccess => {
const parsed = callerEditAccessSchema.safeParse(callerEditAccess);
if (!parsed.success) return { kind: "none" };
if (parsed.data.kind === "team_admin") {
return { kind: "team_admin", editableFields: new Set(parsed.data.editable_fields) };
}
return parsed.data;
};