forked from phoenix/litellm-mirror
(feat proxy) [beta] add support for organization role based access controls (#6112)
* track LiteLLM_OrganizationMembership * add add_internal_user_to_organization * add org membership to schema * read organization membership when reading user info in auth checks * add check for valid organization_id * add test for test_create_new_user_in_organization * test test_create_new_user_in_organization * add new ADMIN role * add test for org admins creating teams * add test for test_org_admin_create_user_permissions * test_org_admin_create_user_team_wrong_org_permissions * test_org_admin_create_user_team_wrong_org_permissions * fix organization_role_based_access_check * fix getting user members * fix TeamBase * fix types used for use role * fix type checks * sync prisma schema * docs - organization admins * fix use organization_endpoints for /organization management * add types for org member endpoints * fix role name for org admin * add type for member add response * add organization/member_add * add error handling for adding members to an org * add nice doc string for oranization/member_add * fix test_create_new_user_in_organization * linting fix * use simple route changes * fix types * add organization member roles * add org admin auth checks * add auth checks for orgs * test for creating teams as org admin * simplify org id usage * fix typo * test test_org_admin_create_user_team_wrong_org_permissions * fix type check issue * code quality fix * fix schema.prisma
This commit is contained in:
parent
945267a511
commit
1fd437e263
14 changed files with 1474 additions and 261 deletions
145
docs/my-website/docs/proxy/access_control.md
Normal file
145
docs/my-website/docs/proxy/access_control.md
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
# Role-based Access Controls (RBAC)
|
||||||
|
|
||||||
|
Role-based access control (RBAC) is based on Organizations, Teams and Internal User Roles
|
||||||
|
|
||||||
|
- `Organizations` are the top-level entities that contain Teams.
|
||||||
|
- `Team` - A Team is a collection of multiple `Internal Users`
|
||||||
|
- `Internal Users` - users that can create keys, make LLM API calls, view usage on LiteLLM
|
||||||
|
- `Roles` define the permissions of an `Internal User`
|
||||||
|
- `Virtual Keys` - Keys are used for authentication to the LiteLLM API. Keys are tied to a `Internal User` and `Team`
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
**Admin Roles**
|
||||||
|
- `proxy_admin`: admin over the platform
|
||||||
|
- `proxy_admin_viewer`: can login, view all keys, view all spend. **Cannot** create/delete keys, add new users.
|
||||||
|
|
||||||
|
**Organization Roles**
|
||||||
|
- `organization_admin`: admin over the organization. Can create teams and users within their organization
|
||||||
|
|
||||||
|
**Internal User Roles**
|
||||||
|
- `internal_user`: can login, view/create/delete their own keys, view their spend. **Cannot** add new users.
|
||||||
|
- `internal_user_viewer`: can login, view their own keys, view their own spend. **Cannot** create/delete keys, add new users.
|
||||||
|
|
||||||
|
|
||||||
|
## Managing Organizations
|
||||||
|
|
||||||
|
### 1. Creating a new Organization
|
||||||
|
|
||||||
|
Any user with role=`proxy_admin` can create a new organization
|
||||||
|
|
||||||
|
**Usage**
|
||||||
|
|
||||||
|
[**API Reference for /organization/new**](https://litellm-api.up.railway.app/#/organization%20management/new_organization_organization_new_post)
|
||||||
|
|
||||||
|
```shell
|
||||||
|
curl --location 'http://0.0.0.0:4000/organization/new' \
|
||||||
|
--header 'Authorization: Bearer sk-1234' \
|
||||||
|
--header 'Content-Type: application/json' \
|
||||||
|
--data '{
|
||||||
|
"organization_alias": "marketing_department",
|
||||||
|
"models": ["gpt-4"],
|
||||||
|
"max_budget": 20
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"organization_id": "ad15e8ca-12ae-46f4-8659-d02debef1b23",
|
||||||
|
"organization_alias": "marketing_department",
|
||||||
|
"budget_id": "98754244-3a9c-4b31-b2e9-c63edc8fd7eb",
|
||||||
|
"metadata": {},
|
||||||
|
"models": [
|
||||||
|
"gpt-4"
|
||||||
|
],
|
||||||
|
"created_by": "109010464461339474872",
|
||||||
|
"updated_by": "109010464461339474872",
|
||||||
|
"created_at": "2024-10-08T18:30:24.637000Z",
|
||||||
|
"updated_at": "2024-10-08T18:30:24.637000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### 2. Adding an `organization_admin` to an Organization
|
||||||
|
|
||||||
|
Create a user (ishaan@berri.ai) as an `organization_admin` for the `marketing_department` Organization (from [step 1](#1-creating-a-new-organization))
|
||||||
|
|
||||||
|
Users with the following roles can call `/organization/member_add`
|
||||||
|
- `proxy_admin`
|
||||||
|
- `organization_admin` only within their own organization
|
||||||
|
|
||||||
|
```shell
|
||||||
|
curl -X POST 'http://0.0.0.0:4000/organization/member_add' \
|
||||||
|
-H 'Authorization: Bearer sk-1234' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"organization_id": "ad15e8ca-12ae-46f4-8659-d02debef1b23", "member": {"role": "organization_admin", "user_id": "ishaan@berri.ai"}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Now a user with user_id = `ishaan@berri.ai` and role = `organization_admin` has been created in the `marketing_department` Organization
|
||||||
|
|
||||||
|
Create a Virtual Key for user_id = `ishaan@berri.ai`. The User can then use the Virtual key for their Organization Admin Operations
|
||||||
|
|
||||||
|
```shell
|
||||||
|
curl --location 'http://0.0.0.0:4000/key/generate' \
|
||||||
|
--header 'Authorization: Bearer sk-1234' \
|
||||||
|
--header 'Content-Type: application/json' \
|
||||||
|
--data '{
|
||||||
|
"user_id": "ishaan@berri.ai"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"models": [],
|
||||||
|
"user_id": "ishaan@berri.ai",
|
||||||
|
"key": "sk-7shH8TGMAofR4zQpAAo6kQ",
|
||||||
|
"key_name": "sk-...o6kQ",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. `Organization Admin` - Create a Team
|
||||||
|
|
||||||
|
The organization admin will use the virtual key created in [step 2](#2-adding-an-organization_admin-to-an-organization) to create a `Team` within the `marketing_department` Organization
|
||||||
|
|
||||||
|
```shell
|
||||||
|
curl --location 'http://0.0.0.0:4000/team/new' \
|
||||||
|
--header 'Authorization: Bearer sk-7shH8TGMAofR4zQpAAo6kQ' \
|
||||||
|
--header 'Content-Type: application/json' \
|
||||||
|
--data '{
|
||||||
|
"team_alias": "engineering_team",
|
||||||
|
"organization_id": "ad15e8ca-12ae-46f4-8659-d02debef1b23",
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
This will create the team `engineering_team` within the `marketing_department` Organization
|
||||||
|
|
||||||
|
Expected Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"team_alias": "engineering_team",
|
||||||
|
"team_id": "01044ee8-441b-45f4-be7d-c70e002722d8",
|
||||||
|
"organization_id": "ad15e8ca-12ae-46f4-8659-d02debef1b23",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### `Organization Admin` - Add an `Internal User`
|
||||||
|
|
||||||
|
The organization admin will use the virtual key created in [step 2](#2-adding-an-organization_admin-to-an-organization) to add an Internal User to the `engineering_team` Team.
|
||||||
|
|
||||||
|
- We will assign role=`internal_user` so the user can create Virtual Keys for themselves
|
||||||
|
- `team_id` is from [step 3](#3-organization-admin---create-a-team)
|
||||||
|
|
||||||
|
```shell
|
||||||
|
curl -X POST 'http://0.0.0.0:4000/team/member_add' \
|
||||||
|
-H 'Authorization: Bearer sk-1234' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"team_id": "01044ee8-441b-45f4-be7d-c70e002722d8",, "member": {"role": "internal_user", "user_id": "krrish@berri.ai"}}'
|
||||||
|
|
||||||
|
```
|
||||||
|
|
|
@ -78,7 +78,12 @@ const sidebars = {
|
||||||
{
|
{
|
||||||
type: "category",
|
type: "category",
|
||||||
label: "Admin UI",
|
label: "Admin UI",
|
||||||
items: ["proxy/ui", "proxy/self_serve", "proxy/custom_sso"],
|
items: [
|
||||||
|
"proxy/ui",
|
||||||
|
"proxy/self_serve",
|
||||||
|
"proxy/access_control",
|
||||||
|
"proxy/custom_sso"
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "category",
|
type: "category",
|
||||||
|
|
|
@ -34,6 +34,7 @@ class LitellmUserRoles(str, enum.Enum):
|
||||||
Admin Roles:
|
Admin Roles:
|
||||||
PROXY_ADMIN: admin over the platform
|
PROXY_ADMIN: admin over the platform
|
||||||
PROXY_ADMIN_VIEW_ONLY: can login, view all own keys, view all spend
|
PROXY_ADMIN_VIEW_ONLY: can login, view all own keys, view all spend
|
||||||
|
ORG_ADMIN: admin over a specific organization, can create teams, users only within their organization
|
||||||
|
|
||||||
Internal User Roles:
|
Internal User Roles:
|
||||||
INTERNAL_USER: can login, view/create/delete their own keys, view their spend
|
INTERNAL_USER: can login, view/create/delete their own keys, view their spend
|
||||||
|
@ -53,6 +54,9 @@ class LitellmUserRoles(str, enum.Enum):
|
||||||
PROXY_ADMIN = "proxy_admin"
|
PROXY_ADMIN = "proxy_admin"
|
||||||
PROXY_ADMIN_VIEW_ONLY = "proxy_admin_viewer"
|
PROXY_ADMIN_VIEW_ONLY = "proxy_admin_viewer"
|
||||||
|
|
||||||
|
# Organization admins
|
||||||
|
ORG_ADMIN = "org_admin"
|
||||||
|
|
||||||
# Internal User Roles
|
# Internal User Roles
|
||||||
INTERNAL_USER = "internal_user"
|
INTERNAL_USER = "internal_user"
|
||||||
INTERNAL_USER_VIEW_ONLY = "internal_user_viewer"
|
INTERNAL_USER_VIEW_ONLY = "internal_user_viewer"
|
||||||
|
@ -359,6 +363,20 @@ class LiteLLMRoutes(enum.Enum):
|
||||||
"/team/member_delete",
|
"/team/member_delete",
|
||||||
] # routes that manage their own allowed/disallowed logic
|
] # routes that manage their own allowed/disallowed logic
|
||||||
|
|
||||||
|
## Org Admin Routes ##
|
||||||
|
|
||||||
|
# Routes only an Org Admin Can Access
|
||||||
|
org_admin_only_routes = [
|
||||||
|
"/organization/info",
|
||||||
|
"/organization/delete",
|
||||||
|
"/organization/member_add",
|
||||||
|
]
|
||||||
|
|
||||||
|
# All routes accesible by an Org Admin
|
||||||
|
org_admin_allowed_routes = (
|
||||||
|
org_admin_only_routes + management_routes + self_managed_routes
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# class LiteLLMAllowedRoutes(LiteLLMBase):
|
# class LiteLLMAllowedRoutes(LiteLLMBase):
|
||||||
# """
|
# """
|
||||||
|
@ -695,12 +713,9 @@ class NewUserRequest(_GenerateKeyRequest):
|
||||||
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
|
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
|
||||||
LitellmUserRoles.INTERNAL_USER,
|
LitellmUserRoles.INTERNAL_USER,
|
||||||
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
|
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
|
||||||
LitellmUserRoles.TEAM,
|
|
||||||
LitellmUserRoles.CUSTOMER,
|
|
||||||
]
|
]
|
||||||
] = None
|
] = None
|
||||||
teams: Optional[list] = None
|
teams: Optional[list] = None
|
||||||
organization_id: Optional[str] = None
|
|
||||||
auto_create_key: bool = (
|
auto_create_key: bool = (
|
||||||
True # flag used for returning a key as part of the /user/new response
|
True # flag used for returning a key as part of the /user/new response
|
||||||
)
|
)
|
||||||
|
@ -716,12 +731,9 @@ class NewUserResponse(GenerateKeyResponse):
|
||||||
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
|
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
|
||||||
LitellmUserRoles.INTERNAL_USER,
|
LitellmUserRoles.INTERNAL_USER,
|
||||||
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
|
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
|
||||||
LitellmUserRoles.TEAM,
|
|
||||||
LitellmUserRoles.CUSTOMER,
|
|
||||||
]
|
]
|
||||||
] = None
|
] = None
|
||||||
teams: Optional[list] = None
|
teams: Optional[list] = None
|
||||||
organization_id: Optional[str] = None
|
|
||||||
user_alias: Optional[str] = None
|
user_alias: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@ -739,8 +751,6 @@ class UpdateUserRequest(GenerateRequestBase):
|
||||||
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
|
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
|
||||||
LitellmUserRoles.INTERNAL_USER,
|
LitellmUserRoles.INTERNAL_USER,
|
||||||
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
|
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
|
||||||
LitellmUserRoles.TEAM,
|
|
||||||
LitellmUserRoles.CUSTOMER,
|
|
||||||
]
|
]
|
||||||
] = None
|
] = None
|
||||||
max_budget: Optional[float] = None
|
max_budget: Optional[float] = None
|
||||||
|
@ -811,7 +821,14 @@ class DeleteCustomerRequest(LiteLLMBase):
|
||||||
|
|
||||||
|
|
||||||
class Member(LiteLLMBase):
|
class Member(LiteLLMBase):
|
||||||
role: Literal["admin", "user"]
|
role: Literal[
|
||||||
|
LitellmUserRoles.ORG_ADMIN,
|
||||||
|
LitellmUserRoles.INTERNAL_USER,
|
||||||
|
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
|
||||||
|
# older Member roles
|
||||||
|
"admin",
|
||||||
|
"user",
|
||||||
|
]
|
||||||
user_id: Optional[str] = None
|
user_id: Optional[str] = None
|
||||||
user_email: Optional[str] = None
|
user_email: Optional[str] = None
|
||||||
|
|
||||||
|
@ -857,51 +874,6 @@ class GlobalEndUsersSpend(LiteLLMBase):
|
||||||
endTime: Optional[datetime] = None
|
endTime: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
class TeamMemberAddRequest(LiteLLMBase):
|
|
||||||
team_id: str
|
|
||||||
member: Union[List[Member], Member]
|
|
||||||
max_budget_in_team: Optional[float] = None # Users max budget within the team
|
|
||||||
|
|
||||||
def __init__(self, **data):
|
|
||||||
member_data = data.get("member")
|
|
||||||
if isinstance(member_data, list):
|
|
||||||
# If member is a list of dictionaries, convert each dictionary to a Member object
|
|
||||||
members = [Member(**item) for item in member_data]
|
|
||||||
# Replace member_data with the list of Member objects
|
|
||||||
data["member"] = members
|
|
||||||
elif isinstance(member_data, dict):
|
|
||||||
# If member is a dictionary, convert it to a single Member object
|
|
||||||
member = Member(**member_data)
|
|
||||||
# Replace member_data with the single Member object
|
|
||||||
data["member"] = member
|
|
||||||
# Call the superclass __init__ method to initialize the object
|
|
||||||
super().__init__(**data)
|
|
||||||
|
|
||||||
|
|
||||||
class TeamMemberDeleteRequest(LiteLLMBase):
|
|
||||||
team_id: str
|
|
||||||
user_id: Optional[str] = None
|
|
||||||
user_email: Optional[str] = None
|
|
||||||
|
|
||||||
@model_validator(mode="before")
|
|
||||||
@classmethod
|
|
||||||
def check_user_info(cls, values):
|
|
||||||
if values.get("user_id") is None and values.get("user_email") is None:
|
|
||||||
raise ValueError("Either user id or user email must be provided")
|
|
||||||
return values
|
|
||||||
|
|
||||||
|
|
||||||
class TeamMemberUpdateRequest(TeamMemberDeleteRequest):
|
|
||||||
max_budget_in_team: float
|
|
||||||
|
|
||||||
|
|
||||||
class TeamMemberUpdateResponse(LiteLLMBase):
|
|
||||||
team_id: str
|
|
||||||
user_id: str
|
|
||||||
user_email: Optional[str] = None
|
|
||||||
max_budget_in_team: float
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateTeamRequest(LiteLLMBase):
|
class UpdateTeamRequest(LiteLLMBase):
|
||||||
"""
|
"""
|
||||||
UpdateTeamRequest, used by /team/update when you need to update a team
|
UpdateTeamRequest, used by /team/update when you need to update a team
|
||||||
|
@ -1444,6 +1416,26 @@ class LiteLLM_Config(LiteLLMBase):
|
||||||
param_value: Dict
|
param_value: Dict
|
||||||
|
|
||||||
|
|
||||||
|
class LiteLLM_OrganizationMembershipTable(LiteLLMBase):
|
||||||
|
"""
|
||||||
|
This is the table that track what organizations a user belongs to and users spend within the organization
|
||||||
|
"""
|
||||||
|
|
||||||
|
user_id: str
|
||||||
|
organization_id: str
|
||||||
|
user_role: Optional[str] = None
|
||||||
|
spend: float = 0.0
|
||||||
|
budget_id: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
user: Optional[Any] = (
|
||||||
|
None # You might want to replace 'Any' with a more specific type if available
|
||||||
|
)
|
||||||
|
litellm_budget_table: Optional[LiteLLM_BudgetTable] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(protected_namespaces=())
|
||||||
|
|
||||||
|
|
||||||
class LiteLLM_UserTable(LiteLLMBase):
|
class LiteLLM_UserTable(LiteLLMBase):
|
||||||
user_id: str
|
user_id: str
|
||||||
max_budget: Optional[float]
|
max_budget: Optional[float]
|
||||||
|
@ -1455,6 +1447,7 @@ class LiteLLM_UserTable(LiteLLMBase):
|
||||||
tpm_limit: Optional[int] = None
|
tpm_limit: Optional[int] = None
|
||||||
rpm_limit: Optional[int] = None
|
rpm_limit: Optional[int] = None
|
||||||
user_role: Optional[str] = None
|
user_role: Optional[str] = None
|
||||||
|
organization_memberships: Optional[List[LiteLLM_OrganizationMembershipTable]] = None
|
||||||
|
|
||||||
@model_validator(mode="before")
|
@model_validator(mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -1907,11 +1900,99 @@ class LiteLLM_TeamMembership(LiteLLMBase):
|
||||||
litellm_budget_table: Optional[LiteLLM_BudgetTable]
|
litellm_budget_table: Optional[LiteLLM_BudgetTable]
|
||||||
|
|
||||||
|
|
||||||
|
#### Organization / Team Member Requests ####
|
||||||
|
|
||||||
|
|
||||||
|
class MemberAddRequest(LiteLLMBase):
|
||||||
|
member: Union[List[Member], Member]
|
||||||
|
|
||||||
|
def __init__(self, **data):
|
||||||
|
member_data = data.get("member")
|
||||||
|
if isinstance(member_data, list):
|
||||||
|
# If member is a list of dictionaries, convert each dictionary to a Member object
|
||||||
|
members = [Member(**item) for item in member_data]
|
||||||
|
# Replace member_data with the list of Member objects
|
||||||
|
data["member"] = members
|
||||||
|
elif isinstance(member_data, dict):
|
||||||
|
# If member is a dictionary, convert it to a single Member object
|
||||||
|
member = Member(**member_data)
|
||||||
|
# Replace member_data with the single Member object
|
||||||
|
data["member"] = member
|
||||||
|
# Call the superclass __init__ method to initialize the object
|
||||||
|
super().__init__(**data)
|
||||||
|
|
||||||
|
|
||||||
class TeamAddMemberResponse(LiteLLM_TeamTable):
|
class TeamAddMemberResponse(LiteLLM_TeamTable):
|
||||||
updated_users: List[LiteLLM_UserTable]
|
updated_users: List[LiteLLM_UserTable]
|
||||||
updated_team_memberships: List[LiteLLM_TeamMembership]
|
updated_team_memberships: List[LiteLLM_TeamMembership]
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationAddMemberResponse(LiteLLMBase):
|
||||||
|
organization_id: str
|
||||||
|
updated_users: List[LiteLLM_UserTable]
|
||||||
|
updated_organization_memberships: List[LiteLLM_OrganizationMembershipTable]
|
||||||
|
|
||||||
|
|
||||||
|
class MemberDeleteRequest(LiteLLMBase):
|
||||||
|
user_id: Optional[str] = None
|
||||||
|
user_email: Optional[str] = None
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def check_user_info(cls, values):
|
||||||
|
if values.get("user_id") is None and values.get("user_email") is None:
|
||||||
|
raise ValueError("Either user id or user email must be provided")
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
class MemberUpdateResponse(LiteLLMBase):
|
||||||
|
user_id: str
|
||||||
|
user_email: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# Team Member Requests
|
||||||
|
class TeamMemberAddRequest(MemberAddRequest):
|
||||||
|
team_id: str
|
||||||
|
max_budget_in_team: Optional[float] = None # Users max budget within the team
|
||||||
|
|
||||||
|
|
||||||
|
class TeamMemberDeleteRequest(MemberDeleteRequest):
|
||||||
|
team_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class TeamMemberUpdateRequest(TeamMemberDeleteRequest):
|
||||||
|
max_budget_in_team: float
|
||||||
|
|
||||||
|
|
||||||
|
class TeamMemberUpdateResponse(MemberUpdateResponse):
|
||||||
|
team_id: str
|
||||||
|
max_budget_in_team: float
|
||||||
|
|
||||||
|
|
||||||
|
# Organization Member Requests
|
||||||
|
class OrganizationMemberAddRequest(MemberAddRequest):
|
||||||
|
organization_id: str
|
||||||
|
max_budget_in_organization: Optional[float] = (
|
||||||
|
None # Users max budget within the organization
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationMemberDeleteRequest(MemberDeleteRequest):
|
||||||
|
organization_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationMemberUpdateRequest(OrganizationMemberDeleteRequest):
|
||||||
|
max_budget_in_organization: float
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationMemberUpdateResponse(MemberUpdateResponse):
|
||||||
|
organization_id: str
|
||||||
|
max_budget_in_organization: float
|
||||||
|
|
||||||
|
|
||||||
|
##########################################
|
||||||
|
|
||||||
|
|
||||||
class TeamInfoResponseObject(TypedDict):
|
class TeamInfoResponseObject(TypedDict):
|
||||||
team_id: str
|
team_id: str
|
||||||
team_info: LiteLLM_TeamTable
|
team_info: LiteLLM_TeamTable
|
||||||
|
|
|
@ -32,6 +32,8 @@ from litellm.proxy.auth.route_checks import is_llm_api_route
|
||||||
from litellm.proxy.utils import PrismaClient, ProxyLogging, log_to_opentelemetry
|
from litellm.proxy.utils import PrismaClient, ProxyLogging, log_to_opentelemetry
|
||||||
from litellm.types.services import ServiceLoggerPayload, ServiceTypes
|
from litellm.types.services import ServiceLoggerPayload, ServiceTypes
|
||||||
|
|
||||||
|
from .auth_checks_organization import organization_role_based_access_check
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from opentelemetry.trace import Span as _Span
|
from opentelemetry.trace import Span as _Span
|
||||||
|
|
||||||
|
@ -63,6 +65,7 @@ def common_checks(
|
||||||
7. [OPTIONAL] If 'litellm.max_budget' is set (>0), is proxy under budget
|
7. [OPTIONAL] If 'litellm.max_budget' is set (>0), is proxy under budget
|
||||||
8. [OPTIONAL] If guardrails modified - is request allowed to change this
|
8. [OPTIONAL] If guardrails modified - is request allowed to change this
|
||||||
9. Check if request body is safe
|
9. Check if request body is safe
|
||||||
|
10. [OPTIONAL] Organization checks - is user_object.organization_id is set, run these checks
|
||||||
"""
|
"""
|
||||||
_model = request_body.get("model", None)
|
_model = request_body.get("model", None)
|
||||||
if team_object is not None and team_object.blocked is True:
|
if team_object is not None and team_object.blocked is True:
|
||||||
|
@ -73,6 +76,7 @@ def common_checks(
|
||||||
if (
|
if (
|
||||||
_model is not None
|
_model is not None
|
||||||
and team_object is not None
|
and team_object is not None
|
||||||
|
and team_object.models is not None
|
||||||
and len(team_object.models) > 0
|
and len(team_object.models) > 0
|
||||||
and _model not in team_object.models
|
and _model not in team_object.models
|
||||||
):
|
):
|
||||||
|
@ -202,6 +206,12 @@ def common_checks(
|
||||||
"error": "Your team does not have permission to modify guardrails."
|
"error": "Your team does not have permission to modify guardrails."
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 10 [OPTIONAL] Organization RBAC checks
|
||||||
|
organization_role_based_access_check(
|
||||||
|
user_object=user_object, route=route, request_body=request_body
|
||||||
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@ -403,17 +413,30 @@ async def get_user_object(
|
||||||
try:
|
try:
|
||||||
|
|
||||||
response = await prisma_client.db.litellm_usertable.find_unique(
|
response = await prisma_client.db.litellm_usertable.find_unique(
|
||||||
where={"user_id": user_id}
|
where={"user_id": user_id}, include={"organization_memberships": True}
|
||||||
)
|
)
|
||||||
|
|
||||||
if response is None:
|
if response is None:
|
||||||
if user_id_upsert:
|
if user_id_upsert:
|
||||||
response = await prisma_client.db.litellm_usertable.create(
|
response = await prisma_client.db.litellm_usertable.create(
|
||||||
data={"user_id": user_id}
|
data={"user_id": user_id},
|
||||||
|
include={"organization_memberships": True},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise Exception
|
raise Exception
|
||||||
|
|
||||||
|
if (
|
||||||
|
response.organization_memberships is not None
|
||||||
|
and len(response.organization_memberships) > 0
|
||||||
|
):
|
||||||
|
# dump each organization membership to type LiteLLM_OrganizationMembershipTable
|
||||||
|
_dumped_memberships = [
|
||||||
|
membership.model_dump()
|
||||||
|
for membership in response.organization_memberships
|
||||||
|
if membership is not None
|
||||||
|
]
|
||||||
|
response.organization_memberships = _dumped_memberships
|
||||||
|
|
||||||
_response = LiteLLM_UserTable(**dict(response))
|
_response = LiteLLM_UserTable(**dict(response))
|
||||||
response_dict = _response.model_dump()
|
response_dict = _response.model_dump()
|
||||||
|
|
||||||
|
@ -421,9 +444,9 @@ async def get_user_object(
|
||||||
await user_api_key_cache.async_set_cache(key=user_id, value=response_dict)
|
await user_api_key_cache.async_set_cache(key=user_id, value=response_dict)
|
||||||
|
|
||||||
return _response
|
return _response
|
||||||
except Exception: # if user not in db
|
except Exception as e: # if user not in db
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"User doesn't exist in db. 'user_id'={user_id}. Create user via `/user/new` call."
|
f"User doesn't exist in db. 'user_id'={user_id}. Create user via `/user/new` call. Got error - {e}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
161
litellm/proxy/auth/auth_checks_organization.py
Normal file
161
litellm/proxy/auth/auth_checks_organization.py
Normal file
|
@ -0,0 +1,161 @@
|
||||||
|
"""
|
||||||
|
Auth Checks for Organizations
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from fastapi import status
|
||||||
|
|
||||||
|
from litellm.proxy._types import *
|
||||||
|
|
||||||
|
|
||||||
|
def organization_role_based_access_check(
|
||||||
|
request_body: dict,
|
||||||
|
user_object: Optional[LiteLLM_UserTable],
|
||||||
|
route: str,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Role based access control checks only run if a user is part of an Organization
|
||||||
|
|
||||||
|
Organization Checks:
|
||||||
|
ONLY RUN IF user_object.organization_memberships is not None
|
||||||
|
|
||||||
|
1. Only Proxy Admins can access /organization/new
|
||||||
|
2. IF route is a LiteLLMRoutes.org_admin_only_routes, then check if user is an Org Admin for that organization
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
if user_object is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
passed_organization_id: Optional[str] = request_body.get("organization_id", None)
|
||||||
|
|
||||||
|
if route == "/organization/new":
|
||||||
|
if user_object.user_role != LitellmUserRoles.PROXY_ADMIN.value:
|
||||||
|
raise ProxyException(
|
||||||
|
message=f"Only proxy admins can create new organizations. You are {user_object.user_role}",
|
||||||
|
type=ProxyErrorTypes.auth_error.value,
|
||||||
|
param="user_role",
|
||||||
|
code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_object.user_role == LitellmUserRoles.PROXY_ADMIN.value:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Checks if route is an Org Admin Only Route
|
||||||
|
if route in LiteLLMRoutes.org_admin_only_routes.value:
|
||||||
|
_user_organizations, _user_organization_role_mapping = (
|
||||||
|
get_user_organization_info(user_object)
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_object.organization_memberships is None:
|
||||||
|
raise ProxyException(
|
||||||
|
message=f"Tried to access route={route} but you are not a member of any organization. Please contact the proxy admin to request access.",
|
||||||
|
type=ProxyErrorTypes.auth_error.value,
|
||||||
|
param="organization_id",
|
||||||
|
code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
|
||||||
|
if passed_organization_id is None:
|
||||||
|
raise ProxyException(
|
||||||
|
message="Passed organization_id is None, please pass an organization_id in your request",
|
||||||
|
type=ProxyErrorTypes.auth_error.value,
|
||||||
|
param="organization_id",
|
||||||
|
code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
|
||||||
|
user_role: Optional[LitellmUserRoles] = _user_organization_role_mapping.get(
|
||||||
|
passed_organization_id
|
||||||
|
)
|
||||||
|
if user_role is None:
|
||||||
|
raise ProxyException(
|
||||||
|
message=f"You do not have a role within the selected organization. Passed organization_id: {passed_organization_id}. Please contact the organization admin to request access.",
|
||||||
|
type=ProxyErrorTypes.auth_error.value,
|
||||||
|
param="organization_id",
|
||||||
|
code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_role != LitellmUserRoles.ORG_ADMIN.value:
|
||||||
|
raise ProxyException(
|
||||||
|
message=f"You do not have the required role to perform {route} in Organization {passed_organization_id}. Your role is {user_role} in Organization {passed_organization_id}",
|
||||||
|
type=ProxyErrorTypes.auth_error.value,
|
||||||
|
param="user_role",
|
||||||
|
code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
elif route == "/team/new":
|
||||||
|
# if user is part of multiple teams, then they need to specify the organization_id
|
||||||
|
_user_organizations, _user_organization_role_mapping = (
|
||||||
|
get_user_organization_info(user_object)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
user_object.organization_memberships is not None
|
||||||
|
and len(user_object.organization_memberships) > 0
|
||||||
|
):
|
||||||
|
if passed_organization_id is None:
|
||||||
|
raise ProxyException(
|
||||||
|
message=f"Passed organization_id is None, please specify the organization_id in your request. You are part of multiple organizations: {_user_organizations}",
|
||||||
|
type=ProxyErrorTypes.auth_error.value,
|
||||||
|
param="organization_id",
|
||||||
|
code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
|
||||||
|
_user_role_in_passed_org = _user_organization_role_mapping.get(
|
||||||
|
passed_organization_id
|
||||||
|
)
|
||||||
|
if _user_role_in_passed_org != LitellmUserRoles.ORG_ADMIN.value:
|
||||||
|
raise ProxyException(
|
||||||
|
message=f"You do not have the required role to call {route}. Your role is {_user_role_in_passed_org} in Organization {passed_organization_id}",
|
||||||
|
type=ProxyErrorTypes.auth_error.value,
|
||||||
|
param="user_role",
|
||||||
|
code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_organization_info(
|
||||||
|
user_object: LiteLLM_UserTable,
|
||||||
|
) -> Tuple[List[str], Dict[str, Optional[LitellmUserRoles]]]:
|
||||||
|
"""
|
||||||
|
Helper function to extract user organization information.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_object (LiteLLM_UserTable): The user object containing organization memberships.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple[List[str], Dict[str, Optional[LitellmUserRoles]]]: A tuple containing:
|
||||||
|
- List of organization IDs the user is a member of
|
||||||
|
- Dictionary mapping organization IDs to user roles
|
||||||
|
"""
|
||||||
|
_user_organizations: List[str] = []
|
||||||
|
_user_organization_role_mapping: Dict[str, Optional[LitellmUserRoles]] = {}
|
||||||
|
|
||||||
|
if user_object.organization_memberships is not None:
|
||||||
|
for _membership in user_object.organization_memberships:
|
||||||
|
if _membership.organization_id is not None:
|
||||||
|
_user_organizations.append(_membership.organization_id)
|
||||||
|
_user_organization_role_mapping[_membership.organization_id] = _membership.user_role # type: ignore
|
||||||
|
|
||||||
|
return _user_organizations, _user_organization_role_mapping
|
||||||
|
|
||||||
|
|
||||||
|
def _user_is_org_admin(
|
||||||
|
request_data: dict,
|
||||||
|
user_object: Optional[LiteLLM_UserTable] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Helper function to check if user is an org admin for the passed organization_id
|
||||||
|
"""
|
||||||
|
if request_data.get("organization_id", None) is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if user_object is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if user_object.organization_memberships is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for _membership in user_object.organization_memberships:
|
||||||
|
if _membership.organization_id == request_data.get("organization_id", None):
|
||||||
|
if _membership.user_role == LitellmUserRoles.ORG_ADMIN.value:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
|
@ -13,10 +13,11 @@ from litellm.proxy._types import (
|
||||||
)
|
)
|
||||||
from litellm.proxy.utils import hash_token
|
from litellm.proxy.utils import hash_token
|
||||||
|
|
||||||
|
from .auth_checks_organization import _user_is_org_admin
|
||||||
from .auth_utils import _has_user_setup_sso
|
from .auth_utils import _has_user_setup_sso
|
||||||
|
|
||||||
|
|
||||||
def non_admin_allowed_routes_check(
|
def non_proxy_admin_allowed_routes_check(
|
||||||
user_obj: Optional[LiteLLM_UserTable],
|
user_obj: Optional[LiteLLM_UserTable],
|
||||||
_user_role: Optional[LitellmUserRoles],
|
_user_role: Optional[LitellmUserRoles],
|
||||||
route: str,
|
route: str,
|
||||||
|
@ -26,7 +27,7 @@ def non_admin_allowed_routes_check(
|
||||||
request_data: dict,
|
request_data: dict,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Checks if Non-Admin User is allowed to access the route
|
Checks if Non Proxy Admin User is allowed to access the route
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Check user has defined custom admin routes
|
# Check user has defined custom admin routes
|
||||||
|
@ -106,6 +107,11 @@ def non_admin_allowed_routes_check(
|
||||||
and route in LiteLLMRoutes.internal_user_routes.value
|
and route in LiteLLMRoutes.internal_user_routes.value
|
||||||
):
|
):
|
||||||
pass
|
pass
|
||||||
|
elif (
|
||||||
|
_user_is_org_admin(request_data=request_data, user_object=user_obj)
|
||||||
|
and route in LiteLLMRoutes.org_admin_allowed_routes.value
|
||||||
|
):
|
||||||
|
pass
|
||||||
elif (
|
elif (
|
||||||
_user_role == LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value
|
_user_role == LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value
|
||||||
and route in LiteLLMRoutes.internal_user_view_only_routes.value
|
and route in LiteLLMRoutes.internal_user_view_only_routes.value
|
||||||
|
|
|
@ -69,7 +69,7 @@ from litellm.proxy.auth.auth_utils import (
|
||||||
)
|
)
|
||||||
from litellm.proxy.auth.oauth2_check import check_oauth2_token
|
from litellm.proxy.auth.oauth2_check import check_oauth2_token
|
||||||
from litellm.proxy.auth.oauth2_proxy_hook import handle_oauth2_proxy_request
|
from litellm.proxy.auth.oauth2_proxy_hook import handle_oauth2_proxy_request
|
||||||
from litellm.proxy.auth.route_checks import non_admin_allowed_routes_check
|
from litellm.proxy.auth.route_checks import non_proxy_admin_allowed_routes_check
|
||||||
from litellm.proxy.auth.service_account_checks import service_account_checks
|
from litellm.proxy.auth.service_account_checks import service_account_checks
|
||||||
from litellm.proxy.common_utils.http_parsing_utils import _read_request_body
|
from litellm.proxy.common_utils.http_parsing_utils import _read_request_body
|
||||||
from litellm.proxy.utils import _to_ns
|
from litellm.proxy.utils import _to_ns
|
||||||
|
@ -1042,7 +1042,7 @@ async def user_api_key_auth(
|
||||||
_user_role = _get_user_role(user_obj=user_obj)
|
_user_role = _get_user_role(user_obj=user_obj)
|
||||||
|
|
||||||
if not _is_user_proxy_admin(user_obj=user_obj): # if non-admin
|
if not _is_user_proxy_admin(user_obj=user_obj): # if non-admin
|
||||||
non_admin_allowed_routes_check(
|
non_proxy_admin_allowed_routes_check(
|
||||||
user_obj=user_obj,
|
user_obj=user_obj,
|
||||||
_user_role=_user_role,
|
_user_role=_user_role,
|
||||||
route=route,
|
route=route,
|
||||||
|
|
|
@ -63,7 +63,6 @@ async def new_user(
|
||||||
- user_id: Optional[str] - Specify a user id. If not set, a unique id will be generated.
|
- user_id: Optional[str] - Specify a user id. If not set, a unique id will be generated.
|
||||||
- user_alias: Optional[str] - A descriptive name for you to know who this user id refers to.
|
- user_alias: Optional[str] - A descriptive name for you to know who this user id refers to.
|
||||||
- teams: Optional[list] - specify a list of team id's a user belongs to.
|
- teams: Optional[list] - specify a list of team id's a user belongs to.
|
||||||
- organization_id: Optional[str] - specify the org a user belongs to.
|
|
||||||
- user_email: Optional[str] - Specify a user email.
|
- user_email: Optional[str] - Specify a user email.
|
||||||
- send_invite_email: Optional[bool] - Specify if an invite email should be sent.
|
- send_invite_email: Optional[bool] - Specify if an invite email should be sent.
|
||||||
- user_role: Optional[str] - Specify a user role - "proxy_admin", "proxy_admin_viewer", "internal_user", "internal_user_viewer", "team", "customer". Info about each role here: `https://github.com/BerriAI/litellm/litellm/proxy/_types.py#L20`
|
- user_role: Optional[str] - Specify a user role - "proxy_admin", "proxy_admin_viewer", "internal_user", "internal_user_viewer", "team", "customer". Info about each role here: `https://github.com/BerriAI/litellm/litellm/proxy/_types.py#L20`
|
||||||
|
@ -79,6 +78,18 @@ async def new_user(
|
||||||
- expires: (datetime) Datetime object for when key expires.
|
- expires: (datetime) Datetime object for when key expires.
|
||||||
- user_id: (str) Unique user id - used for tracking spend across multiple keys for same user id.
|
- user_id: (str) Unique user id - used for tracking spend across multiple keys for same user id.
|
||||||
- max_budget: (float|None) Max budget for given user.
|
- max_budget: (float|None) Max budget for given user.
|
||||||
|
|
||||||
|
Usage Example
|
||||||
|
|
||||||
|
```shell
|
||||||
|
curl -X POST "http://localhost:4000/user/new" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer sk-1234" \
|
||||||
|
-d '{
|
||||||
|
"username": "new_user",
|
||||||
|
"email": "new_user@example.com"
|
||||||
|
}'
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
from litellm.proxy.proxy_server import general_settings, proxy_logging_obj
|
from litellm.proxy.proxy_server import general_settings, proxy_logging_obj
|
||||||
|
|
||||||
|
@ -106,6 +117,7 @@ async def new_user(
|
||||||
response = await generate_key_helper_fn(request_type="user", **data_json)
|
response = await generate_key_helper_fn(request_type="user", **data_json)
|
||||||
|
|
||||||
# Admin UI Logic
|
# Admin UI Logic
|
||||||
|
# Add User to Team and Organization
|
||||||
# if team_id passed add this user to the team
|
# if team_id passed add this user to the team
|
||||||
if data_json.get("team_id", None) is not None:
|
if data_json.get("team_id", None) is not None:
|
||||||
from litellm.proxy.management_endpoints.team_endpoints import team_member_add
|
from litellm.proxy.management_endpoints.team_endpoints import team_member_add
|
||||||
|
@ -888,3 +900,49 @@ async def delete_user(
|
||||||
)
|
)
|
||||||
|
|
||||||
return deleted_users
|
return deleted_users
|
||||||
|
|
||||||
|
|
||||||
|
async def add_internal_user_to_organization(
|
||||||
|
user_id: str,
|
||||||
|
organization_id: str,
|
||||||
|
user_role: LitellmUserRoles,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Helper function to add an internal user to an organization
|
||||||
|
|
||||||
|
Adds the user to LiteLLM_OrganizationMembership table
|
||||||
|
|
||||||
|
- Checks if organization_id exists
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
- Exception if database not connected
|
||||||
|
- Exception if user_id or organization_id not found
|
||||||
|
"""
|
||||||
|
from litellm.proxy.proxy_server import prisma_client
|
||||||
|
|
||||||
|
if prisma_client is None:
|
||||||
|
raise Exception("Database not connected")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if organization_id exists
|
||||||
|
organization_row = await prisma_client.db.litellm_organizationtable.find_unique(
|
||||||
|
where={"organization_id": organization_id}
|
||||||
|
)
|
||||||
|
if organization_row is None:
|
||||||
|
raise Exception(
|
||||||
|
f"Organization not found, passed organization_id={organization_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a new organization membership entry
|
||||||
|
new_membership = await prisma_client.db.litellm_organizationmembership.create(
|
||||||
|
data={
|
||||||
|
"user_id": user_id,
|
||||||
|
"organization_id": organization_id,
|
||||||
|
"user_role": user_role,
|
||||||
|
# Note: You can also set budget within an organization if needed
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return new_membership
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Failed to add user to organization: {str(e)}")
|
||||||
|
|
433
litellm/proxy/management_endpoints/organization_endpoints.py
Normal file
433
litellm/proxy/management_endpoints/organization_endpoints.py
Normal file
|
@ -0,0 +1,433 @@
|
||||||
|
"""
|
||||||
|
Endpoints for /organization operations
|
||||||
|
|
||||||
|
/organization/new
|
||||||
|
/organization/update
|
||||||
|
/organization/delete
|
||||||
|
/organization/info
|
||||||
|
"""
|
||||||
|
|
||||||
|
#### ORGANIZATION MANAGEMENT ####
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import traceback
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
import fastapi
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
|
||||||
|
|
||||||
|
import litellm
|
||||||
|
from litellm._logging import verbose_proxy_logger
|
||||||
|
from litellm.proxy._types import *
|
||||||
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||||
|
from litellm.proxy.management_helpers.utils import (
|
||||||
|
get_new_internal_user_defaults,
|
||||||
|
management_endpoint_wrapper,
|
||||||
|
)
|
||||||
|
from litellm.proxy.utils import PrismaClient
|
||||||
|
from litellm.secret_managers.main import get_secret
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/organization/new",
|
||||||
|
tags=["organization management"],
|
||||||
|
dependencies=[Depends(user_api_key_auth)],
|
||||||
|
response_model=NewOrganizationResponse,
|
||||||
|
)
|
||||||
|
async def new_organization(
|
||||||
|
data: NewOrganizationRequest,
|
||||||
|
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Allow orgs to own teams
|
||||||
|
|
||||||
|
Set org level budgets + model access.
|
||||||
|
|
||||||
|
Only admins can create orgs.
|
||||||
|
|
||||||
|
# Parameters
|
||||||
|
|
||||||
|
- `organization_alias`: *str* = The name of the organization.
|
||||||
|
- `models`: *List* = The models the organization has access to.
|
||||||
|
- `budget_id`: *Optional[str]* = The id for a budget (tpm/rpm/max budget) for the organization.
|
||||||
|
### IF NO BUDGET ID - CREATE ONE WITH THESE PARAMS ###
|
||||||
|
- `max_budget`: *Optional[float]* = Max budget for org
|
||||||
|
- `tpm_limit`: *Optional[int]* = Max tpm limit for org
|
||||||
|
- `rpm_limit`: *Optional[int]* = Max rpm limit for org
|
||||||
|
- `model_max_budget`: *Optional[dict]* = Max budget for a specific model
|
||||||
|
- `budget_duration`: *Optional[str]* = Frequency of reseting org budget
|
||||||
|
|
||||||
|
Case 1: Create new org **without** a budget_id
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --location 'http://0.0.0.0:4000/organization/new' \
|
||||||
|
|
||||||
|
--header 'Authorization: Bearer sk-1234' \
|
||||||
|
|
||||||
|
--header 'Content-Type: application/json' \
|
||||||
|
|
||||||
|
--data '{
|
||||||
|
"organization_alias": "my-secret-org",
|
||||||
|
"models": ["model1", "model2"],
|
||||||
|
"max_budget": 100
|
||||||
|
}'
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
Case 2: Create new org **with** a budget_id
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --location 'http://0.0.0.0:4000/organization/new' \
|
||||||
|
|
||||||
|
--header 'Authorization: Bearer sk-1234' \
|
||||||
|
|
||||||
|
--header 'Content-Type: application/json' \
|
||||||
|
|
||||||
|
--data '{
|
||||||
|
"organization_alias": "my-secret-org",
|
||||||
|
"models": ["model1", "model2"],
|
||||||
|
"budget_id": "428eeaa8-f3ac-4e85-a8fb-7dc8d7aa8689"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
from litellm.proxy.proxy_server import litellm_proxy_admin_name, prisma_client
|
||||||
|
|
||||||
|
if prisma_client is None:
|
||||||
|
raise HTTPException(status_code=500, detail={"error": "No db connected"})
|
||||||
|
|
||||||
|
if (
|
||||||
|
user_api_key_dict.user_role is None
|
||||||
|
or user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail={
|
||||||
|
"error": f"Only admins can create orgs. Your role is = {user_api_key_dict.user_role}"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.budget_id is None:
|
||||||
|
"""
|
||||||
|
Every organization needs a budget attached.
|
||||||
|
|
||||||
|
If none provided, create one based on provided values
|
||||||
|
"""
|
||||||
|
budget_params = LiteLLM_BudgetTable.model_fields.keys()
|
||||||
|
|
||||||
|
# Only include Budget Params when creating an entry in litellm_budgettable
|
||||||
|
_json_data = data.json(exclude_none=True)
|
||||||
|
_budget_data = {k: v for k, v in _json_data.items() if k in budget_params}
|
||||||
|
budget_row = LiteLLM_BudgetTable(**_budget_data)
|
||||||
|
|
||||||
|
new_budget = prisma_client.jsonify_object(budget_row.json(exclude_none=True))
|
||||||
|
|
||||||
|
_budget = await prisma_client.db.litellm_budgettable.create(
|
||||||
|
data={
|
||||||
|
**new_budget, # type: ignore
|
||||||
|
"created_by": user_api_key_dict.user_id or litellm_proxy_admin_name,
|
||||||
|
"updated_by": user_api_key_dict.user_id or litellm_proxy_admin_name,
|
||||||
|
}
|
||||||
|
) # type: ignore
|
||||||
|
|
||||||
|
data.budget_id = _budget.budget_id
|
||||||
|
|
||||||
|
"""
|
||||||
|
Ensure only models that user has access to, are given to org
|
||||||
|
"""
|
||||||
|
if len(user_api_key_dict.models) == 0: # user has access to all models
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
if len(data.models) == 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={
|
||||||
|
"error": "User not allowed to give access to all models. Select models you want org to have access to."
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for m in data.models:
|
||||||
|
if m not in user_api_key_dict.models:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={
|
||||||
|
"error": f"User not allowed to give access to model={m}. Models you have access to = {user_api_key_dict.models}"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
organization_row = LiteLLM_OrganizationTable(
|
||||||
|
**data.json(exclude_none=True),
|
||||||
|
created_by=user_api_key_dict.user_id or litellm_proxy_admin_name,
|
||||||
|
updated_by=user_api_key_dict.user_id or litellm_proxy_admin_name,
|
||||||
|
)
|
||||||
|
new_organization_row = prisma_client.jsonify_object(
|
||||||
|
organization_row.json(exclude_none=True)
|
||||||
|
)
|
||||||
|
response = await prisma_client.db.litellm_organizationtable.create(
|
||||||
|
data={
|
||||||
|
**new_organization_row, # type: ignore
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/organization/update",
|
||||||
|
tags=["organization management"],
|
||||||
|
dependencies=[Depends(user_api_key_auth)],
|
||||||
|
)
|
||||||
|
async def update_organization():
|
||||||
|
"""[TODO] Not Implemented yet. Let us know if you need this - https://github.com/BerriAI/litellm/issues"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/organization/delete",
|
||||||
|
tags=["organization management"],
|
||||||
|
dependencies=[Depends(user_api_key_auth)],
|
||||||
|
)
|
||||||
|
async def delete_organization():
|
||||||
|
"""[TODO] Not Implemented yet. Let us know if you need this - https://github.com/BerriAI/litellm/issues"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/organization/info",
|
||||||
|
tags=["organization management"],
|
||||||
|
dependencies=[Depends(user_api_key_auth)],
|
||||||
|
)
|
||||||
|
async def info_organization(data: OrganizationRequest):
|
||||||
|
"""
|
||||||
|
Get the org specific information
|
||||||
|
"""
|
||||||
|
from litellm.proxy.proxy_server import prisma_client
|
||||||
|
|
||||||
|
if prisma_client is None:
|
||||||
|
raise HTTPException(status_code=500, detail={"error": "No db connected"})
|
||||||
|
|
||||||
|
if len(data.organizations) == 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={
|
||||||
|
"error": f"Specify list of organization id's to query. Passed in={data.organizations}"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response = await prisma_client.db.litellm_organizationtable.find_many(
|
||||||
|
where={"organization_id": {"in": data.organizations}},
|
||||||
|
include={"litellm_budget_table": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/organization/member_add",
|
||||||
|
tags=["organization management"],
|
||||||
|
dependencies=[Depends(user_api_key_auth)],
|
||||||
|
response_model=OrganizationAddMemberResponse,
|
||||||
|
)
|
||||||
|
@management_endpoint_wrapper
|
||||||
|
async def organization_member_add(
|
||||||
|
data: OrganizationMemberAddRequest,
|
||||||
|
http_request: Request,
|
||||||
|
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||||
|
) -> OrganizationAddMemberResponse:
|
||||||
|
"""
|
||||||
|
[BETA]
|
||||||
|
|
||||||
|
Add new members (either via user_email or user_id) to an organization
|
||||||
|
|
||||||
|
If user doesn't exist, new user row will also be added to User Table
|
||||||
|
|
||||||
|
Only proxy_admin or org_admin of organization, allowed to access this endpoint.
|
||||||
|
|
||||||
|
# Parameters:
|
||||||
|
|
||||||
|
- organization_id: str (required)
|
||||||
|
- member: Union[List[Member], Member] (required)
|
||||||
|
- role: Literal[LitellmUserRoles] (required)
|
||||||
|
- user_id: Optional[str]
|
||||||
|
- user_email: Optional[str]
|
||||||
|
|
||||||
|
Note: Either user_id or user_email must be provided for each member.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
curl -X POST 'http://0.0.0.0:4000/organization/member_add' \
|
||||||
|
-H 'Authorization: Bearer sk-1234' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"organization_id": "45e3e396-ee08-4a61-a88e-16b3ce7e0849",
|
||||||
|
"member": {
|
||||||
|
"role": "internal_user",
|
||||||
|
"user_id": "krrish247652@berri.ai"
|
||||||
|
},
|
||||||
|
"max_budget_in_organization": 100.0
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The following is executed in this function:
|
||||||
|
|
||||||
|
1. Check if organization exists
|
||||||
|
2. Creates a new Internal User if the user_id or user_email is not found in LiteLLM_UserTable
|
||||||
|
3. Add Internal User to the `LiteLLM_OrganizationMembership` table
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from litellm.proxy.proxy_server import (
|
||||||
|
litellm_proxy_admin_name,
|
||||||
|
prisma_client,
|
||||||
|
proxy_logging_obj,
|
||||||
|
user_api_key_cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
if prisma_client is None:
|
||||||
|
raise HTTPException(status_code=500, detail={"error": "No db connected"})
|
||||||
|
|
||||||
|
# Check if organization exists
|
||||||
|
existing_organization_row = (
|
||||||
|
await prisma_client.db.litellm_organizationtable.find_unique(
|
||||||
|
where={"organization_id": data.organization_id}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing_organization_row is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail={
|
||||||
|
"error": f"Organization not found for organization_id={getattr(data, 'organization_id', None)}"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
members: List[Member]
|
||||||
|
if isinstance(data.member, List):
|
||||||
|
members = data.member
|
||||||
|
else:
|
||||||
|
members = [data.member]
|
||||||
|
|
||||||
|
updated_users: List[LiteLLM_UserTable] = []
|
||||||
|
updated_organization_memberships: List[LiteLLM_OrganizationMembershipTable] = []
|
||||||
|
|
||||||
|
for member in members:
|
||||||
|
updated_user, updated_organization_membership = (
|
||||||
|
await add_member_to_organization(
|
||||||
|
member=member,
|
||||||
|
organization_id=data.organization_id,
|
||||||
|
prisma_client=prisma_client,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_users.append(updated_user)
|
||||||
|
updated_organization_memberships.append(updated_organization_membership)
|
||||||
|
|
||||||
|
return OrganizationAddMemberResponse(
|
||||||
|
organization_id=data.organization_id,
|
||||||
|
updated_users=updated_users,
|
||||||
|
updated_organization_memberships=updated_organization_memberships,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if isinstance(e, HTTPException):
|
||||||
|
raise ProxyException(
|
||||||
|
message=getattr(e, "detail", f"Authentication Error({str(e)})"),
|
||||||
|
type=ProxyErrorTypes.auth_error,
|
||||||
|
param=getattr(e, "param", "None"),
|
||||||
|
code=getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR),
|
||||||
|
)
|
||||||
|
elif isinstance(e, ProxyException):
|
||||||
|
raise e
|
||||||
|
raise ProxyException(
|
||||||
|
message="Authentication Error, " + str(e),
|
||||||
|
type=ProxyErrorTypes.auth_error,
|
||||||
|
param=getattr(e, "param", "None"),
|
||||||
|
code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def add_member_to_organization(
|
||||||
|
member: Member,
|
||||||
|
organization_id: str,
|
||||||
|
prisma_client: PrismaClient,
|
||||||
|
) -> Tuple[LiteLLM_UserTable, LiteLLM_OrganizationMembershipTable]:
|
||||||
|
"""
|
||||||
|
Add a member to an organization
|
||||||
|
|
||||||
|
- Checks if member.user_id or member.user_email is in LiteLLM_UserTable
|
||||||
|
- If not found, create a new user in LiteLLM_UserTable
|
||||||
|
- Add user to organization in LiteLLM_OrganizationMembership
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_object: Optional[LiteLLM_UserTable] = None
|
||||||
|
existing_user_id_row = None
|
||||||
|
existing_user_email_row = None
|
||||||
|
## Check if user exists in LiteLLM_UserTable - user exists - either the user_id or user_email is in LiteLLM_UserTable
|
||||||
|
if member.user_id is not None:
|
||||||
|
existing_user_id_row = await prisma_client.db.litellm_usertable.find_unique(
|
||||||
|
where={"user_id": member.user_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
if member.user_email is not None:
|
||||||
|
existing_user_email_row = (
|
||||||
|
await prisma_client.db.litellm_usertable.find_unique(
|
||||||
|
where={"user_email": member.user_email}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
## If user does not exist, create a new user
|
||||||
|
if existing_user_id_row is None and existing_user_email_row is None:
|
||||||
|
# Create a new user - since user does not exist
|
||||||
|
user_id: str = member.user_id or str(uuid.uuid4())
|
||||||
|
new_user_defaults = get_new_internal_user_defaults(
|
||||||
|
user_id=user_id,
|
||||||
|
user_email=member.user_email,
|
||||||
|
)
|
||||||
|
|
||||||
|
_returned_user = await prisma_client.insert_data(data=new_user_defaults, table_name="user") # type: ignore
|
||||||
|
if _returned_user is not None:
|
||||||
|
user_object = LiteLLM_UserTable(**_returned_user.model_dump())
|
||||||
|
elif existing_user_email_row is not None and len(existing_user_email_row) > 1:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={
|
||||||
|
"error": "Multiple users with this email found in db. Please use 'user_id' instead."
|
||||||
|
},
|
||||||
|
)
|
||||||
|
elif existing_user_email_row is not None:
|
||||||
|
user_object = LiteLLM_UserTable(**existing_user_email_row.model_dump())
|
||||||
|
elif existing_user_id_row is not None:
|
||||||
|
user_object = LiteLLM_UserTable(**existing_user_id_row.model_dump())
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail={
|
||||||
|
"error": f"User not found for user_id={member.user_id} and user_email={member.user_email}"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_object is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"User does not exist in LiteLLM_UserTable. user_id={member.user_id} and user_email={member.user_email}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add user to organization
|
||||||
|
_organization_membership = (
|
||||||
|
await prisma_client.db.litellm_organizationmembership.create(
|
||||||
|
data={
|
||||||
|
"organization_id": organization_id,
|
||||||
|
"user_id": user_object.user_id,
|
||||||
|
"user_role": member.role,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
organization_membership = LiteLLM_OrganizationMembershipTable(
|
||||||
|
**_organization_membership.model_dump()
|
||||||
|
)
|
||||||
|
return user_object, organization_membership
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Error adding member to organization: {e}")
|
|
@ -185,6 +185,9 @@ from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||||
router as key_management_router,
|
router as key_management_router,
|
||||||
)
|
)
|
||||||
|
from litellm.proxy.management_endpoints.organization_endpoints import (
|
||||||
|
router as organization_router,
|
||||||
|
)
|
||||||
from litellm.proxy.management_endpoints.team_callback_endpoints import (
|
from litellm.proxy.management_endpoints.team_callback_endpoints import (
|
||||||
router as team_callback_router,
|
router as team_callback_router,
|
||||||
)
|
)
|
||||||
|
@ -6313,200 +6316,6 @@ async def create_audit_log_for_update(request_data: LiteLLM_AuditLogs):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
#### ORGANIZATION MANAGEMENT ####
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/organization/new",
|
|
||||||
tags=["organization management"],
|
|
||||||
dependencies=[Depends(user_api_key_auth)],
|
|
||||||
response_model=NewOrganizationResponse,
|
|
||||||
)
|
|
||||||
async def new_organization(
|
|
||||||
data: NewOrganizationRequest,
|
|
||||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Allow orgs to own teams
|
|
||||||
|
|
||||||
Set org level budgets + model access.
|
|
||||||
|
|
||||||
Only admins can create orgs.
|
|
||||||
|
|
||||||
# Parameters
|
|
||||||
|
|
||||||
- `organization_alias`: *str* = The name of the organization.
|
|
||||||
- `models`: *List* = The models the organization has access to.
|
|
||||||
- `budget_id`: *Optional[str]* = The id for a budget (tpm/rpm/max budget) for the organization.
|
|
||||||
### IF NO BUDGET ID - CREATE ONE WITH THESE PARAMS ###
|
|
||||||
- `max_budget`: *Optional[float]* = Max budget for org
|
|
||||||
- `tpm_limit`: *Optional[int]* = Max tpm limit for org
|
|
||||||
- `rpm_limit`: *Optional[int]* = Max rpm limit for org
|
|
||||||
- `model_max_budget`: *Optional[dict]* = Max budget for a specific model
|
|
||||||
- `budget_duration`: *Optional[str]* = Frequency of reseting org budget
|
|
||||||
|
|
||||||
Case 1: Create new org **without** a budget_id
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl --location 'http://0.0.0.0:4000/organization/new' \
|
|
||||||
|
|
||||||
--header 'Authorization: Bearer sk-1234' \
|
|
||||||
|
|
||||||
--header 'Content-Type: application/json' \
|
|
||||||
|
|
||||||
--data '{
|
|
||||||
"organization_alias": "my-secret-org",
|
|
||||||
"models": ["model1", "model2"],
|
|
||||||
"max_budget": 100
|
|
||||||
}'
|
|
||||||
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
Case 2: Create new org **with** a budget_id
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl --location 'http://0.0.0.0:4000/organization/new' \
|
|
||||||
|
|
||||||
--header 'Authorization: Bearer sk-1234' \
|
|
||||||
|
|
||||||
--header 'Content-Type: application/json' \
|
|
||||||
|
|
||||||
--data '{
|
|
||||||
"organization_alias": "my-secret-org",
|
|
||||||
"models": ["model1", "model2"],
|
|
||||||
"budget_id": "428eeaa8-f3ac-4e85-a8fb-7dc8d7aa8689"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
global prisma_client
|
|
||||||
|
|
||||||
if prisma_client is None:
|
|
||||||
raise HTTPException(status_code=500, detail={"error": "No db connected"})
|
|
||||||
|
|
||||||
if (
|
|
||||||
user_api_key_dict.user_role is None
|
|
||||||
or user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN
|
|
||||||
):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=401,
|
|
||||||
detail={
|
|
||||||
"error": f"Only admins can create orgs. Your role is = {user_api_key_dict.user_role}"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if data.budget_id is None:
|
|
||||||
"""
|
|
||||||
Every organization needs a budget attached.
|
|
||||||
|
|
||||||
If none provided, create one based on provided values
|
|
||||||
"""
|
|
||||||
budget_params = LiteLLM_BudgetTable.model_fields.keys()
|
|
||||||
|
|
||||||
# Only include Budget Params when creating an entry in litellm_budgettable
|
|
||||||
_json_data = data.json(exclude_none=True)
|
|
||||||
_budget_data = {k: v for k, v in _json_data.items() if k in budget_params}
|
|
||||||
budget_row = LiteLLM_BudgetTable(**_budget_data)
|
|
||||||
|
|
||||||
new_budget = prisma_client.jsonify_object(budget_row.json(exclude_none=True))
|
|
||||||
|
|
||||||
_budget = await prisma_client.db.litellm_budgettable.create(
|
|
||||||
data={
|
|
||||||
**new_budget, # type: ignore
|
|
||||||
"created_by": user_api_key_dict.user_id or litellm_proxy_admin_name,
|
|
||||||
"updated_by": user_api_key_dict.user_id or litellm_proxy_admin_name,
|
|
||||||
}
|
|
||||||
) # type: ignore
|
|
||||||
|
|
||||||
data.budget_id = _budget.budget_id
|
|
||||||
|
|
||||||
"""
|
|
||||||
Ensure only models that user has access to, are given to org
|
|
||||||
"""
|
|
||||||
if len(user_api_key_dict.models) == 0: # user has access to all models
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
if len(data.models) == 0:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail={
|
|
||||||
"error": "User not allowed to give access to all models. Select models you want org to have access to."
|
|
||||||
},
|
|
||||||
)
|
|
||||||
for m in data.models:
|
|
||||||
if m not in user_api_key_dict.models:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail={
|
|
||||||
"error": f"User not allowed to give access to model={m}. Models you have access to = {user_api_key_dict.models}"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
organization_row = LiteLLM_OrganizationTable(
|
|
||||||
**data.json(exclude_none=True),
|
|
||||||
created_by=user_api_key_dict.user_id or litellm_proxy_admin_name,
|
|
||||||
updated_by=user_api_key_dict.user_id or litellm_proxy_admin_name,
|
|
||||||
)
|
|
||||||
new_organization_row = prisma_client.jsonify_object(
|
|
||||||
organization_row.json(exclude_none=True)
|
|
||||||
)
|
|
||||||
response = await prisma_client.db.litellm_organizationtable.create(
|
|
||||||
data={
|
|
||||||
**new_organization_row, # type: ignore
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/organization/update",
|
|
||||||
tags=["organization management"],
|
|
||||||
dependencies=[Depends(user_api_key_auth)],
|
|
||||||
)
|
|
||||||
async def update_organization():
|
|
||||||
"""[TODO] Not Implemented yet. Let us know if you need this - https://github.com/BerriAI/litellm/issues"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/organization/delete",
|
|
||||||
tags=["organization management"],
|
|
||||||
dependencies=[Depends(user_api_key_auth)],
|
|
||||||
)
|
|
||||||
async def delete_organization():
|
|
||||||
"""[TODO] Not Implemented yet. Let us know if you need this - https://github.com/BerriAI/litellm/issues"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/organization/info",
|
|
||||||
tags=["organization management"],
|
|
||||||
dependencies=[Depends(user_api_key_auth)],
|
|
||||||
)
|
|
||||||
async def info_organization(data: OrganizationRequest):
|
|
||||||
"""
|
|
||||||
Get the org specific information
|
|
||||||
"""
|
|
||||||
global prisma_client
|
|
||||||
|
|
||||||
if prisma_client is None:
|
|
||||||
raise HTTPException(status_code=500, detail={"error": "No db connected"})
|
|
||||||
|
|
||||||
if len(data.organizations) == 0:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail={
|
|
||||||
"error": f"Specify list of organization id's to query. Passed in={data.organizations}"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
response = await prisma_client.db.litellm_organizationtable.find_many(
|
|
||||||
where={"organization_id": {"in": data.organizations}},
|
|
||||||
include={"litellm_budget_table": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
#### BUDGET TABLE MANAGEMENT ####
|
#### BUDGET TABLE MANAGEMENT ####
|
||||||
|
|
||||||
|
|
||||||
|
@ -8181,6 +7990,14 @@ async def login(request: Request):
|
||||||
|
|
||||||
# check if we can find the `username` in the db. on the ui, users can enter username=their email
|
# check if we can find the `username` in the db. on the ui, users can enter username=their email
|
||||||
_user_row = None
|
_user_row = None
|
||||||
|
user_role: Optional[
|
||||||
|
Literal[
|
||||||
|
LitellmUserRoles.PROXY_ADMIN,
|
||||||
|
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
|
||||||
|
LitellmUserRoles.INTERNAL_USER,
|
||||||
|
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
|
||||||
|
]
|
||||||
|
] = None
|
||||||
if prisma_client is not None:
|
if prisma_client is not None:
|
||||||
_user_row = await prisma_client.db.litellm_usertable.find_first(
|
_user_row = await prisma_client.db.litellm_usertable.find_first(
|
||||||
where={"user_email": {"equals": username}}
|
where={"user_email": {"equals": username}}
|
||||||
|
@ -9654,6 +9471,7 @@ app.include_router(key_management_router)
|
||||||
app.include_router(internal_user_router)
|
app.include_router(internal_user_router)
|
||||||
app.include_router(team_router)
|
app.include_router(team_router)
|
||||||
app.include_router(ui_sso_router)
|
app.include_router(ui_sso_router)
|
||||||
|
app.include_router(organization_router)
|
||||||
app.include_router(spend_management_router)
|
app.include_router(spend_management_router)
|
||||||
app.include_router(caching_router)
|
app.include_router(caching_router)
|
||||||
app.include_router(analytics_router)
|
app.include_router(analytics_router)
|
||||||
|
|
|
@ -26,6 +26,7 @@ model LiteLLM_BudgetTable {
|
||||||
keys LiteLLM_VerificationToken[] // multiple keys can have the same budget
|
keys LiteLLM_VerificationToken[] // multiple keys can have the same budget
|
||||||
end_users LiteLLM_EndUserTable[] // multiple end-users can have the same budget
|
end_users LiteLLM_EndUserTable[] // multiple end-users can have the same budget
|
||||||
team_membership LiteLLM_TeamMembership[] // budgets of Users within a Team
|
team_membership LiteLLM_TeamMembership[] // budgets of Users within a Team
|
||||||
|
organization_membership LiteLLM_OrganizationMembership[] // budgets of Users within a Organization
|
||||||
}
|
}
|
||||||
|
|
||||||
// Models on proxy
|
// Models on proxy
|
||||||
|
@ -118,7 +119,10 @@ model LiteLLM_UserTable {
|
||||||
allowed_cache_controls String[] @default([])
|
allowed_cache_controls String[] @default([])
|
||||||
model_spend Json @default("{}")
|
model_spend Json @default("{}")
|
||||||
model_max_budget Json @default("{}")
|
model_max_budget Json @default("{}")
|
||||||
|
|
||||||
|
// relations
|
||||||
litellm_organization_table LiteLLM_OrganizationTable? @relation(fields: [organization_id], references: [organization_id])
|
litellm_organization_table LiteLLM_OrganizationTable? @relation(fields: [organization_id], references: [organization_id])
|
||||||
|
organization_memberships LiteLLM_OrganizationMembership[]
|
||||||
invitations_created LiteLLM_InvitationLink[] @relation("CreatedBy")
|
invitations_created LiteLLM_InvitationLink[] @relation("CreatedBy")
|
||||||
invitations_updated LiteLLM_InvitationLink[] @relation("UpdatedBy")
|
invitations_updated LiteLLM_InvitationLink[] @relation("UpdatedBy")
|
||||||
invitations_user LiteLLM_InvitationLink[] @relation("UserId")
|
invitations_user LiteLLM_InvitationLink[] @relation("UserId")
|
||||||
|
@ -232,6 +236,24 @@ model LiteLLM_TeamMembership {
|
||||||
@@id([user_id, team_id])
|
@@id([user_id, team_id])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model LiteLLM_OrganizationMembership {
|
||||||
|
// Use this table to track Internal User and Organization membership. Helps tracking a users role within an Organization
|
||||||
|
user_id String?
|
||||||
|
organization_id String?
|
||||||
|
user_role String?
|
||||||
|
spend Float? @default(0.0)
|
||||||
|
budget_id String?
|
||||||
|
created_at DateTime? @default(now()) @map("created_at")
|
||||||
|
updated_at DateTime? @default(now()) @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
// relations
|
||||||
|
user LiteLLM_UserTable @relation(fields: [user_id], references: [user_id])
|
||||||
|
litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id])
|
||||||
|
|
||||||
|
@@id([user_id, organization_id])
|
||||||
|
@@unique([user_id, organization_id])
|
||||||
|
}
|
||||||
|
|
||||||
model LiteLLM_InvitationLink {
|
model LiteLLM_InvitationLink {
|
||||||
// use this table to track invite links sent by admin for people to join the proxy
|
// use this table to track invite links sent by admin for people to join the proxy
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
|
|
|
@ -26,6 +26,7 @@ model LiteLLM_BudgetTable {
|
||||||
keys LiteLLM_VerificationToken[] // multiple keys can have the same budget
|
keys LiteLLM_VerificationToken[] // multiple keys can have the same budget
|
||||||
end_users LiteLLM_EndUserTable[] // multiple end-users can have the same budget
|
end_users LiteLLM_EndUserTable[] // multiple end-users can have the same budget
|
||||||
team_membership LiteLLM_TeamMembership[] // budgets of Users within a Team
|
team_membership LiteLLM_TeamMembership[] // budgets of Users within a Team
|
||||||
|
organization_membership LiteLLM_OrganizationMembership[] // budgets of Users within a Organization
|
||||||
}
|
}
|
||||||
|
|
||||||
// Models on proxy
|
// Models on proxy
|
||||||
|
@ -118,7 +119,10 @@ model LiteLLM_UserTable {
|
||||||
allowed_cache_controls String[] @default([])
|
allowed_cache_controls String[] @default([])
|
||||||
model_spend Json @default("{}")
|
model_spend Json @default("{}")
|
||||||
model_max_budget Json @default("{}")
|
model_max_budget Json @default("{}")
|
||||||
|
|
||||||
|
// relations
|
||||||
litellm_organization_table LiteLLM_OrganizationTable? @relation(fields: [organization_id], references: [organization_id])
|
litellm_organization_table LiteLLM_OrganizationTable? @relation(fields: [organization_id], references: [organization_id])
|
||||||
|
organization_memberships LiteLLM_OrganizationMembership[]
|
||||||
invitations_created LiteLLM_InvitationLink[] @relation("CreatedBy")
|
invitations_created LiteLLM_InvitationLink[] @relation("CreatedBy")
|
||||||
invitations_updated LiteLLM_InvitationLink[] @relation("UpdatedBy")
|
invitations_updated LiteLLM_InvitationLink[] @relation("UpdatedBy")
|
||||||
invitations_user LiteLLM_InvitationLink[] @relation("UserId")
|
invitations_user LiteLLM_InvitationLink[] @relation("UserId")
|
||||||
|
@ -232,6 +236,24 @@ model LiteLLM_TeamMembership {
|
||||||
@@id([user_id, team_id])
|
@@id([user_id, team_id])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model LiteLLM_OrganizationMembership {
|
||||||
|
// Use this table to track Internal User and Organization membership. Helps tracking a users role within an Organization
|
||||||
|
user_id String?
|
||||||
|
organization_id String?
|
||||||
|
user_role String?
|
||||||
|
spend Float? @default(0.0)
|
||||||
|
budget_id String?
|
||||||
|
created_at DateTime? @default(now()) @map("created_at")
|
||||||
|
updated_at DateTime? @default(now()) @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
// relations
|
||||||
|
user LiteLLM_UserTable @relation(fields: [user_id], references: [user_id])
|
||||||
|
litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id])
|
||||||
|
|
||||||
|
@@id([user_id, organization_id])
|
||||||
|
@@unique([user_id, organization_id])
|
||||||
|
}
|
||||||
|
|
||||||
model LiteLLM_InvitationLink {
|
model LiteLLM_InvitationLink {
|
||||||
// use this table to track invite links sent by admin for people to join the proxy
|
// use this table to track invite links sent by admin for people to join the proxy
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
|
|
439
tests/proxy_admin_ui_tests/test_role_based_access.py
Normal file
439
tests/proxy_admin_ui_tests/test_role_based_access.py
Normal file
|
@ -0,0 +1,439 @@
|
||||||
|
"""
|
||||||
|
RBAC tests
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
# this file is to test litellm/proxy
|
||||||
|
|
||||||
|
sys.path.insert(
|
||||||
|
0, os.path.abspath("../..")
|
||||||
|
) # Adds the parent directory to the system path
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import litellm
|
||||||
|
from litellm._logging import verbose_proxy_logger
|
||||||
|
from litellm.proxy.auth.auth_checks import get_user_object
|
||||||
|
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||||
|
delete_key_fn,
|
||||||
|
generate_key_fn,
|
||||||
|
generate_key_helper_fn,
|
||||||
|
info_key_fn,
|
||||||
|
regenerate_key_fn,
|
||||||
|
update_key_fn,
|
||||||
|
)
|
||||||
|
from litellm.proxy.management_endpoints.internal_user_endpoints import new_user
|
||||||
|
from litellm.proxy.management_endpoints.organization_endpoints import (
|
||||||
|
new_organization,
|
||||||
|
organization_member_add,
|
||||||
|
)
|
||||||
|
|
||||||
|
from litellm.proxy.management_endpoints.team_endpoints import (
|
||||||
|
new_team,
|
||||||
|
team_info,
|
||||||
|
update_team,
|
||||||
|
)
|
||||||
|
from litellm.proxy.proxy_server import (
|
||||||
|
LitellmUserRoles,
|
||||||
|
audio_transcriptions,
|
||||||
|
chat_completion,
|
||||||
|
completion,
|
||||||
|
embeddings,
|
||||||
|
image_generation,
|
||||||
|
model_list,
|
||||||
|
moderations,
|
||||||
|
new_end_user,
|
||||||
|
user_api_key_auth,
|
||||||
|
)
|
||||||
|
from litellm.proxy.spend_tracking.spend_management_endpoints import (
|
||||||
|
global_spend,
|
||||||
|
global_spend_logs,
|
||||||
|
global_spend_models,
|
||||||
|
global_spend_keys,
|
||||||
|
spend_key_fn,
|
||||||
|
spend_user_fn,
|
||||||
|
view_spend_logs,
|
||||||
|
)
|
||||||
|
from starlette.datastructures import URL
|
||||||
|
|
||||||
|
from litellm.proxy.utils import PrismaClient, ProxyLogging, hash_token, update_spend
|
||||||
|
|
||||||
|
verbose_proxy_logger.setLevel(level=logging.DEBUG)
|
||||||
|
|
||||||
|
from starlette.datastructures import URL
|
||||||
|
|
||||||
|
from litellm.caching import DualCache
|
||||||
|
from litellm.proxy._types import *
|
||||||
|
|
||||||
|
proxy_logging_obj = ProxyLogging(user_api_key_cache=DualCache())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def prisma_client():
|
||||||
|
from litellm.proxy.proxy_cli import append_query_params
|
||||||
|
|
||||||
|
### add connection pool + pool timeout args
|
||||||
|
params = {"connection_limit": 100, "pool_timeout": 60}
|
||||||
|
database_url = os.getenv("DATABASE_URL")
|
||||||
|
modified_url = append_query_params(database_url, params)
|
||||||
|
os.environ["DATABASE_URL"] = modified_url
|
||||||
|
|
||||||
|
# Assuming PrismaClient is a class that needs to be instantiated
|
||||||
|
prisma_client = PrismaClient(
|
||||||
|
database_url=os.environ["DATABASE_URL"], proxy_logging_obj=proxy_logging_obj
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reset litellm.proxy.proxy_server.prisma_client to None
|
||||||
|
litellm.proxy.proxy_server.litellm_proxy_budget_name = (
|
||||||
|
f"litellm-proxy-budget-{time.time()}"
|
||||||
|
)
|
||||||
|
litellm.proxy.proxy_server.user_custom_key_generate = None
|
||||||
|
|
||||||
|
return prisma_client
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
RBAC Tests
|
||||||
|
|
||||||
|
1. Add a user to an organization
|
||||||
|
- test 1 - if organization_id does exist expect to create a new user and user, organization relation
|
||||||
|
|
||||||
|
2. org admin creates team in his org → success
|
||||||
|
|
||||||
|
3. org admin adds new internal user to his org → success
|
||||||
|
|
||||||
|
4. org admin creates team and internal user not in his org → fail both
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"user_role",
|
||||||
|
[
|
||||||
|
LitellmUserRoles.ORG_ADMIN,
|
||||||
|
LitellmUserRoles.INTERNAL_USER,
|
||||||
|
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_create_new_user_in_organization(prisma_client, user_role):
|
||||||
|
"""
|
||||||
|
|
||||||
|
Add a member to an organization and assert the user object is created with the correct organization memberships / roles
|
||||||
|
"""
|
||||||
|
master_key = "sk-1234"
|
||||||
|
setattr(litellm.proxy.proxy_server, "prisma_client", prisma_client)
|
||||||
|
setattr(litellm.proxy.proxy_server, "master_key", master_key)
|
||||||
|
|
||||||
|
await litellm.proxy.proxy_server.prisma_client.connect()
|
||||||
|
|
||||||
|
created_user_id = f"new-user-{uuid.uuid4()}"
|
||||||
|
|
||||||
|
response = await new_organization(
|
||||||
|
data=NewOrganizationRequest(
|
||||||
|
organization_alias=f"new-org-{uuid.uuid4()}",
|
||||||
|
),
|
||||||
|
user_api_key_dict=UserAPIKeyAuth(
|
||||||
|
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
org_id = response.organization_id
|
||||||
|
|
||||||
|
response = await organization_member_add(
|
||||||
|
data=OrganizationMemberAddRequest(
|
||||||
|
organization_id=org_id,
|
||||||
|
member=Member(role=user_role, user_id=created_user_id),
|
||||||
|
),
|
||||||
|
http_request=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("new user response", response)
|
||||||
|
|
||||||
|
# call get_user_object
|
||||||
|
|
||||||
|
user_object = await get_user_object(
|
||||||
|
user_id=created_user_id,
|
||||||
|
prisma_client=prisma_client,
|
||||||
|
user_api_key_cache=DualCache(),
|
||||||
|
user_id_upsert=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("user object", user_object)
|
||||||
|
|
||||||
|
assert user_object.organization_memberships is not None
|
||||||
|
|
||||||
|
_membership = user_object.organization_memberships[0]
|
||||||
|
|
||||||
|
assert _membership.user_id == created_user_id
|
||||||
|
assert _membership.organization_id == org_id
|
||||||
|
|
||||||
|
if user_role != None:
|
||||||
|
assert _membership.user_role == user_role
|
||||||
|
else:
|
||||||
|
assert _membership.user_role == LitellmUserRoles.INTERNAL_USER_VIEW_ONLY
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_org_admin_create_team_permissions(prisma_client):
|
||||||
|
"""
|
||||||
|
Create a new org admin
|
||||||
|
|
||||||
|
org admin creates a new team in their org -> success
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
master_key = "sk-1234"
|
||||||
|
setattr(litellm.proxy.proxy_server, "prisma_client", prisma_client)
|
||||||
|
setattr(litellm.proxy.proxy_server, "master_key", master_key)
|
||||||
|
|
||||||
|
await litellm.proxy.proxy_server.prisma_client.connect()
|
||||||
|
|
||||||
|
response = await new_organization(
|
||||||
|
data=NewOrganizationRequest(
|
||||||
|
organization_alias=f"new-org-{uuid.uuid4()}",
|
||||||
|
),
|
||||||
|
user_api_key_dict=UserAPIKeyAuth(
|
||||||
|
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
org_id = response.organization_id
|
||||||
|
created_user_id = f"new-user-{uuid.uuid4()}"
|
||||||
|
response = await organization_member_add(
|
||||||
|
data=OrganizationMemberAddRequest(
|
||||||
|
organization_id=org_id,
|
||||||
|
member=Member(role=LitellmUserRoles.ORG_ADMIN, user_id=created_user_id),
|
||||||
|
),
|
||||||
|
http_request=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# create key with the response["user_id"]
|
||||||
|
# proxy admin will generate key for org admin
|
||||||
|
_new_key = await generate_key_fn(
|
||||||
|
data=GenerateKeyRequest(user_id=created_user_id),
|
||||||
|
user_api_key_dict=UserAPIKeyAuth(user_id=created_user_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
new_key = _new_key.key
|
||||||
|
|
||||||
|
print("user api key auth response", response)
|
||||||
|
|
||||||
|
# Create /team/new request -> expect auth to pass
|
||||||
|
request = Request(scope={"type": "http"})
|
||||||
|
request._url = URL(url="/team/new")
|
||||||
|
|
||||||
|
async def return_body():
|
||||||
|
body = {"organization_id": org_id}
|
||||||
|
return bytes(json.dumps(body), "utf-8")
|
||||||
|
|
||||||
|
request.body = return_body
|
||||||
|
response = await user_api_key_auth(request=request, api_key="Bearer " + new_key)
|
||||||
|
|
||||||
|
# after auth - actually create team now
|
||||||
|
response = await new_team(
|
||||||
|
data=NewTeamRequest(
|
||||||
|
organization_id=org_id,
|
||||||
|
),
|
||||||
|
http_request=request,
|
||||||
|
user_api_key_dict=UserAPIKeyAuth(
|
||||||
|
user_id=response.user_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
print("response from new team")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_org_admin_create_user_permissions(prisma_client):
|
||||||
|
"""
|
||||||
|
1. Create a new org admin
|
||||||
|
|
||||||
|
2. org admin adds a new member to their org -> success (using using /organization/member_add)
|
||||||
|
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
master_key = "sk-1234"
|
||||||
|
setattr(litellm.proxy.proxy_server, "prisma_client", prisma_client)
|
||||||
|
setattr(litellm.proxy.proxy_server, "master_key", master_key)
|
||||||
|
|
||||||
|
await litellm.proxy.proxy_server.prisma_client.connect()
|
||||||
|
|
||||||
|
# create new org
|
||||||
|
response = await new_organization(
|
||||||
|
data=NewOrganizationRequest(
|
||||||
|
organization_alias=f"new-org-{uuid.uuid4()}",
|
||||||
|
),
|
||||||
|
user_api_key_dict=UserAPIKeyAuth(
|
||||||
|
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Create Org Admin
|
||||||
|
org_id = response.organization_id
|
||||||
|
created_user_id = f"new-user-{uuid.uuid4()}"
|
||||||
|
response = await organization_member_add(
|
||||||
|
data=OrganizationMemberAddRequest(
|
||||||
|
organization_id=org_id,
|
||||||
|
member=Member(role=LitellmUserRoles.ORG_ADMIN, user_id=created_user_id),
|
||||||
|
),
|
||||||
|
http_request=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# create key with for Org Admin
|
||||||
|
_new_key = await generate_key_fn(
|
||||||
|
data=GenerateKeyRequest(user_id=created_user_id),
|
||||||
|
user_api_key_dict=UserAPIKeyAuth(user_id=created_user_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
new_key = _new_key.key
|
||||||
|
|
||||||
|
print("user api key auth response", response)
|
||||||
|
|
||||||
|
# Create /organization/member_add request -> expect auth to pass
|
||||||
|
request = Request(scope={"type": "http"})
|
||||||
|
request._url = URL(url="/organization/member_add")
|
||||||
|
|
||||||
|
async def return_body():
|
||||||
|
body = {"organization_id": org_id}
|
||||||
|
return bytes(json.dumps(body), "utf-8")
|
||||||
|
|
||||||
|
request.body = return_body
|
||||||
|
response = await user_api_key_auth(request=request, api_key="Bearer " + new_key)
|
||||||
|
|
||||||
|
# after auth - actually actually add new user to organization
|
||||||
|
new_internal_user_for_org = f"new-org-user-{uuid.uuid4()}"
|
||||||
|
response = await organization_member_add(
|
||||||
|
data=OrganizationMemberAddRequest(
|
||||||
|
organization_id=org_id,
|
||||||
|
member=Member(
|
||||||
|
role=LitellmUserRoles.INTERNAL_USER, user_id=new_internal_user_for_org
|
||||||
|
),
|
||||||
|
),
|
||||||
|
http_request=request,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("response from new team")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_org_admin_create_user_team_wrong_org_permissions(prisma_client):
|
||||||
|
"""
|
||||||
|
Create a new org admin
|
||||||
|
|
||||||
|
org admin creates a new user and new team in orgs they are not part of -> expect error
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
master_key = "sk-1234"
|
||||||
|
setattr(litellm.proxy.proxy_server, "prisma_client", prisma_client)
|
||||||
|
setattr(litellm.proxy.proxy_server, "master_key", master_key)
|
||||||
|
|
||||||
|
await litellm.proxy.proxy_server.prisma_client.connect()
|
||||||
|
created_user_id = f"new-user-{uuid.uuid4()}"
|
||||||
|
response = await new_organization(
|
||||||
|
data=NewOrganizationRequest(
|
||||||
|
organization_alias=f"new-org-{uuid.uuid4()}",
|
||||||
|
),
|
||||||
|
user_api_key_dict=UserAPIKeyAuth(
|
||||||
|
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
response2 = await new_organization(
|
||||||
|
data=NewOrganizationRequest(
|
||||||
|
organization_alias=f"new-org-{uuid.uuid4()}",
|
||||||
|
),
|
||||||
|
user_api_key_dict=UserAPIKeyAuth(
|
||||||
|
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
org1_id = response.organization_id # has an admin
|
||||||
|
|
||||||
|
org2_id = response2.organization_id # does not have an org admin
|
||||||
|
|
||||||
|
# Create Org Admin for Org1
|
||||||
|
created_user_id = f"new-user-{uuid.uuid4()}"
|
||||||
|
response = await organization_member_add(
|
||||||
|
data=OrganizationMemberAddRequest(
|
||||||
|
organization_id=org1_id,
|
||||||
|
member=Member(role=LitellmUserRoles.ORG_ADMIN, user_id=created_user_id),
|
||||||
|
),
|
||||||
|
http_request=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
_new_key = await generate_key_fn(
|
||||||
|
data=GenerateKeyRequest(
|
||||||
|
user_id=created_user_id,
|
||||||
|
),
|
||||||
|
user_api_key_dict=UserAPIKeyAuth(
|
||||||
|
user_role=LitellmUserRoles.ORG_ADMIN,
|
||||||
|
user_id=created_user_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
new_key = _new_key.key
|
||||||
|
|
||||||
|
print("user api key auth response", response)
|
||||||
|
|
||||||
|
# Add a new request in organization=org_without_admins -> expect fail (organization/member_add)
|
||||||
|
request = Request(scope={"type": "http"})
|
||||||
|
request._url = URL(url="/organization/member_add")
|
||||||
|
|
||||||
|
async def return_body():
|
||||||
|
body = {"organization_id": org2_id}
|
||||||
|
return bytes(json.dumps(body), "utf-8")
|
||||||
|
|
||||||
|
request.body = return_body
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await user_api_key_auth(request=request, api_key="Bearer " + new_key)
|
||||||
|
pytest.fail(
|
||||||
|
f"This should have failed!. creating a user in an org without admins"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print("got exception", e)
|
||||||
|
print("exception.message", e.message)
|
||||||
|
assert (
|
||||||
|
"You do not have a role within the selected organization. Passed organization_id"
|
||||||
|
in e.message
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create /team/new request in organization=org_without_admins -> expect fail
|
||||||
|
request = Request(scope={"type": "http"})
|
||||||
|
request._url = URL(url="/team/new")
|
||||||
|
|
||||||
|
async def return_body():
|
||||||
|
body = {"organization_id": org2_id}
|
||||||
|
return bytes(json.dumps(body), "utf-8")
|
||||||
|
|
||||||
|
request.body = return_body
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await user_api_key_auth(request=request, api_key="Bearer " + new_key)
|
||||||
|
pytest.fail(
|
||||||
|
f"This should have failed!. Org Admin creating a team in an org where they are not an admin"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print("got exception", e)
|
||||||
|
print("exception.message", e.message)
|
||||||
|
assert (
|
||||||
|
"You do not have the required role to call" in e.message
|
||||||
|
and org2_id in e.message
|
||||||
|
)
|
|
@ -33,7 +33,7 @@ async def new_organization(session, i, organization_alias, max_budget=None):
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_organization_new():
|
async def test_organization_new():
|
||||||
"""
|
"""
|
||||||
Make 20 parallel calls to /user/new. Assert all worked.
|
Make 20 parallel calls to /organization/new. Assert all worked.
|
||||||
"""
|
"""
|
||||||
organization_alias = f"Organization: {uuid.uuid4()}"
|
organization_alias = f"Organization: {uuid.uuid4()}"
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue