mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(proxy): let org admins view their organization's usage (#37235)
An internal user who administers an organization saw an empty Organization Usage dashboard and had to be promoted to proxy admin to see any of it. Two independent gates were closed on them. The route layer rejected GET /organization/daily/activity with 401 before the handler ran, since the route belonged to no list a non-proxy-admin can reach, and the handler's own org-admin scoping was therefore dead code. In the dashboard, viewOrganizationUsage was granted by session role alone, and an org admin's session role is internal_user, so the Organization Usage option never rendered and its data fetch stayed disabled. The route now sits in self_managed_routes, where the handler restricts results to organizations the caller is ORG_ADMIN of and 403s on any other org, and viewOrganizationUsage joins the existing per-capability org-admin allowance that already covers viewDeletedTeams. A caller who administers no organization resolves to an empty id list rather than to None, so the organization-alias lookup is scoped by that same list instead of reading the whole table. The Usage page falls back to the global view when org-admin membership is revoked while it is open, so the selector never keeps a value it no longer offers.
This commit is contained in:
parent
1857f5d04b
commit
3fe0201d40
12 changed files with 254 additions and 8 deletions
|
|
@ -811,6 +811,9 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/model/delete",
|
||||
"/user/daily/activity",
|
||||
"/user/daily/activity/aggregated",
|
||||
# Endpoint restricts results to organizations the caller is ORG_ADMIN
|
||||
# of; a caller who administers none gets an empty result set.
|
||||
"/organization/daily/activity",
|
||||
"/user/available_roles", # read-only role metadata; any authenticated user may read
|
||||
"/user/list", # org admins checked in endpoint; non-admins get 403
|
||||
"/model/{model_id}/update",
|
||||
|
|
|
|||
|
|
@ -559,7 +559,7 @@ async def get_organization_daily_activity(
|
|||
|
||||
# Fetch organization aliases for metadata
|
||||
where_condition: Final = _STR_OBJECT_DICT_ADAPTER.validate_python({})
|
||||
if org_ids_list:
|
||||
if org_ids_list is not None:
|
||||
where_condition["organization_id"] = {"in": list(org_ids_list)}
|
||||
org_aliases: Final = await _table(OrganizationRepository(prisma_client)).find_many(where=where_condition)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(
|
||||
|
|
@ -9,7 +10,14 @@ sys.path.insert(
|
|||
import pytest
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from litellm.proxy._types import LiteLLM_UserTable, LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_OrganizationMembershipTable,
|
||||
LiteLLM_UserTable,
|
||||
LiteLLMRoutes,
|
||||
LitellmUserRoles,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.auth.auth_checks_organization import _user_is_org_admin
|
||||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
|
||||
|
||||
|
|
@ -3298,3 +3306,76 @@ def test_user_daily_activity_aggregated_not_covered_by_prefix_match():
|
|||
route="/user/daily/activity/aggregated",
|
||||
allowed_routes=["/user/daily/activity"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user_role",
|
||||
[
|
||||
LitellmUserRoles.INTERNAL_USER.value,
|
||||
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
|
||||
],
|
||||
)
|
||||
def test_organization_daily_activity_reachable_by_non_admin_roles(user_role):
|
||||
"""The Organization Usage dashboard calls /organization/daily/activity, whose
|
||||
handler restricts results to organizations the caller is ORG_ADMIN of (and
|
||||
403s on any other org). That scoping is unreachable unless the route layer
|
||||
lets a non-proxy-admin through first: the route belongs to no info /
|
||||
management / org_admin_only list, so self_managed_routes is the only entry
|
||||
granting it, and dropping it 401s every org admin's Organization Usage view
|
||||
before the handler ever runs.
|
||||
"""
|
||||
user_obj = LiteLLM_UserTable(
|
||||
user_id="test_user",
|
||||
user_email="test@example.com",
|
||||
user_role=user_role,
|
||||
)
|
||||
valid_token = UserAPIKeyAuth(user_id="test_user", user_role=user_role)
|
||||
request = MagicMock(spec=Request)
|
||||
request.query_params = {}
|
||||
|
||||
RouteChecks.non_proxy_admin_allowed_routes_check(
|
||||
user_obj=user_obj,
|
||||
_user_role=user_role,
|
||||
route="/organization/daily/activity",
|
||||
request=request,
|
||||
valid_token=valid_token,
|
||||
request_data={},
|
||||
)
|
||||
|
||||
|
||||
def test_organization_daily_activity_not_granted_by_org_admin_request_data_branch():
|
||||
"""The org-admin branch of the route gate cannot grant this route, so the
|
||||
self_managed_routes entry is load-bearing rather than redundant.
|
||||
|
||||
Query params do reach request_data, so the reason is not body-vs-query: it
|
||||
is the key name. _user_is_org_admin reads ``organization_id`` (singular) and
|
||||
``organizations``, while this endpoint's filter is ``organization_ids``
|
||||
(plural), and the dashboard's first page load sends no organization filter
|
||||
at all. Both shapes are pinned below because renaming the query param would
|
||||
otherwise silently change which gate is doing the work.
|
||||
"""
|
||||
user_obj = LiteLLM_UserTable(
|
||||
user_id="test_user",
|
||||
user_email="test@example.com",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
organization_memberships=[
|
||||
LiteLLM_OrganizationMembershipTable(
|
||||
user_id="test_user",
|
||||
organization_id="org-a",
|
||||
user_role=LitellmUserRoles.ORG_ADMIN.value,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# The dashboard's default page load: no organization filter at all.
|
||||
assert not _user_is_org_admin(request_data={}, user_object=user_obj)
|
||||
# The filtered load, naming an org this user really does administer.
|
||||
assert not _user_is_org_admin(request_data={"organization_ids": "org-a"}, user_object=user_obj)
|
||||
# The key name the helper would have had to see to grant it.
|
||||
assert _user_is_org_admin(request_data={"organization_id": "org-a"}, user_object=user_obj)
|
||||
assert not RouteChecks.check_route_access(
|
||||
route="/organization/daily/activity",
|
||||
allowed_routes=LiteLLMRoutes.org_admin_only_routes.value,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -992,3 +992,51 @@ def test_build_budget_write_data_clears_reset_at_with_null_duration():
|
|||
data = build_budget_write_data({"budget_duration": None}, "admin-1")
|
||||
assert data["budget_duration"] is None
|
||||
assert data["budget_reset_at"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_organization_daily_activity_non_admin_without_org_admin_role_sees_nothing(
|
||||
monkeypatch,
|
||||
):
|
||||
"""A caller who is ORG_ADMIN of no organization must resolve to an EMPTY id
|
||||
list, never to None. None means "no entity filter" downstream, i.e. every
|
||||
organization's spend, so the natural simplification of falling back to None
|
||||
on an empty membership set turns a scoping rule into a proxy-wide leak. The
|
||||
organization-alias lookup must be scoped by that same empty list rather than
|
||||
reading the whole table.
|
||||
"""
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.management_endpoints import organization_endpoints
|
||||
from litellm.proxy.management_endpoints.organization_endpoints import (
|
||||
get_organization_daily_activity,
|
||||
)
|
||||
|
||||
mock_prisma_client = AsyncMock()
|
||||
org_table_find_many = AsyncMock(return_value=[])
|
||||
mock_prisma_client.db.litellm_organizationtable.find_many = org_table_find_many
|
||||
mock_prisma_client.db.litellm_organizationmembership.find_many = AsyncMock(return_value=[])
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.management_endpoints.organization_endpoints._user_has_admin_view",
|
||||
lambda _: False,
|
||||
)
|
||||
|
||||
get_daily_activity_mock = AsyncMock(return_value=MagicMock(name="SpendAnalyticsPaginatedResponse"))
|
||||
monkeypatch.setattr(organization_endpoints, "get_daily_activity", get_daily_activity_mock)
|
||||
|
||||
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="no-orgs-user")
|
||||
await get_organization_daily_activity(
|
||||
organization_ids=None,
|
||||
start_date="2024-04-01",
|
||||
end_date="2024-04-30",
|
||||
model=None,
|
||||
api_key=None,
|
||||
page=1,
|
||||
page_size=10,
|
||||
exclude_organization_ids=None,
|
||||
user_api_key_dict=auth,
|
||||
)
|
||||
|
||||
assert get_daily_activity_mock.call_args.kwargs["entity_id"] == []
|
||||
assert org_table_find_many.call_args.kwargs["where"] == {"organization_id": {"in": []}}
|
||||
|
|
|
|||
|
|
@ -939,6 +939,26 @@ describe("EntityUsage", () => {
|
|||
expect(call()).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// An org admin's session role is "Internal User", so the row above cannot
|
||||
// distinguish them. Gating the fetch on the session role alone left the
|
||||
// Organization Usage panel rendered but permanently empty, because the
|
||||
// request was never issued even though the proxy would have served it.
|
||||
it.each([
|
||||
["organization", () => mockOrganizationDailyActivityCall, true],
|
||||
["agent", () => mockAgentDailyActivityCall, false],
|
||||
] as const)("fetches %s activity for an org admin: %s", async (entityType, call, expected) => {
|
||||
render(<EntityUsage {...defaultProps} entityType={entityType} userRole="Internal User" isOrgAdmin={true} />);
|
||||
|
||||
if (expected) {
|
||||
await waitFor(() => {
|
||||
expect(call()).toHaveBeenCalled();
|
||||
});
|
||||
} else {
|
||||
expect(await screen.findByText("Agent Spend Overview")).toBeInTheDocument();
|
||||
expect(call()).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the team breakdown but drops its agent sub-fetch for an internal user", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="team" userRole="Internal User" />);
|
||||
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ interface EntityUsageProps {
|
|||
entityList: EntityList[] | null;
|
||||
premiumUser: boolean;
|
||||
dateValue: DateRangePickerValue;
|
||||
isOrgAdmin?: boolean;
|
||||
}
|
||||
|
||||
const ENTITY_FETCH_FNS: Record<EntityType, (...args: any[]) => Promise<any>> = {
|
||||
|
|
@ -115,6 +116,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
entityList,
|
||||
userRole,
|
||||
dateValue,
|
||||
isOrgAdmin = false,
|
||||
}) => {
|
||||
const { teams } = useTeams();
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
|
|
@ -135,7 +137,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
const fetchFn = ENTITY_FETCH_FNS[entityType];
|
||||
const aggregatedFetchFn = ENTITY_AGGREGATED_FETCH_FNS[entityType];
|
||||
const entityCapability = ENTITY_CAPABILITIES[entityType];
|
||||
const canViewEntity = entityCapability === undefined || hasCapability(userRole, entityCapability);
|
||||
const canViewEntity = entityCapability === undefined || hasCapability(userRole, entityCapability, isOrgAdmin);
|
||||
const showAgentBreakdown = entityType === "team" && hasCapability(userRole, "viewAgentUsage");
|
||||
const hasRequestWindow = !!accessToken && !!startTime && !!endTime;
|
||||
const enabled = hasRequestWindow && canViewEntity;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
|
||||
import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin";
|
||||
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
|
||||
import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
|
|
@ -141,6 +142,11 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
|||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useIsOrgAdmin", () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({
|
||||
useCurrentUser: vi.fn(),
|
||||
}));
|
||||
|
|
@ -621,6 +627,49 @@ describe("UsagePage", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// Org-admin membership comes from the server, so it can be revoked while the
|
||||
// page is open. The Organization Usage option and its panel both disappear,
|
||||
// and without a fallback the selector keeps a value it no longer offers,
|
||||
// leaving the user on a blank trigger over a blank panel with nothing to
|
||||
// click. An internal user is used because that is the session role an org
|
||||
// admin actually carries.
|
||||
it("should leave the organization view when org-admin membership is revoked mid-session", async () => {
|
||||
const mockUseIsOrgAdmin = vi.mocked(useIsOrgAdmin);
|
||||
mockUseIsOrgAdmin.mockReturnValue(true);
|
||||
mockUseAuthorized.mockReturnValue({
|
||||
isLoading: false,
|
||||
isAuthorized: true,
|
||||
token: "mock-token",
|
||||
accessToken: "test-token",
|
||||
userId: "user-123",
|
||||
userEmail: "test@example.com",
|
||||
userRole: "Internal User",
|
||||
premiumUser: true,
|
||||
disabledPersonalKeyCreation: false,
|
||||
showSSOBanner: false,
|
||||
} as any);
|
||||
|
||||
const { rerender } = renderWithProviders(<UsagePage {...defaultProps} organizations={mockOrganizations} />);
|
||||
|
||||
const usageSelect = screen.getByTestId("usage-view-select");
|
||||
act(() => {
|
||||
fireEvent.change(usageSelect, { target: { value: "organization" } });
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Entity Usage").length).toBeGreaterThan(0);
|
||||
});
|
||||
expect((usageSelect as HTMLSelectElement).value).toBe("organization");
|
||||
|
||||
mockUseIsOrgAdmin.mockReturnValue(false);
|
||||
act(() => {
|
||||
rerender(<UsagePage {...defaultProps} organizations={mockOrganizations} />);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect((screen.getByTestId("usage-view-select") as HTMLSelectElement).value).toBe("global");
|
||||
});
|
||||
});
|
||||
|
||||
it("should show customer usage view for admins", async () => {
|
||||
mockUseCustomers.mockReturnValue({
|
||||
data: mockCustomers,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
|
||||
import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin";
|
||||
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
|
||||
import { hasCapability } from "@/utils/capabilities";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
|
|
@ -100,7 +101,8 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
const { data: currentUser } = useCurrentUser();
|
||||
const isAdmin = all_admin_roles.includes(userRole || "");
|
||||
const canViewTagUsage = isAdmin || internalUserRoles.includes(userRole || "");
|
||||
const canViewOrganizationUsage = hasCapability(userRole, "viewOrganizationUsage");
|
||||
const isOrgAdmin = useIsOrgAdmin();
|
||||
const canViewOrganizationUsage = hasCapability(userRole, "viewOrganizationUsage", isOrgAdmin);
|
||||
const canViewAgentUsage = hasCapability(userRole, "viewAgentUsage");
|
||||
|
||||
// For admins: null means global view (all users), a string means filter by that user
|
||||
|
|
@ -110,7 +112,14 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
const [isCloudZeroModalOpen, setIsCloudZeroModalOpen] = useState(false);
|
||||
const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false);
|
||||
const [isAiChatOpen, setIsAiChatOpen] = useState(false);
|
||||
const [usageView, setUsageView] = useState<UsageOption>("global");
|
||||
const [selectedUsageView, setUsageView] = useState<UsageOption>("global");
|
||||
// Org-admin membership is read from the server, so unlike the other usage
|
||||
// views this one can be revoked while the page is open. Derive the view in
|
||||
// render rather than storing it, so the fallback lands on the same paint and
|
||||
// the selector never holds a value it no longer offers.
|
||||
const usageView: UsageOption =
|
||||
selectedUsageView === "organization" && !canViewOrganizationUsage ? "global" : selectedUsageView;
|
||||
|
||||
const [showCredentialBanner, setShowCredentialBanner] = useState(true);
|
||||
const [topKeysLimit, setTopKeysLimit] = useState<number>(5);
|
||||
const [topModelsLimit, setTopModelsLimit] = useState<number>(5);
|
||||
|
|
@ -460,6 +469,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
onChange={(value) => setUsageView(value)}
|
||||
userRole={userRole}
|
||||
canViewTagUsage={canViewTagUsage}
|
||||
isOrgAdmin={isOrgAdmin}
|
||||
/>
|
||||
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
|
||||
</div>
|
||||
|
|
@ -899,6 +909,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
entityType="organization"
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
isOrgAdmin={isOrgAdmin}
|
||||
dateValue={dateValue}
|
||||
entityList={
|
||||
organizations?.map((organization) => ({
|
||||
|
|
|
|||
|
|
@ -79,6 +79,24 @@ describe("UsageViewSelect", () => {
|
|||
expect(offers(container, optionName)).toBe(false);
|
||||
});
|
||||
|
||||
// An org admin's session role is "Internal User" — org-admin-ness lives in the
|
||||
// membership table — so the two rows above cannot tell them apart from a plain
|
||||
// internal user. Organization Usage must open for them, and only that option:
|
||||
// the proxy serves them /organization/daily/activity scoped to the orgs they
|
||||
// administer, but still refuses the agent usage route.
|
||||
it.each([
|
||||
["Organization Usage", true],
|
||||
["Agent Usage (A2A)", false],
|
||||
] as const)("should offer %s to an org admin: %s", async (optionName, expected) => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" isOrgAdmin={true} />,
|
||||
);
|
||||
|
||||
await openMenu(user);
|
||||
expect(offers(container, optionName)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each(["Team Usage", "Tag Usage"])("should keep %s available to an internal user", async (optionName) => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export interface UsageViewSelectProps {
|
|||
onChange: (value: UsageOption) => void;
|
||||
userRole: string | null;
|
||||
canViewTagUsage?: boolean;
|
||||
isOrgAdmin?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
"data-id"?: string;
|
||||
|
|
@ -108,6 +109,7 @@ export const UsageViewSelect: React.FC<UsageViewSelectProps> = ({
|
|||
onChange,
|
||||
userRole,
|
||||
canViewTagUsage = false,
|
||||
isOrgAdmin = false,
|
||||
title = "Usage View",
|
||||
description = "Select the usage data you want to view",
|
||||
"data-id": dataId,
|
||||
|
|
@ -116,7 +118,7 @@ export const UsageViewSelect: React.FC<UsageViewSelectProps> = ({
|
|||
const getFilteredOptions = () => {
|
||||
return OPTIONS.filter((option) => {
|
||||
if (option.capability) {
|
||||
return hasCapability(userRole, option.capability);
|
||||
return hasCapability(userRole, option.capability, isOrgAdmin);
|
||||
}
|
||||
if (option.value === "tag" && canViewTagUsage) {
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ const SESSION_ROLE_AN_ORG_ADMIN_ACTUALLY_CARRIES = "Internal User";
|
|||
|
||||
const ORG_ADMIN_BACKEND_ACCESS: ReadonlyArray<readonly [Capability, string, boolean]> = [
|
||||
["viewDeletedTeams", "GET /v2/team/list?status=deleted -> 200 (scoped to their orgs)", true],
|
||||
["viewOrganizationUsage", "GET /organization/daily/activity -> 200 (scoped to orgs they administer)", true],
|
||||
["viewToolPolicies", "GET /v1/tool/list -> 401", false],
|
||||
["viewPolicies", "GET /policies/list -> 401", false],
|
||||
["viewPrompts", "GET /prompts/list -> 401", false],
|
||||
|
|
@ -64,6 +65,14 @@ describe("hasCapability for organization admins", () => {
|
|||
expect(hasCapability(role, "viewDeletedTeams", true)).toBe(true);
|
||||
});
|
||||
|
||||
// An org admin is an internal_user whose org-admin-ness lives in the
|
||||
// membership table, so their session role never distinguishes them. Gating
|
||||
// the Organization Usage view on the session role alone hid the whole view
|
||||
// from them and left the tab's data fetch disabled.
|
||||
it.each(NON_ADMIN_ROLES)("grants viewOrganizationUsage to an org admin whose session role is %s", (role) => {
|
||||
expect(hasCapability(role, "viewOrganizationUsage", true)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(ADMIN_ONLY_CAPABILITIES)("leaves %s denied when the caller is not an org admin", (capability) => {
|
||||
expect(hasCapability(SESSION_ROLE_AN_ORG_ADMIN_ACTUALLY_CARRIES, capability, false)).toBe(false);
|
||||
expect(hasCapability(SESSION_ROLE_AN_ORG_ADMIN_ACTUALLY_CARRIES, capability)).toBe(false);
|
||||
|
|
@ -73,7 +82,7 @@ describe("hasCapability for organization admins", () => {
|
|||
const orgAdminCapabilities = ADMIN_ONLY_CAPABILITIES.filter((capability) =>
|
||||
hasCapability(SESSION_ROLE_AN_ORG_ADMIN_ACTUALLY_CARRIES, capability, true),
|
||||
);
|
||||
expect(orgAdminCapabilities).toEqual(["viewDeletedTeams"]);
|
||||
expect(orgAdminCapabilities).toEqual(["viewDeletedTeams", "viewOrganizationUsage"]);
|
||||
});
|
||||
|
||||
it("does not let the org-admin allowance reopen the proxy-admin-only viewGlobalSpend gate", () => {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,10 @@ const CAPABILITY_ROLES = {
|
|||
|
||||
export type Capability = keyof typeof CAPABILITY_ROLES;
|
||||
|
||||
const ORG_ADMIN_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>(["viewDeletedTeams"]);
|
||||
const ORG_ADMIN_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
|
||||
"viewDeletedTeams",
|
||||
"viewOrganizationUsage",
|
||||
]);
|
||||
|
||||
export const hasCapability = (
|
||||
userRole: string | null | undefined,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue