From 619d67d05c9bd77a1c9c522384353d6f4caafc1c Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Tue, 15 Sep 2026 23:13:26 +0000
Subject: [PATCH 1/6] fix(ui): show Projects nav to team and org admins
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../src/components/leftnav.test.tsx | 121 +++++++++++++++++-
.../src/components/leftnav.tsx | 8 +-
2 files changed, 124 insertions(+), 5 deletions(-)
diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx
index 6eb0218c41d..555aa03fbe0 100644
--- a/ui/litellm-dashboard/src/components/leftnav.test.tsx
+++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx
@@ -13,7 +13,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: actual.isUserTeamAdminForAnyTeam,
};
});
@@ -23,7 +23,7 @@ vi.mock("next/navigation", () => ({
usePathname: () => navState.pathname,
}));
-const { mockUseAuthorized, mockUseOrganizations } = vi.hoisted(() => {
+const { mockUseAuthorized, mockUseOrganizations, mockUseTeams } = vi.hoisted(() => {
const mockUseAuthorized = vi.fn(() => ({
userId: "test-user-id",
accessToken: "test-access-token",
@@ -42,7 +42,13 @@ const { mockUseAuthorized, mockUseOrganizations } = vi.hoisted(() => {
error: null,
}));
- return { mockUseAuthorized, mockUseOrganizations };
+ const mockUseTeams = vi.fn(() => ({
+ data: [] as Array<{ members_with_roles: Array<{ user_id: string; role: string }> }>,
+ isLoading: false,
+ error: null,
+ }));
+
+ return { mockUseAuthorized, mockUseOrganizations, mockUseTeams };
});
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
@@ -54,7 +60,7 @@ vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
}));
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
- useTeams: () => ({ data: [], isLoading: false, error: null }),
+ useTeams: mockUseTeams,
}));
vi.mock("@/app/(dashboard)/hooks/uiConfig/useUIConfig", () => {
@@ -110,6 +116,12 @@ describe("Sidebar (leftnav)", () => {
afterEach(() => {
mockUseAuthorized.mockReset();
mockUseOrganizations.mockReset();
+ mockUseTeams.mockReset();
+ mockUseTeams.mockReturnValue({
+ data: [],
+ isLoading: false,
+ error: null,
+ });
mockUseThemeImpl = unbrandedTheme;
navState.pathname = "/ui/api-keys";
});
@@ -514,6 +526,107 @@ describe("Sidebar (leftnav)", () => {
expect(screen.getByText("Organizations")).toBeInTheDocument();
});
+ it("shows Projects to an internal user who administers a team", () => {
+ mockUseAuthorized.mockReturnValue({
+ userId: "team-admin-user-id",
+ accessToken: "test-access-token",
+ userRole: "internal",
+ isViewOnly: false,
+ token: "test-token",
+ userEmail: "teamadmin@example.com",
+ premiumUser: false,
+ disabledPersonalKeyCreation: false,
+ showSSOBanner: false,
+ });
+ mockUseTeams.mockReturnValue({
+ data: [
+ {
+ members_with_roles: [{ user_id: "team-admin-user-id", role: "admin" }],
+ },
+ ],
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithProviders();
+
+ expect(screen.getByRole("link", { name: /Projects/ })).toBeInTheDocument();
+ });
+
+ it("hides Projects when the feature flag is disabled for a team admin", () => {
+ mockUseAuthorized.mockReturnValue({
+ userId: "team-admin-user-id",
+ accessToken: "test-access-token",
+ userRole: "internal",
+ isViewOnly: false,
+ token: "test-token",
+ userEmail: "teamadmin@example.com",
+ premiumUser: false,
+ disabledPersonalKeyCreation: false,
+ showSSOBanner: false,
+ });
+ mockUseTeams.mockReturnValue({
+ data: [
+ {
+ members_with_roles: [{ user_id: "team-admin-user-id", role: "admin" }],
+ },
+ ],
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithProviders();
+
+ expect(screen.queryByRole("link", { name: /Projects/ })).not.toBeInTheDocument();
+ });
+
+ it("hides Projects from an internal user who is not a team admin", () => {
+ mockUseAuthorized.mockReturnValue({
+ userId: "team-member-user-id",
+ accessToken: "test-access-token",
+ userRole: "internal",
+ isViewOnly: false,
+ token: "test-token",
+ userEmail: "teamuser@example.com",
+ premiumUser: false,
+ disabledPersonalKeyCreation: false,
+ showSSOBanner: false,
+ });
+
+ renderWithProviders();
+
+ expect(screen.queryByRole("link", { name: /Projects/ })).not.toBeInTheDocument();
+ });
+
+ it("applies the internal-user page allowlist to Projects for team admins", () => {
+ mockUseAuthorized.mockReturnValue({
+ userId: "team-admin-user-id",
+ accessToken: "test-access-token",
+ userRole: "internal",
+ isViewOnly: false,
+ token: "test-token",
+ userEmail: "teamadmin@example.com",
+ premiumUser: false,
+ disabledPersonalKeyCreation: false,
+ showSSOBanner: false,
+ });
+ mockUseTeams.mockReturnValue({
+ data: [
+ {
+ members_with_roles: [{ user_id: "team-admin-user-id", role: "admin" }],
+ },
+ ],
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.queryByRole("link", { name: /Projects/ })).not.toBeInTheDocument();
+ });
+
it("marks the nav item for the current route active", () => {
navState.pathname = "/ui/logs";
renderWithProviders();
diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx
index 9d772f45153..e0b43c9aad9 100644
--- a/ui/litellm-dashboard/src/components/leftnav.tsx
+++ b/ui/litellm-dashboard/src/components/leftnav.tsx
@@ -475,7 +475,13 @@ const Sidebar_: React.FC = ({
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;
+ const hasRoleAccess = isAdmin || isOrgAdmin || isTeamAdmin;
+ if (!hasRoleAccess) return false;
+ if (!isAdmin && enabledPagesInternalUsers != null) return enabledPagesInternalUsers.includes(item.page);
+ return true;
+ }
if (
!isAdmin &&
item.key === "agents" &&
From f35df471a8d5bfaa7c8bb7ffadbfc38f163947ee Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Tue, 15 Sep 2026 23:19:00 +0000
Subject: [PATCH 2/6] fix(ui): satisfy frontend lint budget
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../src/components/leftnav.test.tsx | 72 ++++++++-----------
1 file changed, 28 insertions(+), 44 deletions(-)
diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx
index 555aa03fbe0..be3c5fe878e 100644
--- a/ui/litellm-dashboard/src/components/leftnav.test.tsx
+++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx
@@ -108,6 +108,30 @@ const placementsOf = (page: string): string[] =>
),
]);
+const teamAdminAuthorization = {
+ userId: "team-admin-user-id",
+ accessToken: "test-access-token",
+ userRole: "internal",
+ isViewOnly: false,
+ token: "test-token",
+ userEmail: "teamadmin@example.com",
+ premiumUser: false,
+ disabledPersonalKeyCreation: false,
+ showSSOBanner: false,
+};
+
+const teamMemberAuthorization = {
+ userId: "team-member-user-id",
+ accessToken: "test-access-token",
+ userRole: "internal",
+ isViewOnly: false,
+ token: "test-token",
+ userEmail: "teamuser@example.com",
+ premiumUser: false,
+ disabledPersonalKeyCreation: false,
+ showSSOBanner: false,
+};
+
describe("Sidebar (leftnav)", () => {
const defaultProps = {
collapsed: false,
@@ -527,17 +551,7 @@ describe("Sidebar (leftnav)", () => {
});
it("shows Projects to an internal user who administers a team", () => {
- mockUseAuthorized.mockReturnValue({
- userId: "team-admin-user-id",
- accessToken: "test-access-token",
- userRole: "internal",
- isViewOnly: false,
- token: "test-token",
- userEmail: "teamadmin@example.com",
- premiumUser: false,
- disabledPersonalKeyCreation: false,
- showSSOBanner: false,
- });
+ mockUseAuthorized.mockReturnValue(teamAdminAuthorization);
mockUseTeams.mockReturnValue({
data: [
{
@@ -554,17 +568,7 @@ describe("Sidebar (leftnav)", () => {
});
it("hides Projects when the feature flag is disabled for a team admin", () => {
- mockUseAuthorized.mockReturnValue({
- userId: "team-admin-user-id",
- accessToken: "test-access-token",
- userRole: "internal",
- isViewOnly: false,
- token: "test-token",
- userEmail: "teamadmin@example.com",
- premiumUser: false,
- disabledPersonalKeyCreation: false,
- showSSOBanner: false,
- });
+ mockUseAuthorized.mockReturnValue(teamAdminAuthorization);
mockUseTeams.mockReturnValue({
data: [
{
@@ -581,17 +585,7 @@ describe("Sidebar (leftnav)", () => {
});
it("hides Projects from an internal user who is not a team admin", () => {
- mockUseAuthorized.mockReturnValue({
- userId: "team-member-user-id",
- accessToken: "test-access-token",
- userRole: "internal",
- isViewOnly: false,
- token: "test-token",
- userEmail: "teamuser@example.com",
- premiumUser: false,
- disabledPersonalKeyCreation: false,
- showSSOBanner: false,
- });
+ mockUseAuthorized.mockReturnValue(teamMemberAuthorization);
renderWithProviders();
@@ -599,17 +593,7 @@ describe("Sidebar (leftnav)", () => {
});
it("applies the internal-user page allowlist to Projects for team admins", () => {
- mockUseAuthorized.mockReturnValue({
- userId: "team-admin-user-id",
- accessToken: "test-access-token",
- userRole: "internal",
- isViewOnly: false,
- token: "test-token",
- userEmail: "teamadmin@example.com",
- premiumUser: false,
- disabledPersonalKeyCreation: false,
- showSSOBanner: false,
- });
+ mockUseAuthorized.mockReturnValue(teamAdminAuthorization);
mockUseTeams.mockReturnValue({
data: [
{
From 821cbe9c85734d486478c671280e0ee89d10e13c Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Tue, 15 Sep 2026 23:24:45 +0000
Subject: [PATCH 3/6] style(ui): format leftnav regression tests
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
ui/litellm-dashboard/src/components/leftnav.test.tsx | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx
index be3c5fe878e..a292f3b1af0 100644
--- a/ui/litellm-dashboard/src/components/leftnav.test.tsx
+++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx
@@ -604,9 +604,7 @@ describe("Sidebar (leftnav)", () => {
error: null,
});
- renderWithProviders(
- ,
- );
+ renderWithProviders();
expect(screen.queryByRole("link", { name: /Projects/ })).not.toBeInTheDocument();
});
From 0b4fd60aee8297493c2f7a14ce99ba1134cd3774 Mon Sep 17 00:00:00 2001
From: ryan-crabbe-berri
Date: Fri, 2 Oct 2026 16:49:19 -0700
Subject: [PATCH 4/6] fix(projects): let org admins see projects of every team
in their orgs
/project/list and /project/info only knew proxy admins and team members, so an
org admin saw projects only for teams they had personally joined. Both now use
the same team access check as /team/info.
---
.../management_endpoints/project_endpoints.py | 59 +++--
litellm/proxy/management/teams/access.py | 6 +
litellm/proxy/management/users/service.py | 13 +-
.../test_project_endpoints_prisma.py | 213 ++++++++++++++++++
4 files changed, 266 insertions(+), 25 deletions(-)
diff --git a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py
index d134c39c91b..366fe9e58fd 100644
--- a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py
+++ b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py
@@ -22,7 +22,9 @@ from litellm._uuid import uuid
from litellm.proxy._types import *
from litellm.proxy.auth.auth_checks import delete_cached_project_object
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
-from litellm.proxy.management.teams.access import is_team_admin
+from litellm.proxy.management.teams.access import TEAM_OR_ORG_ADMIN, TeamAccess, is_team_admin, is_team_member
+from litellm.proxy.management.teams.dependencies import get_team_access
+from litellm.proxy.management.users.service import org_admin_org_ids
from litellm.proxy.management_endpoints.common_utils import _set_object_metadata_field
from litellm.proxy.management_endpoints.team_admin_field_permissions import team_admin_may_manage_projects
from litellm.proxy.management_helpers.utils import (
@@ -118,6 +120,35 @@ async def _check_user_permission_for_project(
return is_team_admin(user_api_key_dict, team) or user_api_key_dict.user_id in (team.admins or [])
+async def _can_view_team_projects(
+ user_api_key_dict: UserAPIKeyAuth,
+ team_id: str | None,
+ prisma_client: PrismaClient,
+ team_access: TeamAccess,
+) -> bool:
+ if user_api_key_has_admin_view(user_api_key_dict):
+ return True
+ if not team_id or not user_api_key_dict.user_id:
+ return False
+ team_row: Final = await _team_table(prisma_client).find_unique(where={"team_id": team_id})
+ if team_row is None:
+ return False
+ team: Final = LiteLLM_TeamTable.model_validate(team_row.model_dump())
+ return is_team_member(user_api_key_dict, team) or await team_access.allows(
+ user_api_key_dict, team, TEAM_OR_ORG_ADMIN
+ )
+
+
+def _visible_projects_where(team_ids: list[str], admin_org_ids: frozenset[str]) -> dict[str, object]:
+ member_scope: Final[dict[str, object]] = {"team_id": {"in": team_ids}}
+ if not admin_org_ids:
+ return member_scope
+ org_scope: Final[dict[str, object]] = {
+ "litellm_team_table": {"is": {"organization_id": {"in": sorted(admin_org_ids)}}}
+ }
+ return {"OR": [member_scope, org_scope]}
+
+
async def _validate_team_exists(
team_id: str,
prisma_client: PrismaClient,
@@ -973,6 +1004,7 @@ async def delete_project(
async def project_info(
project_id: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
+ team_access: TeamAccess = Depends(get_team_access),
):
"""
Get information about a specific project
@@ -1009,21 +1041,7 @@ async def project_info(
param="project_id",
)
- # Check if user has access to this project (admin or team member)
- is_admin = user_api_key_has_admin_view(user_api_key_dict)
- is_team_member = False
-
- if project.team_id and user_api_key_dict.user_id:
- team = await _team_table(prisma_client).find_unique(where={"team_id": project.team_id})
- if team:
- caller_user_id = user_api_key_dict.user_id
- for m in team.members_with_roles or []:
- m_user_id = m.get("user_id") if isinstance(m, dict) else getattr(m, "user_id", None)
- if m_user_id == caller_user_id:
- is_team_member = True
- break
-
- if not (is_admin or is_team_member):
+ if not await _can_view_team_projects(user_api_key_dict, project.team_id, prisma_client, team_access):
raise HTTPException(
status_code=403,
detail={"error": "You don't have access to this project"},
@@ -1072,16 +1090,13 @@ async def list_projects(
include={"litellm_budget_table": True, "object_permission": True}
)
else:
- # Look up the user's team memberships via the reverse-index on
- # LiteLLM_UserTable.teams (maintained by team_member_add alongside
- # members_with_roles). This avoids a full scan of all team rows.
user_record: Final = await _user_table(prisma_client).find_unique(
where={"user_id": user_api_key_dict.user_id},
+ include={"organization_memberships": True},
)
- user_team_ids: list[str] = user_record.teams if user_record is not None and user_record.teams else []
-
+ user: Final = None if user_record is None else LiteLLM_UserTable.model_validate(user_record.model_dump())
projects = await _project_table(prisma_client).find_many(
- where={"team_id": {"in": user_team_ids}},
+ where=_visible_projects_where(user.teams if user is not None else [], org_admin_org_ids(user)),
include={"litellm_budget_table": True, "object_permission": True},
)
diff --git a/litellm/proxy/management/teams/access.py b/litellm/proxy/management/teams/access.py
index 77af588c636..869ee01a302 100644
--- a/litellm/proxy/management/teams/access.py
+++ b/litellm/proxy/management/teams/access.py
@@ -44,6 +44,12 @@ class TeamAccess:
return await self.org_roles.is_org_admin(caller.user_id, team.organization_id)
+def is_team_member(user_api_key_dict: UserAPIKeyAuth, team_obj: LiteLLM_TeamTable) -> bool:
+ return user_api_key_dict.user_id is not None and any(
+ member.user_id == user_api_key_dict.user_id for member in team_obj.members_with_roles
+ )
+
+
def is_team_admin(user_api_key_dict: UserAPIKeyAuth, team_obj: LiteLLM_TeamTable) -> bool:
return any(
member.user_id is not None and member.user_id == user_api_key_dict.user_id and member.role == "admin"
diff --git a/litellm/proxy/management/users/service.py b/litellm/proxy/management/users/service.py
index 5bf19c0c885..8a9c30ddbeb 100644
--- a/litellm/proxy/management/users/service.py
+++ b/litellm/proxy/management/users/service.py
@@ -10,13 +10,20 @@ if TYPE_CHECKING:
from litellm.proxy.utils import PrismaClient, ProxyLogging
-def holds_org_admin(user: LiteLLM_UserTable | None, organization_id: str) -> bool:
- return user is not None and any(
- membership.organization_id == organization_id and membership.user_role == LitellmUserRoles.ORG_ADMIN.value
+def org_admin_org_ids(user: LiteLLM_UserTable | None) -> frozenset[str]:
+ if user is None:
+ return frozenset()
+ return frozenset(
+ membership.organization_id
for membership in user.organization_memberships or []
+ if membership.user_role == LitellmUserRoles.ORG_ADMIN.value
)
+def holds_org_admin(user: LiteLLM_UserTable | None, organization_id: str) -> bool:
+ return organization_id in org_admin_org_ids(user)
+
+
@dataclass(frozen=True, slots=True)
class PrismaOrgRoles:
prisma_client: PrismaClient | None
diff --git a/tests/unit/enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py b/tests/unit/enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py
index 226755e7b8e..9c61a9fb551 100644
--- a/tests/unit/enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py
+++ b/tests/unit/enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py
@@ -1,5 +1,9 @@
import os
import traceback
+from collections.abc import Mapping
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from typing import Final
from litellm._uuid import uuid
from unittest import mock
@@ -37,8 +41,14 @@ from litellm.proxy._types import (
DeleteProjectRequest,
NewTeamRequest,
UserAPIKeyAuth,
+ LiteLLM_OrganizationMembershipTable,
+ LiteLLM_TeamTable,
+ LiteLLM_UserTable,
+ Member,
ProxyException,
)
+from litellm.proxy.management.teams.access import TeamAccess
+from litellm.proxy.management.users.service import PrismaOrgRoles
proxy_logging_obj = ProxyLogging(user_api_key_cache=DualCache())
@@ -1380,3 +1390,206 @@ async def test_new_project_flag_on_access_group_model_returns_400(monkeypatch):
assert "prod-models" in str(exc_info.value)
assert "expand to multiple models at request time" in str(exc_info.value)
+
+
+_ACCESS_NOW: Final = datetime.now(timezone.utc)
+
+
+@dataclass(frozen=True, slots=True)
+class _ProjectRow:
+ project_id: str
+ team_id: str | None
+
+
+def _membership(user_id: str, org_id: str, role: LitellmUserRoles) -> LiteLLM_OrganizationMembershipTable:
+ return LiteLLM_OrganizationMembershipTable(
+ user_id=user_id, organization_id=org_id, user_role=role.value, created_at=_ACCESS_NOW, updated_at=_ACCESS_NOW
+ )
+
+
+def _user(user_id: str, teams: tuple[str, ...] = (), admin_of: tuple[str, ...] = ()) -> LiteLLM_UserTable:
+ return LiteLLM_UserTable(
+ user_id=user_id,
+ teams=list(teams),
+ organization_memberships=[_membership(user_id, "org-a", LitellmUserRoles.INTERNAL_USER)]
+ + [_membership(user_id, org_id, LitellmUserRoles.ORG_ADMIN) for org_id in admin_of],
+ )
+
+
+_TEAMS: Final = {
+ team.team_id: team
+ for team in (
+ LiteLLM_TeamTable(
+ team_id="team-a1",
+ organization_id="org-a",
+ members_with_roles=[Member(user_id="member", role="user"), Member(user_id="team-admin", role="admin")],
+ ),
+ LiteLLM_TeamTable(team_id="team-a2", organization_id="org-a"),
+ LiteLLM_TeamTable(
+ team_id="team-b1",
+ organization_id="org-b",
+ members_with_roles=[Member(user_id="org-admin-a-member-b1", role="user")],
+ ),
+ LiteLLM_TeamTable(team_id="team-orgless"),
+ )
+}
+_PROJECTS: Final = (
+ _ProjectRow("p-a1", "team-a1"),
+ _ProjectRow("p-a2", "team-a2"),
+ _ProjectRow("p-b1", "team-b1"),
+ _ProjectRow("p-orgless", "team-orgless"),
+ _ProjectRow("p-teamless", None),
+)
+_USERS: Final = {
+ user.user_id: user
+ for user in (
+ _user("member", teams=("team-a1",)),
+ _user("team-admin", teams=("team-a1",)),
+ _user("org-admin-a", admin_of=("org-a",)),
+ _user("org-admin-b", admin_of=("org-b",)),
+ _user("org-admin-a-member-b1", teams=("team-b1",), admin_of=("org-a",)),
+ )
+}
+
+
+def _row_matches(row: object, where: Mapping[str, object]) -> bool:
+ return all(_condition_holds(row, key, condition) for key, condition in where.items())
+
+
+def _condition_holds(row: object, key: str, condition) -> bool:
+ if key == "OR":
+ return any(_row_matches(row, branch) for branch in condition)
+ if key == "litellm_team_table":
+ team = _TEAMS.get(getattr(row, "team_id"))
+ return team is not None and _row_matches(team, condition["is"])
+ value = getattr(row, key)
+ return value in condition["in"] if isinstance(condition, dict) else value == condition
+
+
+class _FakeTeamTable:
+ async def find_unique(self, where: Mapping[str, str], include: object = None) -> LiteLLM_TeamTable | None:
+ return _TEAMS.get(where["team_id"])
+
+
+class _FakeProjectTable:
+ async def find_unique(self, where: Mapping[str, str], include: object = None) -> _ProjectRow | None:
+ return next((p for p in _PROJECTS if p.project_id == where["project_id"]), None)
+
+ async def find_many(self, where: Mapping[str, object] | None = None, include: object = None) -> list[_ProjectRow]:
+ return [p for p in _PROJECTS if where is None or _row_matches(p, where)]
+
+
+class _FakeUserTable:
+ async def find_unique(
+ self, where: Mapping[str, str], include: Mapping[str, bool] | None = None
+ ) -> LiteLLM_UserTable | None:
+ user = _USERS.get(where["user_id"])
+ if user is None or (include or {}).get("organization_memberships"):
+ return user
+ return user.model_copy(update={"organization_memberships": None})
+
+
+@pytest.fixture
+def project_access_db(monkeypatch):
+ db = mock.MagicMock(
+ litellm_teamtable=_FakeTeamTable(), litellm_projecttable=_FakeProjectTable(), litellm_usertable=_FakeUserTable()
+ )
+ monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", mock.MagicMock(db=db))
+
+
+async def _team_access_over_cached_users() -> TeamAccess:
+ cache = UserApiKeyCache()
+ for user in _USERS.values():
+ await cache.async_set_cache(key=user.user_id, value=user)
+ return TeamAccess(org_roles=PrismaOrgRoles(None, cache, proxy_logging_obj))
+
+
+def _caller(user_id: str, role: LitellmUserRoles = LitellmUserRoles.INTERNAL_USER) -> UserAPIKeyAuth:
+ return UserAPIKeyAuth(user_role=role, api_key="sk-caller", user_id=user_id)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("user_id", "expected"),
+ [
+ ("member", {"p-a1"}),
+ ("team-admin", {"p-a1"}),
+ ("org-admin-a", {"p-a1", "p-a2"}),
+ ("org-admin-b", {"p-b1"}),
+ ("org-admin-a-member-b1", {"p-a1", "p-a2", "p-b1"}),
+ ("unknown-user", set()),
+ ],
+)
+async def test_list_projects_scopes_to_teams_the_caller_can_view(project_access_db, user_id, expected):
+ from litellm_enterprise.proxy.management_endpoints.project_endpoints import list_projects
+
+ projects = await list_projects(user_api_key_dict=_caller(user_id))
+
+ assert {p.project_id for p in projects} == expected
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("role", [LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY])
+async def test_list_projects_admin_view_sees_every_project(project_access_db, role):
+ from litellm_enterprise.proxy.management_endpoints.project_endpoints import list_projects
+
+ projects = await list_projects(user_api_key_dict=_caller("someone", role))
+
+ assert {p.project_id for p in projects} == {p.project_id for p in _PROJECTS}
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("user_id", "project_id"),
+ [
+ ("member", "p-a1"),
+ ("team-admin", "p-a1"),
+ ("org-admin-a", "p-a1"),
+ ("org-admin-a", "p-a2"),
+ ("org-admin-a-member-b1", "p-b1"),
+ ],
+)
+async def test_project_info_allows_team_members_and_org_admins_of_the_team_org(project_access_db, user_id, project_id):
+ project = await project_info(
+ project_id=project_id,
+ user_api_key_dict=_caller(user_id),
+ team_access=await _team_access_over_cached_users(),
+ )
+
+ assert project.project_id == project_id
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("user_id", "project_id"),
+ [
+ ("member", "p-a2"),
+ ("team-admin", "p-b1"),
+ ("org-admin-b", "p-a2"),
+ ("org-admin-a", "p-b1"),
+ ("org-admin-a", "p-orgless"),
+ ("org-admin-a", "p-teamless"),
+ ],
+)
+async def test_project_info_denies_callers_who_cannot_view_the_team(project_access_db, user_id, project_id):
+ with pytest.raises(ProxyException) as exc_info:
+ await project_info(
+ project_id=project_id,
+ user_api_key_dict=_caller(user_id),
+ team_access=await _team_access_over_cached_users(),
+ )
+
+ assert str(exc_info.value.code) == "403"
+ assert "You don't have access to this project" in exc_info.value.message
+
+
+@pytest.mark.asyncio
+async def test_project_info_missing_project_is_404_even_for_org_admins(project_access_db):
+ with pytest.raises(ProxyException) as exc_info:
+ await project_info(
+ project_id="p-missing",
+ user_api_key_dict=_caller("org-admin-a"),
+ team_access=await _team_access_over_cached_users(),
+ )
+
+ assert str(exc_info.value.code) == "404"
From ce2f7a60d1ebe2d835925f4e729371e0562f1b7c Mon Sep 17 00:00:00 2001
From: ryan-crabbe-berri
Date: Fri, 2 Oct 2026 16:49:19 -0700
Subject: [PATCH 5/6] fix(ui): one shared Projects access rule for nav, data
and actions
The Projects query skipped global org_admin users, the page-visibility picker
could never offer Projects, and New/Edit showed to users the backend would
reject. Nav and queries now share one rule, Projects is selectable in the
allowlist, New/Edit follow team_admin_editable_team_fields, and the project
modal only lists teams the user administers.
---
.../hooks/projects/projectAccess.ts | 46 ++++++++++++++++++
.../hooks/projects/useProjectDetails.test.ts | 14 +++++-
.../hooks/projects/useProjectDetails.ts | 4 +-
.../hooks/projects/useProjects.test.ts | 4 +-
.../(dashboard)/hooks/projects/useProjects.ts | 6 +--
.../_components/ProjectDetailsPage.test.tsx | 46 ++++++++++++++++++
.../_components/ProjectDetailsPage.tsx | 17 +++++--
.../ProjectModals/ProjectBaseForm.test.tsx | 32 +++++++++++++
.../ProjectModals/ProjectBaseForm.tsx | 3 +-
.../_components/ProjectsPage.test.tsx | 47 +++++++++++++++++++
.../projects/_components/ProjectsPage.tsx | 15 ++++--
.../src/components/leftnav.test.tsx | 17 +++++--
.../src/components/leftnav.tsx | 17 ++++---
.../src/components/page_utils.test.ts | 7 +++
.../src/components/page_utils.ts | 10 ++--
15 files changed, 248 insertions(+), 37 deletions(-)
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/projectAccess.ts
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/projectAccess.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/projectAccess.ts
new file mode 100644
index 00000000000..5cc144c94d3
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/projectAccess.ts
@@ -0,0 +1,46 @@
+import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
+import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
+import { parseTeamAdminEditableFields } from "@/components/team/teamAdminEditAccess";
+import { all_admin_roles, internalUserRoles, isAdminRole, isProxyAdminRole } from "@/utils/roles";
+
+const TEAM_ADMIN_PROJECTS_PERMISSION = "projects";
+
+export const projectReaderRoles: readonly string[] = [...all_admin_roles, "Org Admin", ...internalUserRoles];
+
+export const canReadProjects = (userRole: string | null): boolean => projectReaderRoles.includes(userRole ?? "");
+
+export interface ProjectsPageViewer {
+ readonly userRole: string;
+ readonly isOrgAdmin: boolean;
+ readonly isTeamAdmin: boolean;
+}
+
+export const canViewProjectsPage = ({ userRole, isOrgAdmin, isTeamAdmin }: ProjectsPageViewer): boolean =>
+ canReadProjects(userRole) && (isAdminRole(userRole) || isOrgAdmin || isTeamAdmin);
+
+export interface ProjectManager {
+ readonly userRole: string;
+ readonly isViewOnly: boolean;
+ readonly isTeamAdmin: boolean;
+ readonly teamAdminEditableFields: readonly string[];
+}
+
+export const canManageProjects = ({
+ userRole,
+ isViewOnly,
+ isTeamAdmin,
+ teamAdminEditableFields,
+}: ProjectManager): boolean =>
+ !isViewOnly &&
+ (isProxyAdminRole(userRole) || (isTeamAdmin && teamAdminEditableFields.includes(TEAM_ADMIN_PROJECTS_PERMISSION)));
+
+export const useCanManageProjects = (isTeamAdmin: boolean): boolean => {
+ const { userRole, isViewOnly } = useAuthorized();
+ const { data: uiSettings } = useUISettings();
+ return canManageProjects({
+ userRole,
+ isViewOnly,
+ isTeamAdmin,
+ teamAdminEditableFields: parseTeamAdminEditableFields(uiSettings?.values),
+ });
+};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjectDetails.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjectDetails.test.ts
index 426abfe9bb6..cdaba106586 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjectDetails.test.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjectDetails.test.ts
@@ -103,8 +103,18 @@ describe("useProjectDetails", () => {
expect(global.fetch).not.toHaveBeenCalled();
});
- it("should not fetch when userRole is not an admin role", () => {
- mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Internal User" });
+ it.each(["Internal User", "Org Admin"])("should fetch when userRole is %s", async (userRole) => {
+ mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole });
+ (global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProject });
+ const { result } = renderHook(() => useProjectDetails("proj-1"), {
+ wrapper: makeWrapper(queryClient),
+ });
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(global.fetch).toHaveBeenCalled();
+ });
+
+ it("should not fetch when userRole cannot read projects", () => {
+ mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "regular_user" });
const { result } = renderHook(() => useProjectDetails("proj-1"), {
wrapper: makeWrapper(queryClient),
});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjectDetails.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjectDetails.ts
index 037baa18692..637e56d2dfe 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjectDetails.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjectDetails.ts
@@ -1,7 +1,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking";
-import { all_admin_roles } from "@/utils/roles";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
+import { canReadProjects } from "./projectAccess";
import { ProjectResponse, projectKeys } from "./useProjects";
// ── Fetch function ───────────────────────────────────────────────────────────
@@ -37,7 +37,7 @@ export const useProjectDetails = (projectId?: string) => {
return useQuery({
queryKey: projectKeys.detail(projectId!),
queryFn: async () => fetchProjectDetails(accessToken!, projectId!),
- enabled: Boolean(accessToken && projectId) && all_admin_roles.includes(userRole || ""),
+ enabled: Boolean(accessToken && projectId) && canReadProjects(userRole),
// Seed from the list cache when available
initialData: () => {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjects.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjects.test.ts
index 39d1b28303d..a0238ea2121 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjects.test.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjects.test.ts
@@ -115,8 +115,8 @@ describe("useProjects", () => {
expect(global.fetch).not.toHaveBeenCalled();
});
- it("should fetch when userRole is an internal user role", async () => {
- mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Internal User" });
+ it.each(["Internal User", "Org Admin"])("should fetch when userRole is %s", async (userRole) => {
+ mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole });
(global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProjects });
const { result } = renderHook(() => useProjects(), { wrapper: makeWrapper(queryClient) });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjects.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjects.ts
index c240dbb0170..7327e934680 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjects.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjects.ts
@@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
-import { all_admin_roles, internalUserRoles } from "@/utils/roles";
+import { canReadProjects } from "./projectAccess";
// ── Types ────────────────────────────────────────────────────────────────────
@@ -42,8 +42,6 @@ export interface ProjectResponse {
export const projectKeys = createQueryKeys("projects");
-const projectReaderRoles = [...all_admin_roles, ...internalUserRoles];
-
// ── Fetch function ───────────────────────────────────────────────────────────
const fetchProjects = async (accessToken: string): Promise => {
@@ -76,6 +74,6 @@ export const useProjects = () => {
return useQuery({
queryKey: projectKeys.list({}),
queryFn: async () => fetchProjects(accessToken!),
- enabled: Boolean(accessToken) && projectReaderRoles.includes(userRole!),
+ enabled: Boolean(accessToken) && canReadProjects(userRole),
});
};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx
index d22ac0d8742..5d84488d739 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx
@@ -14,6 +14,16 @@ vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useTeam: (id?: string) => mockUseTeam(id),
}));
+const mockUseAuthorized = vi.fn();
+vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
+ default: () => mockUseAuthorized(),
+}));
+
+const mockUseUISettings = vi.fn();
+vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
+ useUISettings: () => mockUseUISettings(),
+}));
+
vi.mock("./ProjectModals/EditProjectModal", () => ({
EditProjectModal: ({ isOpen }: { isOpen: boolean }) => (isOpen ? : null),
}));
@@ -63,6 +73,8 @@ describe("ProjectDetail", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseTeam.mockReturnValue({ data: undefined, isLoading: false });
+ mockUseAuthorized.mockReturnValue({ userId: "admin-user", userRole: "Admin", isViewOnly: false });
+ mockUseUISettings.mockReturnValue({ data: { values: {} } });
});
describe("when loading", () => {
@@ -171,6 +183,40 @@ describe("ProjectDetail", () => {
expect(screen.getByTestId("edit-modal")).toBeInTheDocument();
});
+ it.each([
+ { who: "a proxy admin without the setting", role: "Admin", teamRole: "user", fields: [], visible: true },
+ {
+ who: "the project team's admin without the setting",
+ role: "Internal User",
+ teamRole: "admin",
+ fields: [],
+ visible: false,
+ },
+ {
+ who: "the project team's admin when the setting grants projects",
+ role: "Internal User",
+ teamRole: "admin",
+ fields: ["projects"],
+ visible: true,
+ },
+ {
+ who: "a plain member of the project team when the setting grants projects",
+ role: "Internal User",
+ teamRole: "user",
+ fields: ["projects"],
+ visible: false,
+ },
+ ])("should gate 'Edit Project' for $who", ({ role, teamRole, fields, visible }) => {
+ mockUseAuthorized.mockReturnValue({ userId: "caller", userRole: role, isViewOnly: false });
+ mockUseUISettings.mockReturnValue({ data: { values: { team_admin_editable_team_fields: fields } } });
+ mockUseTeam.mockReturnValue({
+ data: { team_id: "team-1", members_with_roles: [{ user_id: "caller", role: teamRole }] },
+ isLoading: false,
+ });
+ renderWithProviders();
+ expect(screen.queryByRole("button", { name: /edit project/i }) !== null).toBe(visible);
+ });
+
it("should show 'No team assigned' when the project has no team", () => {
mockUseProjectDetails.mockReturnValue({
data: { ...mockProject, team_id: null },
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx
index 2585b67c81b..b9d8ef94cb4 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx
@@ -1,5 +1,8 @@
+import { useCanManageProjects } from "@/app/(dashboard)/hooks/projects/projectAccess";
import { useProjectDetails } from "@/app/(dashboard)/hooks/projects/useProjectDetails";
import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
+import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
+import { isUserTeamAdminForSingleTeam } from "@/utils/roles";
import { BarChart } from "@/components/shared/charts";
import { ArrowLeftIcon, DollarSignIcon, EditIcon, UsersIcon } from "lucide-react";
import { useMemo, useState } from "react";
@@ -24,6 +27,10 @@ const utilisationTone = (percent: number) => (percent >= 90 ? "over" : percent >
export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
const { data: project, isLoading } = useProjectDetails(projectId);
const { data: teamInfo } = useTeam(project?.team_id ?? undefined);
+ const { userId } = useAuthorized();
+ const canEditProject = useCanManageProjects(
+ isUserTeamAdminForSingleTeam(teamInfo?.members_with_roles ?? null, userId ?? ""),
+ );
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const spend = project?.spend ?? 0;
@@ -87,10 +94,12 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
-
+ {canEditProject && (
+
+ )}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.test.tsx
index 85201e2acd2..70530973c1d 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.test.tsx
@@ -11,6 +11,11 @@ vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useTeams: () => mockUseTeams(),
}));
+const mockUseAuthorized = vi.fn();
+vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
+ default: () => mockUseAuthorized(),
+}));
+
vi.mock("@/components/organisms/create_key_button", () => ({
fetchTeamModels: vi.fn().mockResolvedValue([]),
}));
@@ -32,6 +37,7 @@ function FormWrapper() {
describe("ProjectBaseForm", () => {
beforeEach(() => {
mockUseTeams.mockReturnValue({ data: [], isLoading: false });
+ mockUseAuthorized.mockReturnValue({ accessToken: "token", userId: "admin-user", userRole: "Admin" });
});
it("should render", () => {
@@ -83,6 +89,32 @@ describe("ProjectBaseForm", () => {
expect(screen.getByText("Sales")).toBeInTheDocument();
});
+ it("should offer a team admin only the teams they administer", async () => {
+ const user = userEvent.setup();
+ mockUseAuthorized.mockReturnValue({ accessToken: "token", userId: "team-admin", userRole: "Internal User" });
+ mockUseTeams.mockReturnValue({
+ data: [
+ {
+ team_id: "team-1",
+ team_alias: "Engineering",
+ models: [],
+ members_with_roles: [{ user_id: "team-admin", role: "admin" }],
+ },
+ {
+ team_id: "team-2",
+ team_alias: "Sales",
+ models: [],
+ members_with_roles: [{ user_id: "team-admin", role: "user" }],
+ },
+ ],
+ isLoading: false,
+ });
+ renderWithProviders();
+ await user.click(screen.getByLabelText("Team"));
+ expect(await screen.findByText("Engineering")).toBeInTheDocument();
+ expect(screen.queryByText("Sales")).not.toBeInTheDocument();
+ });
+
it("should show the Max Budget field", () => {
renderWithProviders();
expect(screen.getByPlaceholderText("0.00")).toBeInTheDocument();
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx
index b5603ba184c..c5d30e8490e 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx
@@ -24,6 +24,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
+import { teamsUserCanAssign } from "@/utils/roles";
const toOptionalNumber = (raw: string): number | undefined => {
if (raw.trim() === "") return undefined;
@@ -100,7 +101,7 @@ export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: Pr
form.setValue("models", []);
};
- const teamOptions = (teams ?? []).map((team) => ({
+ const teamOptions = (teamsUserCanAssign(teams ?? null, userRole, userId) ?? []).map((team) => ({
value: team.team_id,
label: team.team_alias || team.team_id,
sublabel: team.team_id,
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.test.tsx
index 309da01b295..f23589d1993 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.test.tsx
@@ -15,6 +15,16 @@ vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useTeams: () => mockUseTeams(),
}));
+const mockUseAuthorized = vi.fn();
+vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
+ default: () => mockUseAuthorized(),
+}));
+
+const mockUseUISettings = vi.fn();
+vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
+ useUISettings: () => mockUseUISettings(),
+}));
+
// Stub modals and the detail page to keep tests focused on the list page
vi.mock("./ProjectModals/CreateProjectModal", () => ({
CreateProjectModal: ({ isOpen }: { isOpen: boolean }) => (isOpen ? : null),
@@ -76,6 +86,8 @@ describe("ProjectsPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseTeams.mockReturnValue({ data: [], isLoading: false });
+ mockUseAuthorized.mockReturnValue({ userId: "admin-user", userRole: "Admin", isViewOnly: false });
+ mockUseUISettings.mockReturnValue({ data: { values: {} } });
});
it("should render the Projects heading", () => {
@@ -92,6 +104,41 @@ describe("ProjectsPage", () => {
expect(screen.getByRole("button", { name: /create project/i })).toBeInTheDocument();
});
+ it.each([
+ { who: "a proxy admin without the setting", role: "Admin", isViewOnly: false, fields: [], visible: true },
+ { who: "a proxy admin viewer", role: "Admin", isViewOnly: true, fields: ["projects"], visible: false },
+ { who: "a team admin without the setting", role: "Internal User", isViewOnly: false, fields: [], visible: false },
+ {
+ who: "a team admin when the setting grants projects",
+ role: "Internal User",
+ isViewOnly: false,
+ fields: ["projects"],
+ visible: true,
+ },
+ ])("should gate 'Create Project' for $who", ({ role, isViewOnly, fields, visible }) => {
+ mockUseAuthorized.mockReturnValue({ userId: "team-admin", userRole: role, isViewOnly });
+ mockUseUISettings.mockReturnValue({ data: { values: { team_admin_editable_team_fields: fields } } });
+ mockUseTeams.mockReturnValue({
+ data: [{ team_id: "team-1", members_with_roles: [{ user_id: "team-admin", role: "admin" }] }],
+ isLoading: false,
+ });
+ mockUseProjects.mockReturnValue({ data: [], isLoading: false });
+ renderWithProviders();
+ expect(screen.queryByRole("button", { name: /create project/i }) !== null).toBe(visible);
+ });
+
+ it("should hide 'Create Project' from a team member who administers no team even when the setting grants projects", () => {
+ mockUseAuthorized.mockReturnValue({ userId: "member", userRole: "Internal User", isViewOnly: false });
+ mockUseUISettings.mockReturnValue({ data: { values: { team_admin_editable_team_fields: ["projects"] } } });
+ mockUseTeams.mockReturnValue({
+ data: [{ team_id: "team-1", members_with_roles: [{ user_id: "member", role: "user" }] }],
+ isLoading: false,
+ });
+ mockUseProjects.mockReturnValue({ data: [], isLoading: false });
+ renderWithProviders();
+ expect(screen.queryByRole("button", { name: /create project/i })).not.toBeInTheDocument();
+ });
+
it("should render the projects table", () => {
mockUseProjects.mockReturnValue({ data: mockProjects, isLoading: false });
renderWithProviders();
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.tsx
index 2d3c1acf75e..206d10f4ca2 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.tsx
@@ -1,5 +1,8 @@
+import { useCanManageProjects } from "@/app/(dashboard)/hooks/projects/projectAccess";
import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
+import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
+import { isUserTeamAdminForAnyTeam } from "@/utils/roles";
import { Folder, Plus, SearchIcon, X } from "lucide-react";
import { parseAsString, useQueryState } from "nuqs";
import { useMemo, useState } from "react";
@@ -14,6 +17,8 @@ import { useClearProjectKeysTableState, useProjectsTableState } from "./useProje
export function ProjectsPage() {
const { data: projects, isLoading } = useProjects();
const { data: teams, isLoading: isTeamsLoading } = useTeams();
+ const { userId } = useAuthorized();
+ const canCreateProject = useCanManageProjects(isUserTeamAdminForAnyTeam(teams ?? null, userId ?? ""));
const [selectedProjectId, setSelectedProjectId] = useQueryState(
"project",
@@ -62,10 +67,12 @@ export function ProjectsPage() {
title="Projects"
subtitle="Manage projects within your teams"
primaryAction={
-
+ canCreateProject && (
+
+ )
}
/>
diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx
index 85bf4f05dfc..cdb74c73a80 100644
--- a/ui/litellm-dashboard/src/components/leftnav.test.tsx
+++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx
@@ -625,7 +625,10 @@ describe("Sidebar (leftnav)", () => {
expect(screen.queryByRole("link", { name: /Projects/ })).not.toBeInTheDocument();
});
- it("applies the internal-user page allowlist to Projects for team admins", () => {
+ it.each([
+ { allowlist: ["teams"], visible: false },
+ { allowlist: ["teams", "projects"], visible: true },
+ ])("applies the internal-user page allowlist $allowlist to Projects for team admins", ({ allowlist, visible }) => {
mockUseAuthorized.mockReturnValue(teamAdminAuthorization);
mockUseTeams.mockReturnValue({
data: [
@@ -637,9 +640,17 @@ describe("Sidebar (leftnav)", () => {
error: null,
});
- renderWithProviders();
+ renderWithProviders();
- expect(screen.queryByRole("link", { name: /Projects/ })).not.toBeInTheDocument();
+ expect(screen.queryByRole("link", { name: /Projects/ }) !== null).toBe(visible);
+ });
+
+ it("shows Projects to a user whose global role is org admin", () => {
+ mockUseAuthorized.mockReturnValue({ ...teamMemberAuthorization, userRole: "Org Admin" });
+
+ renderWithProviders();
+
+ expect(screen.getByRole("link", { name: /Projects/ })).toHaveAttribute("href", "/ui/projects");
});
it("marks the nav item for the current route active", () => {
diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx
index 0a25300a376..ee6dd850072 100644
--- a/ui/litellm-dashboard/src/components/leftnav.tsx
+++ b/ui/litellm-dashboard/src/components/leftnav.tsx
@@ -1,3 +1,4 @@
+import { canViewProjectsPage, projectReaderRoles } from "@/app/(dashboard)/hooks/projects/projectAccess";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin";
@@ -275,7 +276,7 @@ const menuGroups: MenuGroup[] = [
),
icon: ,
- roles: all_admin_roles,
+ roles: [...projectReaderRoles],
},
{ key: "users", page: "users", label: "Internal Users", icon: , roles: all_admin_roles },
{
@@ -439,7 +440,7 @@ const prettify = (key: string): string =>
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
-const labelText = (item: MenuItem): string => (typeof item.label === "string" ? item.label : prettify(item.key));
+export const labelText = (item: MenuItem): string => (typeof item.label === "string" ? item.label : prettify(item.key));
// Breadcrumb ("Section" / "Page") for the top bar, derived from the same nav config.
export const getBreadcrumb = (pathname: string): { section: string | null; title: string } => {
@@ -512,13 +513,11 @@ const Sidebar_: React.FC = ({
if (!isAdmin && enabledPagesInternalUsers != null) return enabledPagesInternalUsers.includes(item.page);
return true;
}
- if (item.key === "projects") {
- if (!enableProjectsUI) return false;
- const hasRoleAccess = isAdmin || isOrgAdmin || isTeamAdmin;
- if (!hasRoleAccess) return false;
- if (!isAdmin && enabledPagesInternalUsers != null) return enabledPagesInternalUsers.includes(item.page);
- return true;
- }
+ if (
+ item.key === "projects" &&
+ !(enableProjectsUI && canViewProjectsPage({ userRole, isOrgAdmin, isTeamAdmin }))
+ )
+ return false;
if (
!isAdmin &&
item.key === "agents" &&
diff --git a/ui/litellm-dashboard/src/components/page_utils.test.ts b/ui/litellm-dashboard/src/components/page_utils.test.ts
index a38c56d8681..6f55326a5be 100644
--- a/ui/litellm-dashboard/src/components/page_utils.test.ts
+++ b/ui/litellm-dashboard/src/components/page_utils.test.ts
@@ -195,6 +195,13 @@ describe("Page Utils - LeftNav Sync", () => {
expect(pageKeys.length, "All page keys should be unique (no duplicates)").toBe(uniquePageKeys.size);
});
+ it("offers Projects in the internal-user page picker so team admins can be granted it", () => {
+ expect(getAvailablePages().find((page) => page.page === "projects")).toMatchObject({
+ label: "Projects",
+ group: "ACCESS CONTROL",
+ });
+ });
+
it("should match the structure expected by PageVisibilitySettings component", () => {
const availablePages = getAvailablePages();
diff --git a/ui/litellm-dashboard/src/components/page_utils.ts b/ui/litellm-dashboard/src/components/page_utils.ts
index c682b2db089..2339ffc4006 100644
--- a/ui/litellm-dashboard/src/components/page_utils.ts
+++ b/ui/litellm-dashboard/src/components/page_utils.ts
@@ -2,7 +2,7 @@
* Utility functions for working with navigation pages
*/
-import { menuGroups } from "./leftnav";
+import { labelText, menuGroups } from "./leftnav";
import { pageDescriptions, PageMetadata } from "./page_metadata";
import { internalUserRoles } from "@/utils/roles";
@@ -43,10 +43,9 @@ export const getAvailablePages = (): PageMetadata[] => {
item.page !== "settings" &&
isPageAccessibleToInternalUsers(item.roles)
) {
- const label = typeof item.label === "string" ? item.label : item.key;
pages.push({
page: item.page,
- label: label,
+ label: labelText(item),
group: group.groupLabel,
description: pageDescriptions[item.page] || "No description available",
});
@@ -54,14 +53,13 @@ export const getAvailablePages = (): PageMetadata[] => {
// Add children items (also skip those internal users cannot access)
if (item.children) {
- const parentLabel = typeof item.label === "string" ? item.label : item.key;
+ const parentLabel = labelText(item);
item.children.forEach((child) => {
// Include if internal users can access
if (isPageAccessibleToInternalUsers(child.roles)) {
- const childLabel = typeof child.label === "string" ? child.label : child.key;
pages.push({
page: child.page,
- label: childLabel,
+ label: labelText(child),
group: `${group.groupLabel} > ${parentLabel}`,
description: pageDescriptions[child.page] || "No description available",
});
From 3a5028a0ed6870e5290b0d0c32620cfa9a30b360 Mon Sep 17 00:00:00 2001
From: ryan-crabbe-berri
Date: Fri, 2 Oct 2026 17:08:00 -0700
Subject: [PATCH 6/6] fix(projects): record IN-list bounds for project
visibility filters
Both filters are bounded by one caller's team memberships and admin orgs. Also
cover a project whose team was deleted.
---
.../proxy/management_endpoints/project_endpoints.py | 2 ++
tests/code_coverage_tests/unbounded_in_baseline.txt | 1 -
.../proxy/management_endpoints/test_project_endpoints_prisma.py | 2 ++
3 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py
index 366fe9e58fd..831df7a8294 100644
--- a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py
+++ b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py
@@ -140,10 +140,12 @@ async def _can_view_team_projects(
def _visible_projects_where(team_ids: list[str], admin_org_ids: frozenset[str]) -> dict[str, object]:
+ # bounded-ok: one caller's team memberships
member_scope: Final[dict[str, object]] = {"team_id": {"in": team_ids}}
if not admin_org_ids:
return member_scope
org_scope: Final[dict[str, object]] = {
+ # bounded-ok: orgs one caller administers
"litellm_team_table": {"is": {"organization_id": {"in": sorted(admin_org_ids)}}}
}
return {"OR": [member_scope, org_scope]}
diff --git a/tests/code_coverage_tests/unbounded_in_baseline.txt b/tests/code_coverage_tests/unbounded_in_baseline.txt
index c42a6b0ddf5..a2bf9faed31 100644
--- a/tests/code_coverage_tests/unbounded_in_baseline.txt
+++ b/tests/code_coverage_tests/unbounded_in_baseline.txt
@@ -1,7 +1,6 @@
# Grandfathered findings of check_unbounded_in_lists.py: path::scope::kind::subject::occurrence.
# Fix a site and delete its line; regenerate with `check_unbounded_in_lists.py --update-baseline`.
enterprise/litellm_enterprise/proxy/common_utils/check_responses_cost.py CheckResponsesCost.check_responses_cost prisma id.in `[job.id for job in completed_jobs]` 0
-enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py list_projects prisma team_id.in `user_team_ids` 0
litellm/integrations/shadow_eval_logger.py ShadowEvalLogger._active_jobs prisma job_id.in `[str(record.id) for record in records]` 0
litellm/llms/litellm_proxy/skills/handler.py LiteLLMSkillsHandler.list_skills prisma created_by.in `owner_scopes` 0
litellm/proxy/_experimental/mcp_server/db.py get_mcp_servers prisma server_id.in `server_ids` 0
diff --git a/tests/unit/enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py b/tests/unit/enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py
index 9c61a9fb551..486d3d39415 100644
--- a/tests/unit/enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py
+++ b/tests/unit/enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py
@@ -1439,6 +1439,7 @@ _PROJECTS: Final = (
_ProjectRow("p-b1", "team-b1"),
_ProjectRow("p-orgless", "team-orgless"),
_ProjectRow("p-teamless", None),
+ _ProjectRow("p-deleted-team", "team-deleted"),
)
_USERS: Final = {
user.user_id: user
@@ -1569,6 +1570,7 @@ async def test_project_info_allows_team_members_and_org_admins_of_the_team_org(p
("org-admin-a", "p-b1"),
("org-admin-a", "p-orgless"),
("org-admin-a", "p-teamless"),
+ ("member", "p-deleted-team"),
],
)
async def test_project_info_denies_callers_who_cannot_view_the_team(project_access_db, user_id, project_id):