mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
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.
80 lines
2.8 KiB
TypeScript
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);
|
|
};
|