fix(proxy): let team and org admins reach /project/new and /project/update, and show Projects in their nav

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
jesus 2026-09-08 15:19:17 +00:00
parent 1af7a403c6
commit 21e3857845
4 changed files with 93 additions and 3 deletions

View file

@ -849,9 +849,12 @@ class LiteLLMRoutes(enum.Enum):
"/prompt/list",
"/prompt/info",
"/vector_store/info",
# Project read routes - endpoint scopes results to caller's teams (non-admin)
# Project routes - reads scope results to caller's teams; /new and
# /update require proxy admin or admin of the project's team in the endpoint
"/project/list",
"/project/info",
"/project/new",
"/project/update",
# Endpoint enforces proxy-admin vs team-admin model access itself.
"/health/test_connection",
# Invitation routes - org/team admins checked in endpoint via _user_has_admin_privileges

View file

@ -3638,3 +3638,41 @@ def test_agent_registry_route_gate_open_to_non_admin_roles(user_role, method, ro
valid_token=valid_token,
request_data={},
)
@pytest.mark.parametrize("route", ["/project/new", "/project/update"])
def test_project_write_routes_reach_endpoint_for_internal_user(route):
"""A team admin is an internal_user at the route gate. /project/new and
/project/update must pass it so the endpoint can apply its own proxy-admin
or team-admin check instead of the gate 403ing every non-proxy-admin."""
valid_token = UserAPIKeyAuth(user_id="test_user", user_role=LitellmUserRoles.INTERNAL_USER.value)
request = MagicMock(spec=Request)
request.method = "POST"
request.query_params = {}
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=LiteLLM_UserTable(user_id="test_user", user_role=LitellmUserRoles.INTERNAL_USER.value),
_user_role=LitellmUserRoles.INTERNAL_USER.value,
route=route,
request=request,
valid_token=valid_token,
request_data={"team_id": "team-1"},
)
def test_project_delete_route_stays_proxy_admin_only():
valid_token = UserAPIKeyAuth(user_id="test_user", user_role=LitellmUserRoles.INTERNAL_USER.value)
request = MagicMock(spec=Request)
request.method = "DELETE"
request.query_params = {}
with pytest.raises(Exception, match="Only proxy admin can be used"):
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=LiteLLM_UserTable(user_id="test_user", user_role=LitellmUserRoles.INTERNAL_USER.value),
_user_role=LitellmUserRoles.INTERNAL_USER.value,
route="/project/delete",
request=request,
valid_token=valid_token,
request_data={},
)

View file

@ -3,6 +3,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../tests/test-utils";
import Sidebar, { menuGroups, getBreadcrumb } from "./leftnav";
const teamAdminState = vi.hoisted(() => ({ isTeamAdmin: false }));
vi.mock("../utils/roles", async (importOriginal) => {
const actual = await importOriginal<typeof import("../utils/roles")>();
return {
@ -13,7 +15,7 @@ vi.mock("../utils/roles", async (importOriginal) => {
rolesWithWriteAccess: ["admin", "internal"],
rolesAllowedToViewWriteScopedPages: ["admin", "internal", "admin_viewer"],
isAdminRole: (role: string) => role === "admin" || role === "admin_viewer",
isUserTeamAdminForAnyTeam: () => false,
isUserTeamAdminForAnyTeam: () => teamAdminState.isTeamAdmin,
};
});
@ -586,6 +588,50 @@ describe("Sidebar (leftnav)", () => {
expect(container.querySelector('a[href*="projects"]')).toBeNull();
});
describe("Projects visibility for delegated admins", () => {
const internalAuth = {
userId: "internal-user-id",
accessToken: "test-access-token",
userRole: "internal",
isViewOnly: false,
token: "test-token",
userEmail: "internal@example.com",
premiumUser: false,
disabledPersonalKeyCreation: false,
showSSOBanner: false,
};
afterEach(() => {
mockUseAuthorized.mockReset();
mockUseOrganizations.mockReset();
teamAdminState.isTeamAdmin = false;
});
it("hides Projects from a plain internal user", () => {
mockUseAuthorized.mockReturnValue(internalAuth);
const { container } = renderWithProviders(<Sidebar {...defaultProps} enableProjectsUI />);
expect(container.querySelector('a[href*="projects"]')).toBeNull();
});
it("shows Projects to an internal user who administers a team", () => {
mockUseAuthorized.mockReturnValue(internalAuth);
teamAdminState.isTeamAdmin = true;
const { container } = renderWithProviders(<Sidebar {...defaultProps} enableProjectsUI />);
expect(container.querySelector('a[href*="projects"]')).toHaveTextContent("Projects");
});
it("shows Projects to an internal user who administers an organization", () => {
mockUseAuthorized.mockReturnValue(internalAuth);
mockUseOrganizations.mockReturnValue({
data: [{ organization_id: "org-1", members: [{ user_id: "internal-user-id", user_role: "org_admin" }] }],
isLoading: false,
error: null,
});
const { container } = renderWithProviders(<Sidebar {...defaultProps} enableProjectsUI />);
expect(container.querySelector('a[href*="projects"]')).toHaveTextContent("Projects");
});
});
it("keeps a readable collapsed-rail tooltip for items whose label carries a badge", () => {
const { container } = renderWithProviders(<Sidebar {...defaultProps} enableProjectsUI collapsed />);

View file

@ -475,7 +475,10 @@ const Sidebar_: React.FC<SidebarProps> = ({
if (!isAdmin && enabledPagesInternalUsers != null) return enabledPagesInternalUsers.includes(item.page);
return true;
}
if (item.key === "projects" && !enableProjectsUI) return false;
if (item.key === "projects") {
if (!enableProjectsUI) return false;
return isAdmin || isOrgAdmin || isTeamAdmin;
}
if (
!isAdmin &&
item.key === "agents" &&