feat(team): let default_team_params attach access_group_ids to every new team (#49908)

* feat(team): let default_team_params attach access_group_ids to every new team

* fix(types): keep default access_group_ids as Sequence for the type discipline gate

* fix(settings): chunk the default access group existence lookup

* test(team): prove default access groups reach the saved team row and type the settings fixtures

* fix(team): check default access groups on the writer so a fresh group is not rejected behind a read replica
This commit is contained in:
jesus-berri 2026-11-05 19:58:51 -08:00 • committed by GitHub
parent 18b02380e5
commit 4f06ca217f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 287 additions and 5 deletions

View file

@ -1827,6 +1827,7 @@ async def new_team(
"tpm_limit",
"rpm_limit",
"team_member_permissions",
"access_group_ids",
):
if getattr(data, field, None) is None and (
field != "budget_duration" or field not in data.model_fields_set

View file

@ -47,10 +47,12 @@ from litellm.proxy.spend_tracking.ptu_feature_flag import (
is_ptu_cost_attribution_enabled,
)
from litellm.proxy.utils import CONFIG_PARAMS_TARGET, invalidate_config_param
from litellm.repositories.chunked_in import find_many_in
from litellm.repositories.config_repository import ConfigRepository
from litellm.repositories.organization_repository import OrganizationRepository
from litellm.repositories.prisma_protocols import TableActions
from litellm.repositories.table_repositories import (
AccessGroupRepository,
SSOConfigRepository,
UISettingsRepository,
)
@ -1172,6 +1174,33 @@ async def update_internal_user_settings(
)
async def _validate_default_access_groups_exist(access_group_ids: Sequence[str]) -> None:
if not access_group_ids:
return
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(
status_code=500,
detail={"error": "Database not connected. Please connect a database."},
)
existing_groups: Final = await find_many_in(
AccessGroupRepository(prisma_client, use_writer=True).table, "access_group_id", dict.fromkeys(access_group_ids)
)
existing_ids: Final = {group.access_group_id for group in existing_groups}
missing_ids: Final = tuple(dict.fromkeys(ag_id for ag_id in access_group_ids if ag_id not in existing_ids))
if missing_ids:
raise HTTPException(
status_code=400,
detail={
"error": f"Access group(s) not found: {', '.join(missing_ids)}. "
"Create the access group first or remove it from the default team settings."
},
)
@router.patch(
"/update/default_team_settings",
tags=["SSO Settings"],
@ -1188,6 +1217,8 @@ async def update_default_team_settings(
"""
if settings.organization_id is not None:
await _validate_default_organization_exists(settings.organization_id)
if settings.access_group_ids is not None:
await _validate_default_access_groups_exist(settings.access_group_ids)
return await _update_litellm_setting(
settings=settings,

View file

@ -1,3 +1,4 @@
from collections.abc import Sequence
from typing import Literal
from pydantic import ConfigDict, Field
@ -241,3 +242,7 @@ class DefaultTeamSSOParams(LiteLLMPydanticObjectBase):
default=None,
description="Default organization for new teams created without an explicit organization",
)
access_group_ids: Sequence[str] | None = Field(
default=None,
description="Default access groups attached to new teams created without explicit access_group_ids",
)

View file

@ -3,6 +3,7 @@ Tests for applying default team params during team creation
and loading default_team_params from DB on startup.
"""
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@ -11,6 +12,7 @@ from fastapi import HTTPException
import litellm
from litellm.proxy._types import (
LiteLLM_TeamTable,
LiteLLM_BudgetTable,
LiteLLM_OrganizationTable,
NewTeamRequest,
@ -214,6 +216,59 @@ class TestNewTeamDefaultParamsApplied:
assert data.rpm_limit == 500
assert data.team_member_permissions == ["/key/generate", "/key/update"]
async def _create_team(
self, monkeypatch: pytest.MonkeyPatch, data: NewTeamRequest
) -> tuple[dict[str, object], AsyncMock, AsyncMock]:
from litellm.proxy import proxy_server
from litellm.proxy.management_endpoints import team_endpoints
from litellm.proxy.management_endpoints.team_endpoints import new_team
prisma_client: Final = proxy_server.prisma_client
prisma_client.jsonify_team_object = MagicMock(side_effect=lambda db_data: db_data)
async def _create(data: dict[str, object], include: object) -> LiteLLM_TeamTable:
return LiteLLM_TeamTable(**data)
create: Final = AsyncMock(side_effect=_create)
tx: Final = MagicMock()
tx.litellm_teamtable.create = create
prisma_client.db.tx.return_value.__aenter__.return_value = tx
reconcile: Final = AsyncMock(return_value=())
monkeypatch.setattr(team_endpoints, "reconcile_team_access_group_membership", reconcile)
monkeypatch.setattr(team_endpoints, "invalidate_access_group_caches", AsyncMock())
monkeypatch.setattr(team_endpoints, "_add_team_members_to_team", AsyncMock())
response: Final = await new_team(data=data, user_api_key_dict=self._make_admin_auth(), http_request=MagicMock())
return response, create, reconcile
@pytest.mark.asyncio
async def test_default_access_group_ids_attach_to_a_team_created_without_any(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(litellm, "default_team_params", {"access_group_ids": ["central-models"]})
response, create, reconcile = await self._create_team(monkeypatch, NewTeamRequest(team_alias="my-team"))
assert create.await_args is not None
assert create.await_args.kwargs["data"]["access_group_ids"] == ["central-models"]
assert response["access_group_ids"] == ["central-models"]
reconcile.assert_awaited_once()
assert reconcile.await_args is not None
assert reconcile.await_args.args[1] == response["team_id"]
@pytest.mark.asyncio
@pytest.mark.parametrize("explicit_access_group_ids", [["research"], []])
async def test_explicit_access_group_ids_win_over_the_default(
self, monkeypatch: pytest.MonkeyPatch, explicit_access_group_ids: list[str]
):
monkeypatch.setattr(litellm, "default_team_params", {"access_group_ids": ["central-models"]})
response, create, _ = await self._create_team(
monkeypatch, NewTeamRequest(team_alias="my-team", access_group_ids=explicit_access_group_ids)
)
assert create.await_args is not None
assert create.await_args.kwargs["data"]["access_group_ids"] == explicit_access_group_ids
assert response["access_group_ids"] == explicit_access_group_ids
@pytest.mark.asyncio
async def test_explicit_values_not_overridden(self, monkeypatch):
"""When request provides explicit values, defaults do not override them."""

View file

@ -784,10 +784,15 @@ def test_get_group_ids_from_graph_api_response():
[
# Test case 1: Using DefaultTeamSSOParams
DefaultTeamSSOParams(
max_budget=10, budget_duration="1d", models=["special-gpt-5"]
max_budget=10, budget_duration="1d", models=["special-gpt-5"], access_group_ids=["central-models"]
),
# Test case 2: Using Dict
{"max_budget": 10, "budget_duration": "1d", "models": ["special-gpt-5"]},
{
"max_budget": 10,
"budget_duration": "1d",
"models": ["special-gpt-5"],
"access_group_ids": ["central-models"],
},
],
)
async def test_default_team_params(team_params):
@ -832,6 +837,7 @@ async def test_default_team_params(team_params):
assert create_call_args["max_budget"] == 10
assert create_call_args["budget_duration"] == "1d"
assert create_call_args["models"] == ["special-gpt-5"]
assert create_call_args["access_group_ids"] == ["central-models"]
@pytest.mark.asyncio

View file

@ -1,8 +1,8 @@
import json
import os
from collections.abc import Iterator
from collections.abc import Callable, Iterator
from typing import Final
from unittest.mock import patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi.testclient import TestClient
@ -3498,6 +3498,89 @@ def test_update_default_team_settings_without_organization_skips_lookup(
assert mock_proxy_config["save_call_count"]() == 1
@pytest.fixture
def mock_access_group_lookup(monkeypatch: pytest.MonkeyPatch) -> Callable[[frozenset[str]], AsyncMock]:
import litellm
import litellm.proxy.proxy_server as proxy_server_module
def _install(existing_access_group_ids: frozenset[str]) -> AsyncMock:
async def _find_many(where: dict[str, dict[str, list[str]]]) -> list[MagicMock]:
return [
MagicMock(access_group_id=access_group_id)
for access_group_id in where["access_group_id"]["in"]
if access_group_id in existing_access_group_ids
]
find_many: Final = AsyncMock(side_effect=_find_many)
fake_prisma: Final = MagicMock()
fake_prisma.writer_db.litellm_accessgrouptable.find_many = find_many
monkeypatch.setattr(proxy_server_module, "prisma_client", fake_prisma)
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
monkeypatch.setattr(litellm, "default_team_params", {})
return find_many
return _install
def test_update_default_team_settings_rejects_unknown_access_group(
mock_proxy_config: dict[str, object],
mock_auth: None,
mock_access_group_lookup: Callable[[frozenset[str]], AsyncMock],
) -> None:
import litellm
mock_access_group_lookup(frozenset({"real-group"}))
resp: Final = client.patch(
"/update/default_team_settings",
json={"access_group_ids": ["real-group", "ghost-group"]},
)
assert resp.status_code == 400, resp.text
assert "ghost-group" in resp.json()["detail"]["error"]
assert "real-group" not in resp.json()["detail"]["error"]
assert mock_proxy_config["save_call_count"]() == 0
assert litellm.default_team_params == {}
def test_update_default_team_settings_saves_existing_access_groups(
mock_proxy_config: dict[str, object],
mock_auth: None,
mock_access_group_lookup: Callable[[frozenset[str]], AsyncMock],
) -> None:
import litellm
mock_access_group_lookup(frozenset({"real-group", "other-group"}))
resp: Final = client.patch(
"/update/default_team_settings",
json={"access_group_ids": ["real-group", "other-group"]},
)
assert resp.status_code == 200, resp.text
assert resp.json()["settings"]["access_group_ids"] == ["real-group", "other-group"]
assert mock_proxy_config["save_call_count"]() == 1
assert litellm.default_team_params["access_group_ids"] == ["real-group", "other-group"]
def test_update_default_team_settings_with_empty_access_groups_skips_lookup(
mock_proxy_config: dict[str, object],
mock_auth: None,
mock_access_group_lookup: Callable[[frozenset[str]], AsyncMock],
) -> None:
find_many: Final = mock_access_group_lookup(frozenset())
resp: Final = client.patch(
"/update/default_team_settings",
json={"access_group_ids": []},
)
assert resp.status_code == 200, resp.text
find_many.assert_not_awaited()
assert mock_proxy_config["save_call_count"]() == 1
def test_update_mcp_semantic_filter_settings_requires_proxy_admin(monkeypatch):
"""Non-admin callers must not mutate global MCP semantic filter settings."""
from litellm.proxy._types import UserAPIKeyAuth

View file

@ -89,6 +89,32 @@ vi.mock("./common_components/OrganizationDropdown", () => ({
),
}));
vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({
useAccessGroups: () => ({
data: [
{ access_group_id: "ag-1", access_group_name: "Central Models" },
{ access_group_id: "ag-2", access_group_name: "Research" },
],
isLoading: false,
isError: false,
}),
}));
vi.mock("./common_components/AccessGroupSelector", () => ({
default: ({ value = [], onChange }: { value?: string[]; onChange?: (v: string[]) => void }) => (
<select
data-testid="access-group-selector"
multiple
value={value}
onChange={(e) => onChange?.(Array.from(e.target.selectedOptions, (option) => option.value))}
aria-label="Access Groups"
>
<option value="ag-1">Central Models</option>
<option value="ag-2">Research</option>
</select>
),
}));
vi.mock("./ModelSelect/ModelSelect", () => {
const ModelSelect = ({ value, onChange }: { value: string[]; onChange: (value: string[]) => void }) => (
<select
@ -134,6 +160,7 @@ describe("TeamSSOSettings", () => {
rpm_limit: 100,
models: ["gpt-4"],
team_member_permissions: ["/key/generate", "/key/update"],
access_group_ids: ["ag-1"],
},
};
@ -247,6 +274,47 @@ describe("TeamSSOSettings", () => {
});
});
it("should display default access groups by name in view mode", async () => {
mockGetDefaultTeamSettings.mockResolvedValue({
values: { ...mockSettingsResponse.values, access_group_ids: ["ag-1", "ag-missing"] },
});
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
expect(await screen.findByText("Central Models")).toBeInTheDocument();
expect(screen.getByText("ag-missing")).toBeInTheDocument();
});
it("should say the models default only applies to SSO auto-created teams", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
expect(await screen.findByText(/auto-created from SSO groups/i)).toBeInTheDocument();
});
it("should send the newly selected access group alongside the existing one when saving", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
mockUpdateDefaultTeamSettings.mockResolvedValue({
settings: { ...mockSettingsResponse.values, access_group_ids: ["ag-1", "ag-2"] },
});
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await userEvent.click(await screen.findByRole("button", { name: /Edit Settings/i }));
await userEvent.selectOptions(screen.getByTestId("access-group-selector"), "ag-2");
await userEvent.click(screen.getByRole("button", { name: /Save Changes/i }));
await waitFor(() => {
expect(mockUpdateDefaultTeamSettings).toHaveBeenCalledWith(
"test-token",
expect.objectContaining({ access_group_ids: ["ag-1", "ag-2"] }),
);
});
expect(await screen.findByText("Research")).toBeInTheDocument();
});
it("should display permissions as tags in view mode", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);

View file

@ -2,6 +2,7 @@ import { Edit, Save } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAccessGroups } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -26,6 +27,7 @@ import BudgetDurationDropdown, { getBudgetDurationLabel } from "./common_compone
import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key";
import { toast } from "@/lib/toast";
import { ModelSelect } from "./ModelSelect/ModelSelect";
import AccessGroupSelector from "./common_components/AccessGroupSelector";
import OrganizationDropdown from "./common_components/OrganizationDropdown";
interface TeamSSOSettingsProps {
@ -102,6 +104,7 @@ interface SettingsValues {
models: string[];
team_member_permissions: string[];
organization_id: string | null;
access_group_ids: string[];
}
const DEFAULT_VALUES: SettingsValues = {
@ -112,6 +115,7 @@ const DEFAULT_VALUES: SettingsValues = {
models: [],
team_member_permissions: [],
organization_id: null,
access_group_ids: [],
};
const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
@ -124,6 +128,9 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
const [saving, setSaving] = useState<boolean>(false);
const [fetchError, setFetchError] = useState<boolean>(false);
const { data: organizations, isLoading: isOrganizationsLoading } = useOrganizations();
const { data: accessGroups } = useAccessGroups();
const getAccessGroupLabel = (accessGroupId: string): string =>
accessGroups?.find((group) => group.access_group_id === accessGroupId)?.access_group_name ?? accessGroupId;
useEffect(() => {
const fetchSettings = async () => {
@ -348,6 +355,22 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
}
/>
<SettingRow
label={t("teams.ssoSettings.fields.accessGroups.label")}
description={t("teams.ssoSettings.fields.accessGroups.description")}
isEditing={isEditing}
viewContent={renderTags(values.access_group_ids, getAccessGroupLabel)}
editContent={
<div className="*:w-full">
<AccessGroupSelector
value={editedValues.access_group_ids || []}
onChange={(v) => update("access_group_ids", v)}
placeholder={t("teams.ssoSettings.fields.accessGroups.placeholder")}
/>
</div>
}
/>
<SettingRow
label={t("teams.ssoSettings.fields.models.label")}
description={t("teams.ssoSettings.fields.models.description")}

View file

@ -16755,9 +16755,14 @@
"description": "Teams created without an explicit organization are assigned to this organization.",
"placeholder": "Select an organization"
},
"accessGroups": {
"label": "Access Groups",
"description": "Access groups attached to teams created without explicit access groups. Use this to manage model access for all new teams in one place.",
"placeholder": "Select access groups"
},
"models": {
"label": "Models",
"description": "Default list of models that new teams can access."
"description": "Default list of models for teams auto-created from SSO groups. Teams created from the UI or /team/new are not affected."
},
"permissions": {
"label": "Team Member Permissions",

View file

@ -32655,6 +32655,11 @@ export interface components {
* `models` is the exception: it only applies to teams automatically created by LiteLLM via SSO Groups.
*/
DefaultTeamSSOParams: {
/**
* Access Group Ids
* @description Default access groups attached to new teams created without explicit access_group_ids
*/
access_group_ids?: string[] | null;
/**
* Budget Duration
* @description Default budget duration for new teams, when not explicitly provided (e.g. '24h', '7d', '30d')