mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
feat(teams)!: only accept team member permission grants that are enforced
Some checks failed
LiteLLM Rust / rustfmt, clippy, test (push) Has been cancelled
Terraform Modules / fmt, validate, test (aws) (push) Has been cancelled
Terraform Provider / gofmt, vet, build, test (push) Has been cancelled
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Has been cancelled
Some checks failed
LiteLLM Rust / rustfmt, clippy, test (push) Has been cancelled
Terraform Modules / fmt, validate, test (aws) (push) Has been cancelled
Terraform Provider / gofmt, vet, build, test (push) Has been cancelled
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Has been cancelled
Team member permission writes previously accepted every
KeyManagementRoutes value (and /team/permissions_update accepted any
string at all), but only nine of those grants are ever read by the
permission checks; values like /key/block or /key/bulk_update guard
admin-only endpoints and the templated /key/{key_id}/... entries are
enforced under their plain counterparts, so granting them silently did
nothing. A new TeamMemberPermissions enum narrows the write surfaces
(default team settings, /team/permissions_update,
/team/permissions_bulk_update) to the nine enforced grants plus the
always-included /key/info and /key/health, /team/permissions_list now
advertises only the nine grantable values, and the default team
settings form derives its checkboxes from the new enum
BREAKING CHANGE: PATCH /update/default_team_settings,
POST /team/permissions_update, POST /team/permissions_bulk_update, and
litellm_settings.default_team_params now reject team member permission
values that were never enforced: /key/block, /key/unblock,
/key/bulk_update, /team/key/bulk_update, /key/{key_id}/regenerate,
/key/{key_id}/reset_spend, /key/aliases, /spend/logs/v2. Stored values
are unaffected and still read back
This commit is contained in:
parent
22e600fe5a
commit
774444fcd7
13 changed files with 161 additions and 40 deletions
|
|
@ -293,6 +293,28 @@ class KeyManagementRoutes(str, enum.Enum):
|
|||
SPEND_LOGS_V2 = "/spend/logs/v2"
|
||||
|
||||
|
||||
class TeamMemberPermissions(str, enum.Enum):
|
||||
"""
|
||||
The subset of KeyManagementRoutes accepted as team member permission
|
||||
grants. The remaining KeyManagementRoutes values are admin-gated or
|
||||
enforced under one of these grants, so accepting them would store a
|
||||
grant that silently never takes effect.
|
||||
"""
|
||||
|
||||
KEY_GENERATE = KeyManagementRoutes.KEY_GENERATE.value
|
||||
KEY_UPDATE = KeyManagementRoutes.KEY_UPDATE.value
|
||||
KEY_DELETE = KeyManagementRoutes.KEY_DELETE.value
|
||||
KEY_REGENERATE = KeyManagementRoutes.KEY_REGENERATE.value
|
||||
KEY_GENERATE_SERVICE_ACCOUNT = KeyManagementRoutes.KEY_GENERATE_SERVICE_ACCOUNT.value
|
||||
KEY_LIST = KeyManagementRoutes.KEY_LIST.value
|
||||
KEY_ACCESS_GROUP_ASSIGNMENT = KeyManagementRoutes.KEY_ACCESS_GROUP_ASSIGNMENT.value
|
||||
SPEND_LOGS = KeyManagementRoutes.SPEND_LOGS.value
|
||||
TEAM_DAILY_ACTIVITY = KeyManagementRoutes.TEAM_DAILY_ACTIVITY.value
|
||||
|
||||
KEY_INFO = KeyManagementRoutes.KEY_INFO.value
|
||||
KEY_HEALTH = KeyManagementRoutes.KEY_HEALTH.value
|
||||
|
||||
|
||||
class LiteLLMRoutes(enum.Enum):
|
||||
openai_route_names = [
|
||||
"chat_completion",
|
||||
|
|
|
|||
|
|
@ -5540,7 +5540,7 @@ async def update_team_member_permissions(
|
|||
# Update the team member permissions
|
||||
updated_team: Final = await _team_db(prisma_client).update(
|
||||
where={"team_id": data.team_id},
|
||||
data={"team_member_permissions": data.team_member_permissions},
|
||||
data={"team_member_permissions": [permission.value for permission in data.team_member_permissions]},
|
||||
)
|
||||
|
||||
return updated_team
|
||||
|
|
@ -5594,7 +5594,7 @@ async def bulk_update_team_member_permissions(
|
|||
detail={"error": "Cannot set both apply_to_all_teams=true and team_ids"},
|
||||
)
|
||||
|
||||
permissions_to_add: Final = set(data.permissions)
|
||||
permissions_to_add: Final = {permission.value for permission in data.permissions}
|
||||
|
||||
if data.team_ids:
|
||||
teams_updated = await _append_permissions_to_specific_teams(prisma_client, data.team_ids, permissions_to_add)
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ from litellm.proxy._types import (
|
|||
KeyManagementRoutes,
|
||||
LiteLLM_TeamTableCachedObj,
|
||||
LiteLLM_VerificationToken,
|
||||
LiteLLMRoutes,
|
||||
LitellmUserRoles,
|
||||
Member,
|
||||
ProxyErrorTypes,
|
||||
ProxyException,
|
||||
TeamMemberPermissions,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.auth.auth_checks import get_team_object
|
||||
|
|
@ -235,12 +235,11 @@ class TeamMemberPermissionChecks:
|
|||
@staticmethod
|
||||
def get_all_available_team_member_permissions() -> list[str]:
|
||||
"""
|
||||
Returns all available team member permissions
|
||||
Returns the team member permissions that can be granted: every
|
||||
TeamMemberPermissions value except the always-included baseline pair.
|
||||
"""
|
||||
all_available_permissions: Final = []
|
||||
for route in LiteLLMRoutes.key_management_routes.value:
|
||||
all_available_permissions.append(route)
|
||||
return all_available_permissions
|
||||
baseline: Final = {route.value for route in BASELINE_TEAM_MEMBER_PERMISSIONS}
|
||||
return [permission.value for permission in TeamMemberPermissions if permission.value not in baseline]
|
||||
|
||||
@staticmethod
|
||||
def default_team_member_permissions() -> list[str]:
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ from typing import Any, Literal
|
|||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from litellm.proxy._types import (
|
||||
KeyManagementRoutes,
|
||||
LiteLLM_DeletedTeamTable,
|
||||
LiteLLM_TeamMembership,
|
||||
LiteLLM_TeamTable,
|
||||
Member,
|
||||
TeamMemberPermissions,
|
||||
)
|
||||
|
||||
TeamIdSearchMatch = Literal["exact", "prefix"]
|
||||
|
|
@ -42,13 +42,13 @@ class UpdateTeamMemberPermissionsRequest(BaseModel):
|
|||
"""Request to update the team member permissions for a team"""
|
||||
|
||||
team_id: str
|
||||
team_member_permissions: list[str]
|
||||
team_member_permissions: list[TeamMemberPermissions]
|
||||
|
||||
|
||||
class BulkUpdateTeamMemberPermissionsRequest(BaseModel):
|
||||
"""Request to bulk-update team member permissions across teams."""
|
||||
|
||||
permissions: list[KeyManagementRoutes]
|
||||
permissions: list[TeamMemberPermissions]
|
||||
"""Permissions to append to the target teams (duplicates are skipped)."""
|
||||
|
||||
team_ids: list[str] | None = None
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from typing import Literal
|
|||
from pydantic import Field
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from litellm.proxy._types import KeyManagementRoutes, LitellmUserRoles
|
||||
from litellm.proxy._types import LitellmUserRoles, TeamMemberPermissions
|
||||
from litellm.types.utils import LiteLLMPydanticObjectBase
|
||||
|
||||
|
||||
|
|
@ -226,7 +226,7 @@ class DefaultTeamSSOParams(LiteLLMPydanticObjectBase):
|
|||
default=None,
|
||||
description="Default rpm limit for new teams, when not explicitly provided",
|
||||
)
|
||||
team_member_permissions: list[KeyManagementRoutes] | None = Field(
|
||||
team_member_permissions: list[TeamMemberPermissions] | None = Field(
|
||||
default=None,
|
||||
description="Default permissions granted to members of newly created teams (e.g. /key/generate, /key/update, /key/delete). /key/info and /key/health are always included.",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,21 @@ def _make_team_table(team_member_permissions):
|
|||
|
||||
|
||||
class TestGetPermissionsForTeamMember:
|
||||
def test_all_available_permissions_are_the_grantable_set_without_the_baseline(self):
|
||||
available = TeamMemberPermissionChecks.get_all_available_team_member_permissions()
|
||||
|
||||
assert sorted(available) == [
|
||||
"/key/access_group_assignment",
|
||||
"/key/delete",
|
||||
"/key/generate",
|
||||
"/key/list",
|
||||
"/key/regenerate",
|
||||
"/key/service-account/generate",
|
||||
"/key/update",
|
||||
"/spend/logs",
|
||||
"/team/daily/activity",
|
||||
]
|
||||
|
||||
def test_none_permissions_returns_defaults(self):
|
||||
"""When team_member_permissions is None, return DEFAULT_TEAM_MEMBER_PERMISSIONS."""
|
||||
team = _make_team_table(None)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from litellm.types.proxy.management_endpoints.team_endpoints import (
|
||||
BulkUpdateTeamMemberPermissionsRequest,
|
||||
UpdateTeamMemberPermissionsRequest,
|
||||
)
|
||||
|
||||
|
||||
def test_permissions_update_accepts_enforced_grants():
|
||||
request = UpdateTeamMemberPermissionsRequest(
|
||||
team_id="team-1",
|
||||
team_member_permissions=["/key/generate", "/key/delete"],
|
||||
)
|
||||
assert [permission.value for permission in request.team_member_permissions] == ["/key/generate", "/key/delete"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dead_route", ["/key/block", "/key/bulk_update", "/made/up/route"])
|
||||
def test_permissions_update_rejects_unenforced_grants(dead_route):
|
||||
with pytest.raises(ValidationError):
|
||||
UpdateTeamMemberPermissionsRequest(team_id="team-1", team_member_permissions=[dead_route])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dead_route", ["/key/unblock", "/spend/logs/v2"])
|
||||
def test_bulk_permissions_update_rejects_unenforced_grants(dead_route):
|
||||
with pytest.raises(ValidationError):
|
||||
BulkUpdateTeamMemberPermissionsRequest(permissions=[dead_route], apply_to_all_teams=True)
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from litellm.proxy._types import KeyManagementRoutes, TeamMemberPermissions
|
||||
from litellm.types.proxy.management_endpoints.ui_sso import DefaultTeamSSOParams
|
||||
|
||||
GRANTABLE_PERMISSIONS = sorted(
|
||||
permission.value
|
||||
for permission in TeamMemberPermissions
|
||||
if permission not in (TeamMemberPermissions.KEY_INFO, TeamMemberPermissions.KEY_HEALTH)
|
||||
)
|
||||
|
||||
DEAD_KEY_MANAGEMENT_ROUTES = sorted(
|
||||
{route.value for route in KeyManagementRoutes} - {permission.value for permission in TeamMemberPermissions}
|
||||
)
|
||||
|
||||
|
||||
def test_every_grantable_permission_is_a_key_management_route():
|
||||
key_management_values = {route.value for route in KeyManagementRoutes}
|
||||
assert {permission.value for permission in TeamMemberPermissions} <= key_management_values
|
||||
|
||||
|
||||
def test_grantable_permissions_are_exactly_the_enforced_set():
|
||||
assert GRANTABLE_PERMISSIONS == [
|
||||
"/key/access_group_assignment",
|
||||
"/key/delete",
|
||||
"/key/generate",
|
||||
"/key/list",
|
||||
"/key/regenerate",
|
||||
"/key/service-account/generate",
|
||||
"/key/update",
|
||||
"/spend/logs",
|
||||
"/team/daily/activity",
|
||||
]
|
||||
|
||||
|
||||
def test_default_team_params_accept_every_grantable_permission():
|
||||
params = DefaultTeamSSOParams(team_member_permissions=GRANTABLE_PERMISSIONS)
|
||||
assert [permission.value for permission in params.team_member_permissions or []] == GRANTABLE_PERMISSIONS
|
||||
|
||||
|
||||
def test_default_team_params_accept_the_always_included_baseline_pair():
|
||||
params = DefaultTeamSSOParams(team_member_permissions=["/key/info", "/key/health"])
|
||||
assert [permission.value for permission in params.team_member_permissions or []] == ["/key/info", "/key/health"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dead_route", DEAD_KEY_MANAGEMENT_ROUTES)
|
||||
def test_default_team_params_reject_grants_that_are_never_enforced(dead_route):
|
||||
with pytest.raises(ValidationError):
|
||||
DefaultTeamSSOParams(team_member_permissions=[dead_route])
|
||||
|
||||
|
||||
def test_dead_routes_cover_the_known_unenforced_grants():
|
||||
assert DEAD_KEY_MANAGEMENT_ROUTES == [
|
||||
"/key/aliases",
|
||||
"/key/block",
|
||||
"/key/bulk_update",
|
||||
"/key/unblock",
|
||||
"/key/{key_id}/regenerate",
|
||||
"/key/{key_id}/reset_spend",
|
||||
"/spend/logs/v2",
|
||||
"/team/key/bulk_update",
|
||||
]
|
||||
|
|
@ -23,7 +23,7 @@ import { useZodForm } from "@/lib/forms/useZodForm";
|
|||
import { fetchClient } from "@/lib/http/api";
|
||||
|
||||
import { buildBody, settingsToForm, type DefaultTeamParams, type DefaultTeamSettings } from "./mapper";
|
||||
import { SELECTABLE_PERMISSIONS, type KeyManagementRoute } from "./permissions";
|
||||
import { SELECTABLE_PERMISSIONS, type TeamMemberPermission } from "./permissions";
|
||||
import { defaultTeamSettingsSchema, type DefaultTeamSettingsFormValues } from "./schema";
|
||||
|
||||
const MODEL_SENTINEL_LABELS: ReadonlyMap<string, string> = new Map(
|
||||
|
|
@ -54,7 +54,7 @@ const PermissionRow = ({
|
|||
granted,
|
||||
onToggle,
|
||||
}: {
|
||||
route: KeyManagementRoute;
|
||||
route: TeamMemberPermission;
|
||||
granted: boolean;
|
||||
onToggle: (checked: boolean) => void;
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -1,32 +1,24 @@
|
|||
import type { components } from "@/lib/http/schema";
|
||||
|
||||
export type KeyManagementRoute = components["schemas"]["KeyManagementRoutes"];
|
||||
export type TeamMemberPermission = components["schemas"]["TeamMemberPermissions"];
|
||||
|
||||
const PERMISSION_IS_SELECTABLE: Record<KeyManagementRoute, boolean> = {
|
||||
const PERMISSION_IS_SELECTABLE: Record<TeamMemberPermission, boolean> = {
|
||||
"/key/generate": true,
|
||||
"/key/update": true,
|
||||
"/key/delete": true,
|
||||
"/key/regenerate": true,
|
||||
"/key/{key_id}/regenerate": true,
|
||||
"/key/service-account/generate": true,
|
||||
"/key/block": true,
|
||||
"/key/unblock": true,
|
||||
"/key/bulk_update": true,
|
||||
"/team/key/bulk_update": true,
|
||||
"/key/{key_id}/reset_spend": true,
|
||||
"/key/access_group_assignment": true,
|
||||
"/key/info": true,
|
||||
"/key/list": true,
|
||||
"/key/aliases": true,
|
||||
"/team/daily/activity": true,
|
||||
"/spend/logs": true,
|
||||
"/spend/logs/v2": true,
|
||||
"/key/info": false,
|
||||
"/key/health": false,
|
||||
};
|
||||
|
||||
export const SELECTABLE_PERMISSIONS: readonly KeyManagementRoute[] = (
|
||||
Object.keys(PERMISSION_IS_SELECTABLE) as KeyManagementRoute[]
|
||||
export const SELECTABLE_PERMISSIONS: readonly TeamMemberPermission[] = (
|
||||
Object.keys(PERMISSION_IS_SELECTABLE) as TeamMemberPermission[]
|
||||
).filter((route) => PERMISSION_IS_SELECTABLE[route]);
|
||||
|
||||
export const isSelectablePermission = (value: string): value is KeyManagementRoute =>
|
||||
export const isSelectablePermission = (value: string): value is TeamMemberPermission =>
|
||||
(SELECTABLE_PERMISSIONS as readonly string[]).includes(value);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { z } from "zod/v4";
|
||||
|
||||
import { isSelectablePermission, type KeyManagementRoute } from "./permissions";
|
||||
import { isSelectablePermission, type TeamMemberPermission } from "./permissions";
|
||||
|
||||
const isBlank = (value: string): boolean => value.trim() === "";
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ const wholeNumberOrEmpty = z
|
|||
"Must be a non-negative whole number",
|
||||
);
|
||||
|
||||
const selectablePermission = z.custom<KeyManagementRoute>(
|
||||
const selectablePermission = z.custom<TeamMemberPermission>(
|
||||
(value) => typeof value === "string" && isSelectablePermission(value),
|
||||
"Unknown permission",
|
||||
);
|
||||
|
|
|
|||
21
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
21
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -22593,7 +22593,7 @@ export interface components {
|
|||
*/
|
||||
apply_to_all_teams: boolean;
|
||||
/** Permissions */
|
||||
permissions: components["schemas"]["KeyManagementRoutes"][];
|
||||
permissions: components["schemas"]["TeamMemberPermissions"][];
|
||||
/** Team Ids */
|
||||
team_ids?: string[] | null;
|
||||
};
|
||||
|
|
@ -24560,7 +24560,7 @@ export interface components {
|
|||
* Team Member Permissions
|
||||
* @description Default permissions granted to members of newly created teams (e.g. /key/generate, /key/update, /key/delete). /key/info and /key/health are always included.
|
||||
*/
|
||||
team_member_permissions?: components["schemas"]["KeyManagementRoutes"][] | null;
|
||||
team_member_permissions?: components["schemas"]["TeamMemberPermissions"][] | null;
|
||||
/**
|
||||
* Tpm Limit
|
||||
* @description Default tpm limit for new teams, when not explicitly provided
|
||||
|
|
@ -25893,12 +25893,6 @@ export interface components {
|
|||
/** Total Pages */
|
||||
total_pages?: number | null;
|
||||
};
|
||||
/**
|
||||
* KeyManagementRoutes
|
||||
* @description Enum for key management routes
|
||||
* @enum {string}
|
||||
*/
|
||||
KeyManagementRoutes: "/key/generate" | "/key/update" | "/key/delete" | "/key/regenerate" | "/key/service-account/generate" | "/key/{key_id}/regenerate" | "/key/block" | "/key/unblock" | "/key/bulk_update" | "/team/key/bulk_update" | "/key/{key_id}/reset_spend" | "/key/access_group_assignment" | "/key/info" | "/key/health" | "/key/list" | "/key/aliases" | "/team/daily/activity" | "/spend/logs" | "/spend/logs/v2";
|
||||
/**
|
||||
* KeyManagementSystem
|
||||
* @enum {string}
|
||||
|
|
@ -33597,6 +33591,15 @@ export interface components {
|
|||
/** User Id */
|
||||
user_id: string;
|
||||
};
|
||||
/**
|
||||
* TeamMemberPermissions
|
||||
* @description The subset of KeyManagementRoutes accepted as team member permission
|
||||
* grants. The remaining KeyManagementRoutes values are admin-gated or
|
||||
* enforced under one of these grants, so accepting them would store a
|
||||
* grant that silently never takes effect.
|
||||
* @enum {string}
|
||||
*/
|
||||
TeamMemberPermissions: "/key/generate" | "/key/update" | "/key/delete" | "/key/regenerate" | "/key/service-account/generate" | "/key/list" | "/key/access_group_assignment" | "/spend/logs" | "/team/daily/activity" | "/key/info" | "/key/health";
|
||||
/** TeamMemberUpdateRequest */
|
||||
TeamMemberUpdateRequest: {
|
||||
/**
|
||||
|
|
@ -34645,7 +34648,7 @@ export interface components {
|
|||
/** Team Id */
|
||||
team_id: string;
|
||||
/** Team Member Permissions */
|
||||
team_member_permissions: string[];
|
||||
team_member_permissions: components["schemas"]["TeamMemberPermissions"][];
|
||||
};
|
||||
/**
|
||||
* UpdateTeamRequest
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue