mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
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:
parent
1af7a403c6
commit
21e3857845
4 changed files with 93 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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={},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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" &&
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue