fix(ui): withhold team-scoped model writes from view-only sessions too

The route-level RBAC in litellm/proxy/auth/route_checks.py 403s
/model/new, /model/update, and /model/delete for proxy_admin_viewer on
the session role alone, before ModelManagementAuthChecks' team-admin
carve-out can run. A view-only session therefore gets no model write
affordance, team admin or not.
This commit is contained in:
mateo-berri 2026-08-30 10:01:44 -07:00
parent 74fb398f9b
commit b6bd749c02
2 changed files with 26 additions and 16 deletions

View file

@ -53,11 +53,11 @@ describe("modelCreationScope", () => {
expect(canCreateModels(VIEW_ONLY_ADMIN, { teams: [], ...noLimits })).toBe(false);
});
// A blunt view-only gate would fail this: team-admin membership legitimately grants
// team-scoped creation, whatever the session role says.
it("still requires a team from a view-only admin who admins a team", () => {
// _check_proxy_admin_viewer_access (route_checks.py) 403s /model/new on the session role
// alone, before the team-scoped carve-out in ModelManagementAuthChecks can run.
it("forbids a view-only admin even when they admin a team", () => {
expect(modelCreationScope(VIEW_ONLY_ADMIN, { teams: teamWhere("u-viewer", "admin"), ...noLimits })).toBe(
"team-required",
"forbidden",
);
});
});
@ -105,7 +105,9 @@ describe("canModifyModel", () => {
expect(canModifyModel(VIEW_ONLY_ADMIN, null, teamRow)).toBe(false);
});
it("lets a view-only user who admins the owning team act on its row", () => {
expect(canModifyModel(VIEW_ONLY_ADMIN, teamWhere("u-viewer", "admin"), teamRow)).toBe(true);
// The route RBAC blocks /model/update and /model/delete for the viewer role before the
// team-scoped carve-out runs, so team-admin membership changes nothing here either.
it("refuses a view-only user even when they admin the owning team", () => {
expect(canModifyModel(VIEW_ONLY_ADMIN, teamWhere("u-viewer", "admin"), teamRow)).toBe(false);
});
});

View file

@ -3,14 +3,17 @@ 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.
* The dashboard's mirror of the two server layers that gate model writes: the role-level
* route RBAC (`_check_proxy_admin_viewer_access` in litellm/proxy/auth/route_checks.py),
* which 403s /model/new, /model/update, and /model/delete for every view-only session
* before the endpoint runs, and ModelManagementAuthChecks in
* litellm/proxy/management_endpoints/model_management_endpoints.py behind it.
*
* 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.
* Past that route gate, both questions below are answered 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;
@ -42,13 +45,18 @@ const isTeamAdminOf = (teams: Team[] | null, userID: string, teamId: string): bo
/**
* 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.
* team; an unscoped create from anyone else is a 403. A view-only session is 403d by the
* route RBAC on its role alone, so team-admin membership cannot rescue it. Returning the
* requirement rather than a pair of booleans keeps "may not create" and "may create
* unscoped" from being confused.
*/
export const modelCreationScope = (
actor: ModelActor,
{ teams, disabledForInternalUsers }: ModelCreationLimits,
): ModelWriteScope => {
if (actor.isViewOnly) {
return "forbidden";
}
if (isWritableProxyAdmin(actor)) {
return "unscoped-ok";
}
@ -76,7 +84,7 @@ export const canModifyModel = (
teams: Team[] | null,
{ teamId, isDbModel }: ModelRowOrigin,
): boolean => {
if (!isDbModel) {
if (actor.isViewOnly || !isDbModel) {
return false;
}
if (isWritableProxyAdmin(actor)) {