feat(ui): let team admins create auto-routers; authorize models by team, not created_by

The Auto-Routers tab was proxy-admin only, while Add Model on the same page already
admits team admins. The asymmetry was not a policy decision; the auto-router create form
simply never mounted a team selector, so a team admin's submit was unscoped and POST
/model/new rejects an unscoped create from any non-proxy-admin. Mounting the shared
TeamDropdown closes it, and the tab now takes the same audience as its sibling.

Fixing that surfaced a second, larger problem. The dashboard decided who may edit or
delete a deployment with `(userRole === "Admin" || created_by === userID) && db_model`,
but `created_by` is written at creation and never read by any backend auth check. The API
authorizes on team-admin membership of model_info.team_id, so the dashboard was wrong in
both directions: it hid controls from team admins the API accepts, and offered them to
former team admins the API rejects. Verified against a live proxy; a model created by the
proxy admin was PATCHed and DELETEd 200 by a team admin who did not create it, while the
same key got 403 on another team's row and on an unscoped row.

Both questions now have one owner in utils/modelPermissions.ts, deliberately shaped as a
mirror of ModelManagementAuthChecks. Creation returns a tagged union rather than a pair of
booleans, so "may not create" and "may create unscoped" cannot be confused, and the five
places that had each invented their own spelling (the models page, the auto-routers tab
and panel, the auto-router form, and both branches of AddModelForm) call it instead.

Row affordances are now per row rather than per tab, because opening the tab to team
admins puts routers they cannot act on in the same list.

Note for reviewers: collapsing AddModelForm onto the shared owner changes behaviour for
org_admin and Admin Viewer who also admin a team. They previously got the optional team
selector, because all_admin_roles counts them as admins, and could submit an unscoped
create that the API always 403s; they now get the required selector.

Also corrects stale copy left by the auto-router move. The exclude_auto_routers API
description named a dashboard page, which went stale inside a single PR; it now describes
the concept so it cannot drift with the UI again.

The eslint-suppressions prune includes one entry for caching/_components/cache_dashboard.tsx,
which this branch does not touch. Its baseline was already stale; the gate measures the whole
tree, so it could not be left behind.
This commit is contained in:
Tin Chi Lo 2026-07-30 00:18:45 -07:00
parent fec7f5f246
commit 516953b073
18 changed files with 459 additions and 54 deletions

View file

@ -12087,8 +12087,9 @@ async def model_info_v2(
False,
description=(
"Omit auto-router deployments (litellm model prefixed `auto_router/`). "
"They are routing constructs rather than deployments, and are managed on the "
"Router Settings page. Defaults to false, so existing callers are unaffected"
"They select among deployments rather than being deployments themselves, so a "
"caller rendering a deployment list can leave them out. Defaults to false, so "
"existing callers are unaffected"
),
),
):

View file

@ -151,14 +151,11 @@
"no-restricted-imports": {
"count": 1
},
"prefer-const": {
"count": 1
},
"react-hooks/purity": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 2
"count": 1
}
},
"src/app/(dashboard)/caching/_components/cache_health.tsx": {
@ -3320,7 +3317,7 @@
"count": 5
},
"no-restricted-syntax": {
"count": 153
"count": 152
},
"prefer-const": {
"count": 32

View file

@ -45,7 +45,7 @@ const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken
</div>
<p className="mt-1 text-sm text-muted-foreground">
Track and configure the mechanisms that save you money: prompt compression and prompt caching. Auto routers
live on the Router Settings page
live under Models + Endpoints, on the Auto-Routers tab
</p>
</div>

View file

@ -104,6 +104,7 @@ export interface AutoRouterDeployment extends AutoRouterCandidateDeployment {
created_at?: string | null;
updated_at?: string | null;
team_id?: string | null;
created_by?: string | null;
} | null;
}

View file

