litellm/ui/litellm-dashboard/src/utils/modelPermissions.ts
Tin Chi Lo 516953b073 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.
2026-07-30 00:18:45 -07:00

80 lines
2.8 KiB
TypeScript

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