@ -104,8 +104,8 @@ const AllModelsTab = ({
teamIdForQuery,
sortBy,
sortOrder,
// Auto-routers are routing constructs, not deployments; they are listed and managed on
// the Router Settings page. Excluded server-side so total_count stays honest.
// Auto-routers are routing constructs, not deployments; the sibling Auto-Routers tab
// lists and manages them. Excluded server-side so total_count stays honest.
true,
);
const isLoading = isLoadingModelsInfo || isLoadingModelCostMap;

View file

@ -108,7 +108,15 @@ const mockDeploymentsPage = () => {
};
const renderPanel = (canModify = true) =>
renderWithProviders(<AutoRoutersPanel accessToken="token" userRole="Admin" canModify={canModify} />);
renderWithProviders(
<AutoRoutersPanel
accessToken="token"
userRole="Admin"
userID="u-admin"
teams={null}
createScope={canModify ? "unscoped-ok" : "forbidden"}
/>,
);
describe("AutoRoutersPanel", () => {
beforeEach(() => {

View file

@ -11,6 +11,8 @@ import NotificationsManager from "@/components/molecules/notifications_manager";
import { modelDeleteCall } from "@/components/networking";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { type ModelWriteScope } from "@/utils/modelPermissions";
import { Team } from "@/components/networking";
import { AutoRoutersTable } from "./AutoRoutersTable";
import { AutoRouterRow, toAutoRouterRows } from "./autoRouterRows";
@ -18,11 +20,14 @@ import { AutoRouterRow, toAutoRouterRows } from "./autoRouterRows";
interface AutoRoutersPanelProps {
accessToken: string;
userRole: string;
/** Owned by the page, which knows whether the caller may write. */
canModify: boolean;
userID: string | null;
teams: Team[] | null;
/** Owned by the page, which knows how this caller must scope what they create. */
createScope: ModelWriteScope;
}
export function AutoRoutersPanel({ accessToken, userRole, canModify }: AutoRoutersPanelProps) {
export function AutoRoutersPanel({ accessToken, userRole, userID, teams, createScope }: AutoRoutersPanelProps) {
const canCreate = createScope !== "forbidden";
const { data: deployments, isLoading } = useAutoRouters();
const invalidateAutoRouters = useInvalidateAutoRouters();
// Clicking a router opens the same ?model= drill-in the All Models table uses, so an auto
@ -33,7 +38,10 @@ export function AutoRoutersPanel({ accessToken, userRole, canModify }: AutoRoute
const [deletingRouter, setDeletingRouter] = useState<AutoRouterRow | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const routers = useMemo(() => toAutoRouterRows(deployments ?? []), [deployments]);
const routers = useMemo(
() => toAutoRouterRows(deployments ?? [], { userRole, userID }, teams),
[deployments, userRole, userID, teams],
);
const handleCreated = () => {
setIsCreating(false);
@ -65,7 +73,7 @@ export function AutoRoutersPanel({ accessToken, userRole, canModify }: AutoRoute
so clients keep using a single model name.
</p>
</div>
{canModify && (
{canCreate && (
<Button onClick={() => setIsCreating(true)} className="shrink-0">
<Plus />
Add Auto Router
@ -76,7 +84,7 @@ export function AutoRoutersPanel({ accessToken, userRole, canModify }: AutoRoute
<AutoRoutersTable
routers={routers}
isLoading={isLoading}
canModify={canModify}
canModify={canCreate}
onRouterClick={(row) => openModel(row.id)}
onDeleteClick={setDeletingRouter}
/>
@ -92,7 +100,12 @@ export function AutoRoutersPanel({ accessToken, userRole, canModify }: AutoRoute
using a single model name.
</DialogDescription>
</DialogHeader>
<AddAutoRouterTab handleOk={handleCreated} accessToken={accessToken} userRole={userRole} />
<AddAutoRouterTab
handleOk={handleCreated}
accessToken={accessToken}
userRole={userRole}
createScope={createScope}
/>
</DialogContent>
</Dialog>

View file

@ -3,6 +3,11 @@ import { describe, expect, it } from "vitest";
import { autoRouterStrategy, isComplexityRouter } from "@/components/add_model/auto_router_strategies";
import { toAutoRouterRow, toAutoRouterRows } from "./autoRouterRows";
// Existing cases assert resource classification, so they run as a proxy admin: the actor
// gate is then a pass-through and canEdit/canDelete still reflect the row itself.
const ADMIN = { userRole: "Admin", userID: "u-admin" };
const TEAM_ADMIN = { userRole: "Internal User", userID: "u-team-admin" };
const complexityDeployment = {
model_name: "tri-tier-router",
litellm_params: {
@ -38,7 +43,7 @@ const semanticDeployment = {
describe("autoRouterRows", () => {
it("classifies a complexity router and unions its tier models as targets", () => {
const row = toAutoRouterRow(complexityDeployment, 0);
const row = toAutoRouterRow(complexityDeployment, 0, ADMIN, null);
expect(row.kind).toBe("complexity");
expect(row.typeLabel).toBe("Heuristic");
@ -49,7 +54,7 @@ describe("autoRouterRows", () => {
});
it("parses a semantic router whose config arrives as a JSON string", () => {
const row = toAutoRouterRow(semanticDeployment, 0);
const row = toAutoRouterRow(semanticDeployment, 0, ADMIN, null);
expect(row.kind).toBe("semantic");
expect(row.typeLabel).toBe("Semantic");
@ -70,6 +75,8 @@ describe("autoRouterRows", () => {
},
},
0,
ADMIN,
null,
);
expect(row.targets).toEqual(["gpt-4o-mini", "anthropic-sonnet-4-6"]);
@ -85,6 +92,8 @@ describe("autoRouterRows", () => {
},
},
0,
ADMIN,
null,
);
expect(row.typeLabel).toBe("LLM Classifier");
@ -102,6 +111,8 @@ describe("autoRouterRows", () => {
model_info: { id: "bid-1" },
},
0,
ADMIN,
null,
);
expect(row.kind).toBe("semantic");
@ -109,10 +120,14 @@ describe("autoRouterRows", () => {
});
it("falls back to a stable synthetic id when the deployment has no model_info id", () => {
const rows = toAutoRouterRows([
{ model_name: "a", litellm_params: { model: "auto_router/a" } },
{ model_name: "b", litellm_params: { model: "auto_router/b" } },
]);
const rows = toAutoRouterRows(
[
{ model_name: "a", litellm_params: { model: "auto_router/a" } },
{ model_name: "b", litellm_params: { model: "auto_router/b" } },
],
ADMIN,
null,
);
expect(rows.map((row) => row.id)).toEqual(["a-0", "b-1"]);
});
@ -130,6 +145,8 @@ describe("autoRouterRows", () => {
model_info: { id: "ad-1" },
},
0,
ADMIN,
null,
);
expect(row.kind).toBe("adaptive");
@ -150,6 +167,8 @@ describe("autoRouterRows", () => {
model_info: { id: "q-1" },
},
0,
ADMIN,
null,
);
expect(row.kind).toBe("quality");
@ -170,7 +189,12 @@ describe("autoRouterRows", () => {
// with no delete control. Live-verified: for a config row PATCH /model/{id}/update 404s
// and POST /model/delete 400s.
const rowFor = (model: string, dbModel: boolean) =>
toAutoRouterRow({ model_name: "r", litellm_params: { model }, model_info: { id: "x", db_model: dbModel } }, 0);
toAutoRouterRow(
{ model_name: "r", litellm_params: { model }, model_info: { id: "x", db_model: dbModel } },
0,
ADMIN,
null,
);
it.each([
{ model: "auto_router/complexity_router", db: true, canEdit: true, canDelete: true, reason: null },
@ -189,8 +213,50 @@ describe("autoRouterRows", () => {
});
it("treats a missing db_model as config-defined rather than assuming it is writable", () => {
const row = toAutoRouterRow({ ...complexityDeployment, model_info: { id: "unknown-1" } }, 0);
const row = toAutoRouterRow({ ...complexityDeployment, model_info: { id: "unknown-1" } }, 0, ADMIN, null);
expect(row.canEdit).toBe(false);
expect(row.canDelete).toBe(false);
});
});
describe("autoRouterRows actor gating", () => {
const TEAMS = [
{ team_id: "team-1", members_with_roles: [{ user_id: "u-team-admin", user_email: "t@t", role: "admin" }] },
] as never;
const rowIn = (actor: { userRole: string; userID: string }, teamId: string | null) =>
toAutoRouterRow(
{ ...complexityDeployment, model_info: { id: "cid-1", db_model: true, team_id: teamId } },
0,
actor,
TEAMS,
);
// Opening the tab to team admins puts rows they cannot act on in the same list: other
// teams' routers, and the proxy-level unscoped ones. PATCH and DELETE both 403 those, so
// the affordance has to be per row rather than per tab.
it("hides write affordances on another team's router", () => {
const row = rowIn(TEAM_ADMIN, "other-team");
expect(row.canEdit).toBe(false);
expect(row.canDelete).toBe(false);
});
it("hides them on an unscoped router a proxy admin owns", () => {
const row = rowIn(TEAM_ADMIN, null);
expect(row.canEdit).toBe(false);
expect(row.canDelete).toBe(false);
});
// Authorizing on created_by would fail this: the API lets any admin of the owning team act.
it("keeps them on the team's router regardless of who created it", () => {
const row = rowIn(TEAM_ADMIN, "team-1");
expect(row.canEdit).toBe(true);
expect(row.canDelete).toBe(true);
});
it("lets a proxy admin act on any team's router", () => {
const row = rowIn(ADMIN, "other-team");
expect(row.canEdit).toBe(true);
expect(row.canDelete).toBe(true);
});
});

View file

@ -6,9 +6,14 @@ import {
autoRouterStrategy,
} from "@/components/add_model/auto_router_strategies";
import { normalizeTierModels } from "@/components/add_model/complexity_router_tiers";
import { Team } from "@/components/networking";
import { type ModelActor, canModifyModel } from "@/utils/modelPermissions";
export type { AutoRouterKind };
/** Who is looking at the list; decides which rows offer write affordances. */
export type AutoRouterActor = ModelActor;
export interface AutoRouterRow {
id: string;
name: string;
@ -16,7 +21,11 @@ export interface AutoRouterRow {
typeLabel: string;
/** Edit needs an API-created row AND a strategy the dashboard has a form for. */
canEdit: boolean;
/** Delete only needs an API-created row; removing by id never reads the config. */
/**
* Resource capability ANDed with the caller's standing on this specific row. A team admin
* sees rows they cannot delete (another team's, or one a teammate created), and the API
* would 403 those, so the affordance has to be per row rather than per tab.
*/
canDelete: boolean;
editBlockedReason: EditBlockedReason | null;
targets: string[];
@ -78,19 +87,25 @@ const PRESENTERS: Record<AutoRouterKind, (config: Record<string, unknown>) => Pr
quality: (config) => configManaged("Quality", config),
};
export const toAutoRouterRow = (deployment: AutoRouterDeployment, index: number): AutoRouterRow => {
export const toAutoRouterRow = (
deployment: AutoRouterDeployment,
index: number,
actor: AutoRouterActor,
teams: Team[] | null,
): AutoRouterRow => {
const params = deployment.litellm_params ?? {};
const info = deployment.model_info ?? {};
const name = deployment.model_name ?? "";
const strategy = autoRouterStrategy(params);
const { canEdit, canDelete, editBlockedReason } = autoRouterCapabilities(params, info);
const mayActOnRow = canModifyModel(actor, teams, { teamId: info.team_id, isDbModel: info.db_model === true });
return {
id: info.id ?? `${name}-${index}`,
name,
kind: strategy.kind,
canEdit,
canDelete,
canEdit: canEdit && mayActOnRow,
canDelete: canDelete && mayActOnRow,
editBlockedReason,
createdAt: info.created_at ?? null,
defaultModel: (params[strategy.defaultModelKey] as string | null | undefined) ?? null,
@ -99,5 +114,8 @@ export const toAutoRouterRow = (deployment: AutoRouterDeployment, index: number)
};
};
export const toAutoRouterRows = (deployments: AutoRouterDeployment[]): AutoRouterRow[] =>
deployments.map(toAutoRouterRow);
export const toAutoRouterRows = (
deployments: AutoRouterDeployment[],
actor: AutoRouterActor,
teams: Team[] | null,
): AutoRouterRow[] => deployments.map((deployment, index) => toAutoRouterRow(deployment, index, actor, teams));

View file

@ -7,7 +7,8 @@ import { useQueryClient } from "@tanstack/react-query";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import { all_admin_roles, internalUserRoles, isProxyAdminRole, isUserTeamAdminForAnyTeam } from "@/utils/roles";
import { all_admin_roles, internalUserRoles } from "@/utils/roles";
import { canCreateModels } from "@/utils/modelPermissions";
import BetaBadge from "@/components/BetaBadge";
import CostOptimizationFeedbackBanner from "@/components/molecules/cost_optimization_feedback_banner";
import ModelInfoView from "@/components/model_info_view";
@ -83,24 +84,27 @@ export default function ModelsAndEndpointsPage() {
const [activeKey, setActiveKey] = useState<string>(BASE_TAB_KEY);
const [lastRefreshed, setLastRefreshed] = useState("");
const isProxyAdmin = userRole && isProxyAdminRole(userRole);
const isInternalUser = userRole && internalUserRoles.includes(userRole);
const isUserTeamAdmin = userID && isUserTeamAdminForAnyTeam(teams ?? null, userID);
const addModelDisabledForInternalUsers =
isInternalUser && uiSettings?.values?.disable_model_add_for_internal_users === true;
const shouldHideAddModelTab = !isProxyAdmin && (addModelDisabledForInternalUsers || !isUserTeamAdmin);
const canCreate = canCreateModels(
{ userRole, userID },
{
teams: teams ?? null,
disabledForInternalUsers:
isInternalUser === true && uiSettings?.values?.disable_model_add_for_internal_users === true,
},
);
const isAdmin = all_admin_roles.includes(userRole);
const visibleSlugs = useMemo<Array<"" | ModelTabSlug>>(
() => [
"",
...(shouldHideAddModelTab ? [] : (["add"] as const)),
...(isAdmin ? (["auto-routers"] as const) : []),
...(canCreate ? (["add"] as const) : []),
...(isAdmin || canCreate ? (["auto-routers"] as const) : []),
...(isAdmin
? (["llm-credentials", "pass-through", "health", "retry-settings", "model-group-alias", "price-data"] as const)
: []),
],
[shouldHideAddModelTab, isAdmin],
[canCreate, isAdmin],
);
const allModelsLabel = isAdmin ? "All Models" : "Your Models";

View file

@ -1,23 +1,40 @@
"use client";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { isProxyAdminRole } from "@/utils/roles";
import { internalUserRoles } from "@/utils/roles";
import { modelCreationScope } from "@/utils/modelPermissions";
import { AutoRoutersPanel } from "../components/AutoRouters/AutoRoutersPanel";
/**
* Owns the permission decision for the Auto-Routers tab so the panel stays a renderer.
* Creating or editing an auto router is a POST /model/new or PATCH /model/{id}/update, both
* proxy-admin gated, so viewer roles read the list without write affordances.
* Creating an auto router is a POST /model/new, the same endpoint Add Model posts to, so it
* takes the same audience rule: a proxy admin, or a team admin who scopes it to a team.
* Viewer roles reach the list without write affordances.
*/
export default function AutoRoutersTabPanel() {
const { accessToken, userRole } = useAuthorized();
const { accessToken, userRole, userId: userID } = useAuthorized();
const { data: teams } = useTeams();
const { data: uiSettings } = useUISettings();
const isInternalUser = userRole != null && internalUserRoles.includes(userRole);
const scope = modelCreationScope(
{ userRole, userID },
{
teams: teams ?? null,
disabledForInternalUsers: isInternalUser && uiSettings?.values?.disable_model_add_for_internal_users === true,
},
);
return (
<AutoRoutersPanel
accessToken={accessToken}
userRole={userRole ?? ""}
canModify={isProxyAdminRole(userRole ?? "")}
userID={userID ?? null}
teams={teams ?? null}
createScope={scope}
/>
);
}

View file

@ -2,6 +2,7 @@ import { useProviderFields } from "@/app/(dashboard)/hooks/providers/useProvider
import { useGuardrails } from "@/app/(dashboard)/hooks/guardrails/useGuardrails";
import { useTags } from "@/app/(dashboard)/hooks/tags/useTags";
import { all_admin_roles, isUserTeamAdminForAnyTeam } from "@/utils/roles";
import { modelCreationScope } from "@/utils/modelPermissions";
import { Switch, Text } from "@tremor/react";
import type { FormInstance } from "antd";
import { Select as AntdSelect, Button, Card, Col, Form, Modal, Row, Tooltip, Typography, Alert } from "antd";
@ -101,6 +102,10 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
const isAdmin = all_admin_roles.includes(userRole);
const isTeamAdmin = isUserTeamAdminForAnyTeam(teams, userId);
// Same owner the Auto-Routers tab uses, so the two creation forms cannot disagree about
// who has to name a team. This form is only reachable when creation is allowed at all.
const createScope = modelCreationScope({ userRole, userID: userId }, { teams, disabledForInternalUsers: false });
const requiresTeamScope = createScope === "team-required";
return (
<>
@ -120,7 +125,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
labelAlign="left"
>
<>
{isTeamAdmin && !isAdmin && (
{requiresTeamScope && (
<>
<Form.Item
label="Select Team"
@ -307,7 +312,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
)}
{/* Conditional Team Selection */}
{isTeamOnly && (isAdmin || !isTeamAdmin) && (
{isTeamOnly && !requiresTeamScope && (
<Form.Item
label="Select Team"
name="team_id"

View file

@ -1,8 +1,10 @@
import { renderWithProviders, screen } from "../../../tests/test-utils";
import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";
import AddAutoRouterTab from "./add_auto_router_tab";
import NotificationManager from "../molecules/notifications_manager";
import { handleAddAutoRouterSubmit } from "./handle_add_auto_router_submit";
import { getMissingTiersError } from "./build_complexity_router_config";
vi.mock("../networking", () => ({
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
@ -20,9 +22,36 @@ vi.mock("../molecules/notifications_manager", () => ({
default: { fromBackend: vi.fn() },
}));
// Kept real by default so the "mandatory field" test still sees genuine tier validation; one
// test overrides it to reach the submit path without driving four tier selects.
vi.mock("./build_complexity_router_config", async (importOriginal) => {
const actual = await importOriginal<typeof import("./build_complexity_router_config")>();
return { ...actual, getMissingTiersError: vi.fn(actual.getMissingTiersError) };
});
// A real TeamDropdown fetches teams and renders an antd Select; the wiring under test is
// whether team_id is registered, validated and forwarded, so a plain control stands in.
vi.mock("../common_components/team_dropdown", () => ({
default: ({ value, onChange }: { value?: string; onChange?: (next: string) => void }) => (
<select
data-testid="team-dropdown"
value={value ?? ""}
onChange={(event) => onChange?.(event.target.value)}
aria-label="Select Team"
>
<option value="">none</option>
<option value="team-1">team-1</option>
</select>
),
}));
const Harness = () => <AddAutoRouterTab handleOk={vi.fn()} accessToken="token" userRole="Admin" />;
describe("AddAutoRouterTab", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("flags every mandatory field when Add Auto Router is clicked with nothing filled", async () => {
const user = userEvent.setup();
renderWithProviders(<Harness />);
@ -33,4 +62,53 @@ describe("AddAutoRouterTab", () => {
expect(screen.getAllByText("This tier is required")).toHaveLength(4);
expect(NotificationManager.fromBackend).toHaveBeenCalledWith("Please enter an Auto Router Name");
});
it("offers no team selector to a proxy admin, who may create an unscoped router", () => {
renderWithProviders(<Harness />);
expect(screen.queryByTestId("team-dropdown")).not.toBeInTheDocument();
});
it("requires a team admin to pick a team", async () => {
renderWithProviders(
<AddAutoRouterTab handleOk={vi.fn()} accessToken="token" userRole="Internal User" createScope="team-required" />,
);
expect(screen.getByTestId("team-dropdown")).toBeInTheDocument();
expect(screen.getByText("Select Team")).toBeInTheDocument();
});
// POST /model/new 403s an unscoped create from a non-proxy-admin, so a selected team that
// never reaches the payload is indistinguishable from having no selector at all. The value
// has to survive form.validateFields, which only returns the fields it is asked for.
it("carries the selected team through to the create payload", async () => {
const user = userEvent.setup();
vi.mocked(getMissingTiersError).mockReturnValue(null);
renderWithProviders(
<AddAutoRouterTab handleOk={vi.fn()} accessToken="token" userRole="Internal User" createScope="team-required" />,
);
await user.type(screen.getByPlaceholderText(/smart_router/i), "team-scoped-router");
await user.selectOptions(screen.getByTestId("team-dropdown"), "team-1");
await user.click(screen.getByRole("button", { name: /add auto router/i }));
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0]).toMatchObject({ team_id: "team-1" });
});
it("blocks the submit when a team admin has not picked a team", async () => {
const user = userEvent.setup();
vi.mocked(getMissingTiersError).mockReturnValue(null);
renderWithProviders(
<AddAutoRouterTab handleOk={vi.fn()} accessToken="token" userRole="Internal User" createScope="team-required" />,
);
await user.type(screen.getByPlaceholderText(/smart_router/i), "team-scoped-router");
await user.click(screen.getByRole("button", { name: /add auto router/i }));
expect(await screen.findByText("Please select a team to continue")).toBeInTheDocument();
expect(handleAddAutoRouterSubmit).not.toHaveBeenCalled();
});
});

View file

@ -3,6 +3,8 @@ import { Card, Form, Button, Tooltip, Typography, Select as AntdSelect, Modal }
import { TextInput } from "@tremor/react";
import { modelAvailableCall } from "../networking";
import { all_admin_roles } from "@/utils/roles";
import { type ModelWriteScope } from "@/utils/modelPermissions";
import TeamDropdown from "../common_components/team_dropdown";
import { handleAddAutoRouterSubmit } from "./handle_add_auto_router_submit";
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
import ComplexityRouterConfig, {
@ -26,11 +28,23 @@ interface AddAutoRouterTabProps {
handleOk: () => void;
accessToken: string;
userRole: string;
/**
* How this caller must scope what they create. A team admin has to name a team, because
* POST /model/new rejects an unscoped create from any non-proxy-admin; without the selector
* their submit is a guaranteed 403.
*/
createScope?: ModelWriteScope;
}
const { Title } = Typography;
const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ handleOk, accessToken, userRole }) => {
const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
handleOk,
accessToken,
userRole,
createScope = "unscoped-ok",
}) => {
const requiresTeamScope = createScope === "team-required";
const [form] = Form.useForm();
const [modelAccessGroups, setModelAccessGroups] = useState<string[]>([]);
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
@ -122,7 +136,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ handleOk, accessTok
});
form
.validateFields(["auto_router_name"])
.validateFields(requiresTeamScope ? ["auto_router_name", "team_id"] : ["auto_router_name"])
.then((values) => {
const complexityRouterConfigParams = {
tiers,
@ -209,6 +223,19 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ handleOk, accessTok
<TextInput placeholder="e.g., smart_router, auto_router_1" />
</Form.Item>
{requiresTeamScope && (
<Form.Item
label="Select Team"
name="team_id"
rules={[{ required: true, message: "Please select a team to continue" }]}
tooltip="Select the team this auto router belongs to. Only keys for this team will be able to call it."
labelCol={{ span: 10 }}
labelAlign="left"
>
<TeamDropdown />
</Form.Item>
)}
<div className="w-full mb-4">
<ComplexityRouterConfig
modelInfo={modelInfo}

View file

@ -32,6 +32,8 @@ import {
isAutoRouterDeployment,
isComplexityRouter as isComplexityRouterParams,
} from "./add_model/auto_router_strategies";
import { canModifyModel } from "@/utils/modelPermissions";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import CacheControlSettings from "./add_model/cache_control_settings";
import DeleteResourceModal from "./common_components/DeleteResourceModal";
import EditAutoRouterModal from "./edit_auto_router/edit_auto_router_modal";
@ -153,6 +155,7 @@ export default function ModelInfoView({
const { data: rawModelDataResponse, isLoading: isLoadingModel } = useModelsInfo(1, 50, undefined, modelId);
const { data: modelCostMapData } = useModelCostMap();
const { data: modelHubData } = useModelHub();
const { data: teams } = useTeams();
// Transform the model data
const getProviderFromModel = (model: string) => {
@ -175,8 +178,10 @@ export default function ModelInfoView({
// Keep modelData variable name for backwards compatibility
const modelData = transformedModelData;
const canEditModel =
(userRole === "Admin" || modelData?.model_info?.created_by === userID) && modelData?.model_info?.db_model;
const canEditModel = canModifyModel({ userRole, userID }, teams ?? null, {
teamId: modelData?.model_info?.team_id,
isDbModel: modelData?.model_info?.db_model === true,
});
const isAdmin = userRole === "Admin";
// Editor-aware on purpose: an adaptive or quality router must not offer Edit Auto Router.
const isAutoRouterModel = hasAutoRouterEditor(modelData?.litellm_params);

View file

@ -58089,7 +58089,7 @@ export interface operations {
sortBy?: string | null;
/** @description Sort order. Options: asc, desc */
sortOrder?: string | null;
/** @description Omit auto-router deployments (litellm model prefixed `auto_router/`). They are routing constructs rather than deployments, and are managed on the Router Settings page. Defaults to false, so existing callers are unaffected */
/** @description Omit auto-router deployments (litellm model prefixed `auto_router/`). They select among deployments rather than being deployments themselves, so a caller rendering a deployment list can leave them out. Defaults to false, so existing callers are unaffected */
exclude_auto_routers?: boolean | null;
};
header?: never;

View file

@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { Team } from "@/components/networking";
import { canModifyModel, modelCreationScope } from "./modelPermissions";
const teamWhere = (userId: string, role: string, teamId = "team-1"): Team[] =>
[{ team_id: teamId, members_with_roles: [{ user_id: userId, user_email: "t@test.com", role }] }] as unknown as Team[];
const PROXY_ADMIN = { userRole: "Admin", userID: "u-admin" };
const TEAM_ADMIN = { userRole: "Internal User", userID: "u-team-admin" };
const MEMBER = { userRole: "Internal User", userID: "u-member" };
const noLimits = { disabledForInternalUsers: false };
describe("modelCreationScope", () => {
it("lets a proxy admin create without naming a team", () => {
expect(modelCreationScope(PROXY_ADMIN, { teams: null, ...noLimits })).toBe("unscoped-ok");
});
// Live-verified: POST /model/new from a team admin 403s without model_info.team_id and
// returns 200 with it, so the form must make the team mandatory rather than optional.
it("requires a team admin to name a team", () => {
expect(modelCreationScope(TEAM_ADMIN, { teams: teamWhere("u-team-admin", "admin"), ...noLimits })).toBe(
"team-required",
);
});
it("forbids a plain team member", () => {
expect(modelCreationScope(MEMBER, { teams: teamWhere("u-member", "user"), ...noLimits })).toBe("forbidden");
});
// The admin setting is scoped to internal users and must never lock out a proxy admin.
it("honours the internal-user kill switch without touching proxy admins", () => {
const limits = { teams: teamWhere("u-team-admin", "admin"), disabledForInternalUsers: true };
expect(modelCreationScope(TEAM_ADMIN, limits)).toBe("forbidden");
expect(modelCreationScope(PROXY_ADMIN, limits)).toBe("unscoped-ok");
});
// org_admin and Admin Viewer are in all_admin_roles but are not PROXY_ADMIN to the API, so
// an unscoped create from them 403s. Treating them as admins here is what let a form submit
// a payload the backend always rejected.
it("does not treat an org admin as able to create unscoped", () => {
const orgAdmin = { userRole: "org_admin", userID: "u-org" };
expect(modelCreationScope(orgAdmin, { teams: teamWhere("u-org", "admin"), ...noLimits })).toBe("team-required");
});
});
describe("canModifyModel", () => {
const teamRow = { teamId: "team-1", isDbModel: true };
// config.yaml rows: PATCH /model/{id}/update 404s and POST /model/delete 400s for everyone.
it("refuses a config-defined row even to a proxy admin", () => {
expect(canModifyModel(PROXY_ADMIN, null, { teamId: "team-1", isDbModel: false })).toBe(false);
});
it("lets a proxy admin act on any DB row", () => {
expect(canModifyModel(PROXY_ADMIN, null, teamRow)).toBe(true);
});
// The regression this whole owner exists for. Live-verified: a model created by the proxy
// admin (created_by=default_user_id) was PATCHed and DELETEd 200 by a team admin who did
// not create it. Authorizing on created_by hid controls the API accepts.
it("lets a team admin act on their team's row they did not create", () => {
expect(canModifyModel(TEAM_ADMIN, teamWhere("u-team-admin", "admin"), teamRow)).toBe(true);
});
it("refuses a plain member of the owning team", () => {
expect(canModifyModel(MEMBER, teamWhere("u-member", "user"), teamRow)).toBe(false);
});
it("refuses a team admin of a different team", () => {
expect(canModifyModel(TEAM_ADMIN, teamWhere("u-team-admin", "admin", "other-team"), teamRow)).toBe(false);
});
// Unscoped rows can only have been created by a proxy admin, and only one can edit them.
it("refuses a team admin on an unscoped row", () => {
expect(canModifyModel(TEAM_ADMIN, teamWhere("u-team-admin", "admin"), { teamId: null, isDbModel: true })).toBe(
false,
);
});
it("does not treat two absent identities as a match", () => {
expect(canModifyModel({ userRole: "Internal User", userID: null }, null, teamRow)).toBe(false);
});
});

View file

@ -0,0 +1,80 @@
import { Team } from "@/components/networking";
import { isProxyAdminRole, isUserTeamAdminForAnyTeam, isUserTeamAdminForSingleTeam } from "./roles";
/**
* The dashboard's mirror of ModelManagementAuthChecks in
* litellm/proxy/management_endpoints/model_management_endpoints.py.
*
* Both questions below are answered there by exactly two inputs: the caller's role, and
* whether the caller admins the team named in `model_info.team_id`. `created_by` is written
* at creation and never read by an auth check, so it is deliberately absent here; gating on
* it hid controls from team admins the API accepts, and showed controls to former team admins
* the API rejects.
*/
export interface ModelActor {
userRole: string | null;
userID: string | null;
}
/** How this actor must scope a deployment they create, or that they may not create one. */
export type ModelWriteScope = "forbidden" | "unscoped-ok" | "team-required";
export interface ModelCreationLimits {
teams: Team[] | null;
/** The admin setting that withdraws model creation from internal users. */
disabledForInternalUsers: boolean;
}
const isTeamAdminOf = (teams: Team[] | null, userID: string, teamId: string): boolean => {
const team = teams?.find((candidate) => candidate.team_id === teamId);
return team != null && isUserTeamAdminForSingleTeam(team.members_with_roles, userID);
};
/**
* POST /model/new takes a proxy admin unconditionally, or a team admin whose payload names a
* team; an unscoped create from anyone else is a 403. Returning the requirement rather than a
* pair of booleans keeps "may not create" and "may create unscoped" from being confused.
*/
export const modelCreationScope = (
{ userRole, userID }: ModelActor,
{ teams, disabledForInternalUsers }: ModelCreationLimits,
): ModelWriteScope => {
if (userRole != null && isProxyAdminRole(userRole)) {
return "unscoped-ok";
}
if (disabledForInternalUsers) {
return "forbidden";
}
if (userID != null && isUserTeamAdminForAnyTeam(teams, userID)) {
return "team-required";
}
return "forbidden";
};
export const canCreateModels = (actor: ModelActor, limits: ModelCreationLimits): boolean =>
modelCreationScope(actor, limits) !== "forbidden";
export interface ModelRowOrigin {
teamId: string | null | undefined;
/** False for config.yaml rows, which update and delete both refuse whoever asks. */
isDbModel: boolean;
}
/** May this actor edit or delete this specific deployment? */
export const canModifyModel = (
{ userRole, userID }: ModelActor,
teams: Team[] | null,
{ teamId, isDbModel }: ModelRowOrigin,
): boolean => {
if (!isDbModel) {
return false;
}
if (userRole != null && isProxyAdminRole(userRole)) {
return true;
}
if (userID == null || teamId == null) {
return false;
}
return isTeamAdminOf(teams, userID, teamId);
};