mirror of
https://github.com/BerriAI/litellm.git
synced 2025-04-24 18:24:20 +00:00
[Feat SSO] Add LiteLLM SCIM Integration for Team and User management (#10072)
* fix NewUser response type * add scim router * add v0 scim v2 endpoints * working scim transformation * use 1 file for types * fix scim firstname and givenName storage * working SCIMErrorResponse * working team / group provisioning on SCIM * add SCIMPatchOp * move scim folder * fix import scim_router * fix dont auto create scim keys * add auth on all scim endpoints * add is_virtual_key_allowed_to_call_route * fix allowed routes * fix for key management * fix allowed routes check * clean up error message * fix code check * fix for route checks * ui SCIM support * add UI tab for SCIM * fixes SCIM * fixes for SCIM settings on ui * scim settings * clean up scim view * add migration for allowed_routes in keys table * refactor scim transform * fix SCIM linting error * fix code quality check * fix ui linting * test_scim_transformations.py
This commit is contained in:
parent
7ca553b235
commit
6220f3e7b8
19 changed files with 2512 additions and 131 deletions
|
@ -0,0 +1,3 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_VerificationToken" ADD COLUMN "allowed_routes" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
|
@ -169,6 +169,7 @@ model LiteLLM_VerificationToken {
|
|||
budget_duration String?
|
||||
budget_reset_at DateTime?
|
||||
allowed_cache_controls String[] @default([])
|
||||
allowed_routes String[] @default([])
|
||||
model_spend Json @default("{}")
|
||||
model_max_budget Json @default("{}")
|
||||
budget_id String?
|
||||
|
|
|
@ -650,9 +650,9 @@ class GenerateRequestBase(LiteLLMPydanticObjectBase):
|
|||
allowed_cache_controls: Optional[list] = []
|
||||
config: Optional[dict] = {}
|
||||
permissions: Optional[dict] = {}
|
||||
model_max_budget: Optional[
|
||||
dict
|
||||
] = {} # {"gpt-4": 5.0, "gpt-3.5-turbo": 5.0}, defaults to {}
|
||||
model_max_budget: Optional[dict] = (
|
||||
{}
|
||||
) # {"gpt-4": 5.0, "gpt-3.5-turbo": 5.0}, defaults to {}
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
model_rpm_limit: Optional[dict] = None
|
||||
|
@ -667,6 +667,7 @@ class KeyRequestBase(GenerateRequestBase):
|
|||
budget_id: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
enforced_params: Optional[List[str]] = None
|
||||
allowed_routes: Optional[list] = []
|
||||
|
||||
|
||||
class GenerateKeyRequest(KeyRequestBase):
|
||||
|
@ -816,6 +817,8 @@ class NewUserResponse(GenerateKeyResponse):
|
|||
teams: Optional[list] = None
|
||||
user_alias: Optional[str] = None
|
||||
model_max_budget: Optional[dict] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class UpdateUserRequest(GenerateRequestBase):
|
||||
|
@ -908,12 +911,12 @@ class NewCustomerRequest(BudgetNewRequest):
|
|||
alias: Optional[str] = None # human-friendly alias
|
||||
blocked: bool = False # allow/disallow requests for this end-user
|
||||
budget_id: Optional[str] = None # give either a budget_id or max_budget
|
||||
allowed_model_region: Optional[
|
||||
AllowedModelRegion
|
||||
] = None # require all user requests to use models in this specific region
|
||||
default_model: Optional[
|
||||
str
|
||||
] = None # if no equivalent model in allowed region - default all requests to this model
|
||||
allowed_model_region: Optional[AllowedModelRegion] = (
|
||||
None # require all user requests to use models in this specific region
|
||||
)
|
||||
default_model: Optional[str] = (
|
||||
None # if no equivalent model in allowed region - default all requests to this model
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
|
@ -935,12 +938,12 @@ class UpdateCustomerRequest(LiteLLMPydanticObjectBase):
|
|||
blocked: bool = False # allow/disallow requests for this end-user
|
||||
max_budget: Optional[float] = None
|
||||
budget_id: Optional[str] = None # give either a budget_id or max_budget
|
||||
allowed_model_region: Optional[
|
||||
AllowedModelRegion
|
||||
] = None # require all user requests to use models in this specific region
|
||||
default_model: Optional[
|
||||
str
|
||||
] = None # if no equivalent model in allowed region - default all requests to this model
|
||||
allowed_model_region: Optional[AllowedModelRegion] = (
|
||||
None # require all user requests to use models in this specific region
|
||||
)
|
||||
default_model: Optional[str] = (
|
||||
None # if no equivalent model in allowed region - default all requests to this model
|
||||
)
|
||||
|
||||
|
||||
class DeleteCustomerRequest(LiteLLMPydanticObjectBase):
|
||||
|
@ -1076,9 +1079,9 @@ class BlockKeyRequest(LiteLLMPydanticObjectBase):
|
|||
|
||||
class AddTeamCallback(LiteLLMPydanticObjectBase):
|
||||
callback_name: str
|
||||
callback_type: Optional[
|
||||
Literal["success", "failure", "success_and_failure"]
|
||||
] = "success_and_failure"
|
||||
callback_type: Optional[Literal["success", "failure", "success_and_failure"]] = (
|
||||
"success_and_failure"
|
||||
)
|
||||
callback_vars: Dict[str, str]
|
||||
|
||||
@model_validator(mode="before")
|
||||
|
@ -1144,6 +1147,7 @@ class LiteLLM_TeamTable(TeamBase):
|
|||
budget_reset_at: Optional[datetime] = None
|
||||
model_id: Optional[int] = None
|
||||
litellm_model_table: Optional[LiteLLM_ModelTable] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
@ -1335,9 +1339,9 @@ class ConfigList(LiteLLMPydanticObjectBase):
|
|||
stored_in_db: Optional[bool]
|
||||
field_default_value: Any
|
||||
premium_field: bool = False
|
||||
nested_fields: Optional[
|
||||
List[FieldDetail]
|
||||
] = None # For nested dictionary or Pydantic fields
|
||||
nested_fields: Optional[List[FieldDetail]] = (
|
||||
None # For nested dictionary or Pydantic fields
|
||||
)
|
||||
|
||||
|
||||
class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
|
||||
|
@ -1491,6 +1495,7 @@ class LiteLLM_VerificationToken(LiteLLMPydanticObjectBase):
|
|||
budget_duration: Optional[str] = None
|
||||
budget_reset_at: Optional[datetime] = None
|
||||
allowed_cache_controls: Optional[list] = []
|
||||
allowed_routes: Optional[list] = []
|
||||
permissions: Dict = {}
|
||||
model_spend: Dict = {}
|
||||
model_max_budget: Dict = {}
|
||||
|
@ -1604,9 +1609,9 @@ class LiteLLM_OrganizationMembershipTable(LiteLLMPydanticObjectBase):
|
|||
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
|
||||
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=())
|
||||
|
@ -2354,9 +2359,9 @@ class TeamModelDeleteRequest(BaseModel):
|
|||
# Organization Member Requests
|
||||
class OrganizationMemberAddRequest(OrgMemberAddRequest):
|
||||
organization_id: str
|
||||
max_budget_in_organization: Optional[
|
||||
float
|
||||
] = None # Users max budget within the organization
|
||||
max_budget_in_organization: Optional[float] = (
|
||||
None # Users max budget within the organization
|
||||
)
|
||||
|
||||
|
||||
class OrganizationMemberDeleteRequest(MemberDeleteRequest):
|
||||
|
@ -2545,9 +2550,9 @@ class ProviderBudgetResponse(LiteLLMPydanticObjectBase):
|
|||
Maps provider names to their budget configs.
|
||||
"""
|
||||
|
||||
providers: Dict[
|
||||
str, ProviderBudgetResponseObject
|
||||
] = {} # Dictionary mapping provider names to their budget configurations
|
||||
providers: Dict[str, ProviderBudgetResponseObject] = (
|
||||
{}
|
||||
) # Dictionary mapping provider names to their budget configurations
|
||||
|
||||
|
||||
class ProxyStateVariables(TypedDict):
|
||||
|
@ -2675,9 +2680,9 @@ class LiteLLM_JWTAuth(LiteLLMPydanticObjectBase):
|
|||
enforce_rbac: bool = False
|
||||
roles_jwt_field: Optional[str] = None # v2 on role mappings
|
||||
role_mappings: Optional[List[RoleMapping]] = None
|
||||
object_id_jwt_field: Optional[
|
||||
str
|
||||
] = None # can be either user / team, inferred from the role mapping
|
||||
object_id_jwt_field: Optional[str] = (
|
||||
None # can be either user / team, inferred from the role mapping
|
||||
)
|
||||
scope_mappings: Optional[List[ScopeMapping]] = None
|
||||
enforce_scope_based_access: bool = False
|
||||
enforce_team_based_model_access: bool = False
|
||||
|
|
|
@ -2,11 +2,11 @@
|
|||
## Common auth checks between jwt + key based auth
|
||||
"""
|
||||
Got Valid Token from Cache, DB
|
||||
Run checks for:
|
||||
Run checks for:
|
||||
|
||||
1. If user can call model
|
||||
2. If user is in budget
|
||||
3. If end_user ('user' passed to /chat/completions, /embeddings endpoint) is in budget
|
||||
2. If user is in budget
|
||||
3. If end_user ('user' passed to /chat/completions, /embeddings endpoint) is in budget
|
||||
"""
|
||||
import asyncio
|
||||
import re
|
||||
|
@ -270,6 +270,11 @@ def _is_api_route_allowed(
|
|||
if valid_token is None:
|
||||
raise Exception("Invalid proxy server token passed. valid_token=None.")
|
||||
|
||||
# Check if Virtual Key is allowed to call the route - Applies to all Roles
|
||||
RouteChecks.is_virtual_key_allowed_to_call_route(
|
||||
route=route, valid_token=valid_token
|
||||
)
|
||||
|
||||
if not _is_user_proxy_admin(user_obj=user_obj): # if non-admin
|
||||
RouteChecks.non_proxy_admin_allowed_routes_check(
|
||||
user_obj=user_obj,
|
||||
|
|
|
@ -16,6 +16,37 @@ from .auth_checks_organization import _user_is_org_admin
|
|||
|
||||
|
||||
class RouteChecks:
|
||||
@staticmethod
|
||||
def is_virtual_key_allowed_to_call_route(
|
||||
route: str, valid_token: UserAPIKeyAuth
|
||||
) -> bool:
|
||||
"""
|
||||
Raises Exception if Virtual Key is not allowed to call the route
|
||||
"""
|
||||
|
||||
# Only check if valid_token.allowed_routes is set and is a list with at least one item
|
||||
if valid_token.allowed_routes is None:
|
||||
return True
|
||||
if not isinstance(valid_token.allowed_routes, list):
|
||||
return True
|
||||
if len(valid_token.allowed_routes) == 0:
|
||||
return True
|
||||
|
||||
# explicit check for allowed routes
|
||||
if route in valid_token.allowed_routes:
|
||||
return True
|
||||
|
||||
# check if wildcard pattern is allowed
|
||||
for allowed_route in valid_token.allowed_routes:
|
||||
if RouteChecks._route_matches_wildcard_pattern(
|
||||
route=route, pattern=allowed_route
|
||||
):
|
||||
return True
|
||||
|
||||
raise Exception(
|
||||
f"Virtual key is not allowed to call this route. Only allowed to call routes: {valid_token.allowed_routes}. Tried to call route: {route}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def non_proxy_admin_allowed_routes_check(
|
||||
user_obj: Optional[LiteLLM_UserTable],
|
||||
|
@ -220,6 +251,35 @@ class RouteChecks:
|
|||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _route_matches_wildcard_pattern(route: str, pattern: str) -> bool:
|
||||
"""
|
||||
Check if route matches the wildcard pattern
|
||||
|
||||
eg.
|
||||
|
||||
pattern: "/scim/v2/*"
|
||||
route: "/scim/v2/Users"
|
||||
- returns: True
|
||||
|
||||
pattern: "/scim/v2/*"
|
||||
route: "/chat/completions"
|
||||
- returns: False
|
||||
|
||||
|
||||
pattern: "/scim/v2/*"
|
||||
route: "/scim/v2/Users/123"
|
||||
- returns: True
|
||||
|
||||
"""
|
||||
if pattern.endswith("*"):
|
||||
# Get the prefix (everything before the wildcard)
|
||||
prefix = pattern[:-1]
|
||||
return route.startswith(prefix)
|
||||
else:
|
||||
# If there's no wildcard, the pattern and route should match exactly
|
||||
return route == pattern
|
||||
|
||||
@staticmethod
|
||||
def check_route_access(route: str, allowed_routes: List[str]) -> bool:
|
||||
"""
|
||||
|
|
|
@ -372,7 +372,7 @@ async def generate_key_fn( # noqa: PLR0915
|
|||
- soft_budget: Optional[float] - Specify soft budget for a given key. Will trigger a slack alert when this soft budget is reached.
|
||||
- tags: Optional[List[str]] - Tags for [tracking spend](https://litellm.vercel.app/docs/proxy/enterprise#tracking-spend-for-custom-tags) and/or doing [tag-based routing](https://litellm.vercel.app/docs/proxy/tag_routing).
|
||||
- enforced_params: Optional[List[str]] - List of enforced params for the key (Enterprise only). [Docs](https://docs.litellm.ai/docs/proxy/enterprise#enforce-required-params-for-llm-requests)
|
||||
|
||||
- allowed_routes: Optional[list] - List of allowed routes for the key. Store the actual route or store a wildcard pattern for a set of routes. Example - ["/chat/completions", "/embeddings", "/keys/*"]
|
||||
Examples:
|
||||
|
||||
1. Allow users to turn on/off pii masking
|
||||
|
@ -577,9 +577,9 @@ async def generate_key_fn( # noqa: PLR0915
|
|||
request_type="key", **data_json, table_name="key"
|
||||
)
|
||||
|
||||
response[
|
||||
"soft_budget"
|
||||
] = data.soft_budget # include the user-input soft budget in the response
|
||||
response["soft_budget"] = (
|
||||
data.soft_budget
|
||||
) # include the user-input soft budget in the response
|
||||
|
||||
response = GenerateKeyResponse(**response)
|
||||
|
||||
|
@ -723,6 +723,7 @@ async def update_key_fn(
|
|||
- config: Optional[dict] - [DEPRECATED PARAM] Key-specific config.
|
||||
- temp_budget_increase: Optional[float] - Temporary budget increase for the key (Enterprise only).
|
||||
- temp_budget_expiry: Optional[str] - Expiry time for the temporary budget increase (Enterprise only).
|
||||
- allowed_routes: Optional[list] - List of allowed routes for the key. Store the actual route or store a wildcard pattern for a set of routes. Example - ["/chat/completions", "/embeddings", "/keys/*"]
|
||||
|
||||
Example:
|
||||
```bash
|
||||
|
@ -1167,6 +1168,7 @@ async def generate_key_helper_fn( # noqa: PLR0915
|
|||
send_invite_email: Optional[bool] = None,
|
||||
created_by: Optional[str] = None,
|
||||
updated_by: Optional[str] = None,
|
||||
allowed_routes: Optional[list] = None,
|
||||
):
|
||||
from litellm.proxy.proxy_server import (
|
||||
litellm_proxy_budget_name,
|
||||
|
@ -1272,6 +1274,7 @@ async def generate_key_helper_fn( # noqa: PLR0915
|
|||
"blocked": blocked,
|
||||
"created_by": created_by,
|
||||
"updated_by": updated_by,
|
||||
"allowed_routes": allowed_routes or [],
|
||||
}
|
||||
|
||||
if (
|
||||
|
@ -1467,10 +1470,10 @@ async def delete_verification_tokens(
|
|||
try:
|
||||
if prisma_client:
|
||||
tokens = [_hash_token_if_needed(token=key) for key in tokens]
|
||||
_keys_being_deleted: List[
|
||||
LiteLLM_VerificationToken
|
||||
] = await prisma_client.db.litellm_verificationtoken.find_many(
|
||||
where={"token": {"in": tokens}}
|
||||
_keys_being_deleted: List[LiteLLM_VerificationToken] = (
|
||||
await prisma_client.db.litellm_verificationtoken.find_many(
|
||||
where={"token": {"in": tokens}}
|
||||
)
|
||||
)
|
||||
|
||||
# Assuming 'db' is your Prisma Client instance
|
||||
|
@ -1572,9 +1575,9 @@ async def _rotate_master_key(
|
|||
from litellm.proxy.proxy_server import proxy_config
|
||||
|
||||
try:
|
||||
models: Optional[
|
||||
List
|
||||
] = await prisma_client.db.litellm_proxymodeltable.find_many()
|
||||
models: Optional[List] = (
|
||||
await prisma_client.db.litellm_proxymodeltable.find_many()
|
||||
)
|
||||
except Exception:
|
||||
models = None
|
||||
# 2. process model table
|
||||
|
@ -1861,11 +1864,11 @@ async def validate_key_list_check(
|
|||
param="user_id",
|
||||
code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
complete_user_info_db_obj: Optional[
|
||||
BaseModel
|
||||
] = await prisma_client.db.litellm_usertable.find_unique(
|
||||
where={"user_id": user_api_key_dict.user_id},
|
||||
include={"organization_memberships": True},
|
||||
complete_user_info_db_obj: Optional[BaseModel] = (
|
||||
await prisma_client.db.litellm_usertable.find_unique(
|
||||
where={"user_id": user_api_key_dict.user_id},
|
||||
include={"organization_memberships": True},
|
||||
)
|
||||
)
|
||||
|
||||
if complete_user_info_db_obj is None:
|
||||
|
@ -1926,10 +1929,10 @@ async def get_admin_team_ids(
|
|||
if complete_user_info is None:
|
||||
return []
|
||||
# Get all teams that user is an admin of
|
||||
teams: Optional[
|
||||
List[BaseModel]
|
||||
] = await prisma_client.db.litellm_teamtable.find_many(
|
||||
where={"team_id": {"in": complete_user_info.teams}}
|
||||
teams: Optional[List[BaseModel]] = (
|
||||
await prisma_client.db.litellm_teamtable.find_many(
|
||||
where={"team_id": {"in": complete_user_info.teams}}
|
||||
)
|
||||
)
|
||||
if teams is None:
|
||||
return []
|
||||
|
|
118
litellm/proxy/management_endpoints/scim/README_SCIM.md
Normal file
118
litellm/proxy/management_endpoints/scim/README_SCIM.md
Normal file
|
@ -0,0 +1,118 @@
|
|||
# SCIM v2 Integration for LiteLLM Proxy
|
||||
|
||||
This module provides SCIM v2 (System for Cross-domain Identity Management) endpoints for LiteLLM Proxy, allowing identity providers to manage users and teams (groups) within the LiteLLM ecosystem.
|
||||
|
||||
## Overview
|
||||
|
||||
SCIM is an open standard designed to simplify user management across different systems. This implementation allows compatible identity providers (like Okta, Azure AD, OneLogin, etc.) to automatically provision and deprovision users and groups in LiteLLM Proxy.
|
||||
|
||||
## Endpoints
|
||||
|
||||
The SCIM v2 API follows the standard specification with the following base URL:
|
||||
|
||||
```
|
||||
/scim/v2
|
||||
```
|
||||
|
||||
### User Management
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/Users` | GET | List all users with pagination support |
|
||||
| `/Users/{user_id}` | GET | Get a specific user by ID |
|
||||
| `/Users` | POST | Create a new user |
|
||||
| `/Users/{user_id}` | PUT | Update an existing user |
|
||||
| `/Users/{user_id}` | DELETE | Delete a user |
|
||||
|
||||
### Group Management
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/Groups` | GET | List all groups with pagination support |
|
||||
| `/Groups/{group_id}` | GET | Get a specific group by ID |
|
||||
| `/Groups` | POST | Create a new group |
|
||||
| `/Groups/{group_id}` | PUT | Update an existing group |
|
||||
| `/Groups/{group_id}` | DELETE | Delete a group |
|
||||
|
||||
## SCIM Schema
|
||||
|
||||
This implementation follows the standard SCIM v2 schema with the following mappings:
|
||||
|
||||
### Users
|
||||
|
||||
- SCIM User ID → LiteLLM `user_id`
|
||||
- SCIM User Email → LiteLLM `user_email`
|
||||
- SCIM User Group Memberships → LiteLLM User-Team relationships
|
||||
|
||||
### Groups
|
||||
|
||||
- SCIM Group ID → LiteLLM `team_id`
|
||||
- SCIM Group Display Name → LiteLLM `team_alias`
|
||||
- SCIM Group Members → LiteLLM Team members list
|
||||
|
||||
## Configuration
|
||||
|
||||
To enable SCIM in your identity provider, use the full URL to the SCIM endpoint:
|
||||
|
||||
```
|
||||
https://your-litellm-proxy-url/scim/v2
|
||||
```
|
||||
|
||||
Most identity providers will require authentication. You should use a valid LiteLLM API key with administrative privileges.
|
||||
|
||||
## Features
|
||||
|
||||
- Full CRUD operations for users and groups
|
||||
- Pagination support
|
||||
- Basic filtering support
|
||||
- Automatic synchronization of user-team relationships
|
||||
- Proper status codes and error handling per SCIM specification
|
||||
|
||||
|
||||
## Example Usage
|
||||
|
||||
### Listing Users
|
||||
|
||||
```
|
||||
GET /scim/v2/Users?startIndex=1&count=10
|
||||
```
|
||||
|
||||
### Creating a User
|
||||
|
||||
```json
|
||||
POST /scim/v2/Users
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"userName": "john.doe@example.com",
|
||||
"active": true,
|
||||
"emails": [
|
||||
{
|
||||
"value": "john.doe@example.com",
|
||||
"primary": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Adding a User to Groups
|
||||
|
||||
```json
|
||||
PUT /scim/v2/Users/{user_id}
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"userName": "john.doe@example.com",
|
||||
"active": true,
|
||||
"emails": [
|
||||
{
|
||||
"value": "john.doe@example.com",
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"value": "team-123",
|
||||
"display": "Engineering Team"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
154
litellm/proxy/management_endpoints/scim/scim_transformations.py
Normal file
154
litellm/proxy/management_endpoints/scim/scim_transformations.py
Normal file
|
@ -0,0 +1,154 @@
|
|||
from typing import List, Union
|
||||
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_TeamTable,
|
||||
LiteLLM_UserTable,
|
||||
Member,
|
||||
NewUserResponse,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.scim_v2 import *
|
||||
|
||||
|
||||
class ScimTransformations:
|
||||
DEFAULT_SCIM_NAME = "Unknown User"
|
||||
DEFAULT_SCIM_FAMILY_NAME = "Unknown Family Name"
|
||||
DEFAULT_SCIM_DISPLAY_NAME = "Unknown Display Name"
|
||||
DEFAULT_SCIM_MEMBER_VALUE = "Unknown Member Value"
|
||||
|
||||
@staticmethod
|
||||
async def transform_litellm_user_to_scim_user(
|
||||
user: Union[LiteLLM_UserTable, NewUserResponse],
|
||||
) -> SCIMUser:
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(
|
||||
status_code=500, detail={"error": "No database connected"}
|
||||
)
|
||||
|
||||
# Get user's teams/groups
|
||||
groups = []
|
||||
for team_id in user.teams or []:
|
||||
team = await prisma_client.db.litellm_teamtable.find_unique(
|
||||
where={"team_id": team_id}
|
||||
)
|
||||
if team:
|
||||
team_alias = getattr(team, "team_alias", team.team_id)
|
||||
groups.append(SCIMUserGroup(value=team.team_id, display=team_alias))
|
||||
|
||||
user_created_at = user.created_at.isoformat() if user.created_at else None
|
||||
user_updated_at = user.updated_at.isoformat() if user.updated_at else None
|
||||
|
||||
emails = []
|
||||
if user.user_email:
|
||||
emails.append(SCIMUserEmail(value=user.user_email, primary=True))
|
||||
|
||||
return SCIMUser(
|
||||
schemas=["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
id=user.user_id,
|
||||
userName=ScimTransformations._get_scim_user_name(user),
|
||||
displayName=ScimTransformations._get_scim_user_name(user),
|
||||
name=SCIMUserName(
|
||||
familyName=ScimTransformations._get_scim_family_name(user),
|
||||
givenName=ScimTransformations._get_scim_given_name(user),
|
||||
),
|
||||
emails=emails,
|
||||
groups=groups,
|
||||
active=True,
|
||||
meta={
|
||||
"resourceType": "User",
|
||||
"created": user_created_at,
|
||||
"lastModified": user_updated_at,
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_scim_user_name(user: Union[LiteLLM_UserTable, NewUserResponse]) -> str:
|
||||
"""
|
||||
SCIM requires a display name with length > 0
|
||||
|
||||
We use the same userName and displayName for SCIM users
|
||||
"""
|
||||
if user.user_email and len(user.user_email) > 0:
|
||||
return user.user_email
|
||||
return ScimTransformations.DEFAULT_SCIM_DISPLAY_NAME
|
||||
|
||||
@staticmethod
|
||||
def _get_scim_family_name(user: Union[LiteLLM_UserTable, NewUserResponse]) -> str:
|
||||
"""
|
||||
SCIM requires a family name with length > 0
|
||||
"""
|
||||
metadata = user.metadata or {}
|
||||
if "scim_metadata" in metadata:
|
||||
scim_metadata: LiteLLM_UserScimMetadata = LiteLLM_UserScimMetadata(
|
||||
**metadata["scim_metadata"]
|
||||
)
|
||||
if scim_metadata.familyName and len(scim_metadata.familyName) > 0:
|
||||
return scim_metadata.familyName
|
||||
|
||||
if user.user_alias and len(user.user_alias) > 0:
|
||||
return user.user_alias
|
||||
return ScimTransformations.DEFAULT_SCIM_FAMILY_NAME
|
||||
|
||||
@staticmethod
|
||||
def _get_scim_given_name(user: Union[LiteLLM_UserTable, NewUserResponse]) -> str:
|
||||
"""
|
||||
SCIM requires a given name with length > 0
|
||||
"""
|
||||
metadata = user.metadata or {}
|
||||
if "scim_metadata" in metadata:
|
||||
scim_metadata: LiteLLM_UserScimMetadata = LiteLLM_UserScimMetadata(
|
||||
**metadata["scim_metadata"]
|
||||
)
|
||||
if scim_metadata.givenName and len(scim_metadata.givenName) > 0:
|
||||
return scim_metadata.givenName
|
||||
|
||||
if user.user_alias and len(user.user_alias) > 0:
|
||||
return user.user_alias or ScimTransformations.DEFAULT_SCIM_NAME
|
||||
return ScimTransformations.DEFAULT_SCIM_NAME
|
||||
|
||||
@staticmethod
|
||||
async def transform_litellm_team_to_scim_group(
|
||||
team: Union[LiteLLM_TeamTable, dict],
|
||||
) -> SCIMGroup:
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(
|
||||
status_code=500, detail={"error": "No database connected"}
|
||||
)
|
||||
|
||||
if isinstance(team, dict):
|
||||
team = LiteLLM_TeamTable(**team)
|
||||
|
||||
# Get team members
|
||||
scim_members: List[SCIMMember] = []
|
||||
for member in team.members_with_roles or []:
|
||||
scim_members.append(
|
||||
SCIMMember(
|
||||
value=ScimTransformations._get_scim_member_value(member),
|
||||
display=member.user_email,
|
||||
)
|
||||
)
|
||||
|
||||
team_alias = getattr(team, "team_alias", team.team_id)
|
||||
team_created_at = team.created_at.isoformat() if team.created_at else None
|
||||
team_updated_at = team.updated_at.isoformat() if team.updated_at else None
|
||||
|
||||
return SCIMGroup(
|
||||
schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
||||
id=team.team_id,
|
||||
displayName=team_alias,
|
||||
members=scim_members,
|
||||
meta={
|
||||
"resourceType": "Group",
|
||||
"created": team_created_at,
|
||||
"lastModified": team_updated_at,
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_scim_member_value(member: Member) -> str:
|
||||
if member.user_email:
|
||||
return member.user_email
|
||||
return ScimTransformations.DEFAULT_SCIM_MEMBER_VALUE
|
757
litellm/proxy/management_endpoints/scim/scim_v2.py
Normal file
757
litellm/proxy/management_endpoints/scim/scim_v2.py
Normal file
|
@ -0,0 +1,757 @@
|
|||
"""
|
||||
SCIM v2 Endpoints for LiteLLM Proxy using Internal User/Team Management
|
||||
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Body,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Path,
|
||||
Query,
|
||||
Request,
|
||||
Response,
|
||||
)
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_UserTable,
|
||||
LitellmUserRoles,
|
||||
Member,
|
||||
NewTeamRequest,
|
||||
NewUserRequest,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.management_endpoints.internal_user_endpoints import new_user
|
||||
from litellm.proxy.management_endpoints.scim.scim_transformations import (
|
||||
ScimTransformations,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.team_endpoints import new_team
|
||||
from litellm.types.proxy.management_endpoints.scim_v2 import *
|
||||
|
||||
scim_router = APIRouter(
|
||||
prefix="/scim/v2",
|
||||
tags=["SCIM v2"],
|
||||
)
|
||||
|
||||
|
||||
# Dependency to set the correct SCIM Content-Type
|
||||
async def set_scim_content_type(response: Response):
|
||||
"""Sets the Content-Type header to application/scim+json"""
|
||||
# Check if content type is already application/json, only override in that case
|
||||
# Avoids overriding for non-JSON responses or already correct types if they were set manually
|
||||
response.headers["Content-Type"] = "application/scim+json"
|
||||
|
||||
|
||||
# User Endpoints
|
||||
@scim_router.get(
|
||||
"/Users",
|
||||
response_model=SCIMListResponse,
|
||||
status_code=200,
|
||||
dependencies=[Depends(user_api_key_auth), Depends(set_scim_content_type)],
|
||||
)
|
||||
async def get_users(
|
||||
startIndex: int = Query(1, ge=1),
|
||||
count: int = Query(10, ge=1, le=100),
|
||||
filter: Optional[str] = Query(None),
|
||||
):
|
||||
"""
|
||||
Get a list of users according to SCIM v2 protocol
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail={"error": "No database connected"})
|
||||
|
||||
try:
|
||||
# Parse filter if provided (basic support)
|
||||
where_conditions = {}
|
||||
if filter:
|
||||
# Very basic filter support - only handling userName eq and emails.value eq
|
||||
if "userName eq" in filter:
|
||||
user_id = filter.split("userName eq ")[1].strip("\"'")
|
||||
where_conditions["user_id"] = user_id
|
||||
elif "emails.value eq" in filter:
|
||||
email = filter.split("emails.value eq ")[1].strip("\"'")
|
||||
where_conditions["user_email"] = email
|
||||
|
||||
# Get users from database
|
||||
users: List[LiteLLM_UserTable] = (
|
||||
await prisma_client.db.litellm_usertable.find_many(
|
||||
where=where_conditions,
|
||||
skip=(startIndex - 1),
|
||||
take=count,
|
||||
order={"created_at": "desc"},
|
||||
)
|
||||
)
|
||||
|
||||
# Get total count for pagination
|
||||
total_count = await prisma_client.db.litellm_usertable.count(
|
||||
where=where_conditions
|
||||
)
|
||||
|
||||
# Convert to SCIM format
|
||||
scim_users: List[SCIMUser] = []
|
||||
for user in users:
|
||||
scim_user = await ScimTransformations.transform_litellm_user_to_scim_user(
|
||||
user=user
|
||||
)
|
||||
scim_users.append(scim_user)
|
||||
|
||||
return SCIMListResponse(
|
||||
totalResults=total_count,
|
||||
startIndex=startIndex,
|
||||
itemsPerPage=min(count, len(scim_users)),
|
||||
Resources=scim_users,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail={"error": f"Error retrieving users: {str(e)}"}
|
||||
)
|
||||
|
||||
|
||||
@scim_router.get(
|
||||
"/Users/{user_id}",
|
||||
response_model=SCIMUser,
|
||||
status_code=200,
|
||||
dependencies=[Depends(user_api_key_auth), Depends(set_scim_content_type)],
|
||||
)
|
||||
async def get_user(
|
||||
user_id: str = Path(..., title="User ID"),
|
||||
):
|
||||
"""
|
||||
Get a single user by ID according to SCIM v2 protocol
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail={"error": "No database connected"})
|
||||
|
||||
try:
|
||||
user = await prisma_client.db.litellm_usertable.find_unique(
|
||||
where={"user_id": user_id}
|
||||
)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=404, detail={"error": f"User not found with ID: {user_id}"}
|
||||
)
|
||||
|
||||
# Convert to SCIM format
|
||||
scim_user = await ScimTransformations.transform_litellm_user_to_scim_user(user)
|
||||
return scim_user
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail={"error": f"Error retrieving user: {str(e)}"}
|
||||
)
|
||||
|
||||
|
||||
@scim_router.post(
|
||||
"/Users",
|
||||
response_model=SCIMUser,
|
||||
status_code=201,
|
||||
dependencies=[Depends(user_api_key_auth), Depends(set_scim_content_type)],
|
||||
)
|
||||
async def create_user(
|
||||
user: SCIMUser = Body(...),
|
||||
):
|
||||
"""
|
||||
Create a user according to SCIM v2 protocol
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail={"error": "No database connected"})
|
||||
|
||||
try:
|
||||
verbose_proxy_logger.debug("SCIM CREATE USER request: %s", user)
|
||||
# Extract email from SCIM user
|
||||
user_email = None
|
||||
if user.emails and len(user.emails) > 0:
|
||||
user_email = user.emails[0].value
|
||||
|
||||
# Check if user already exists
|
||||
existing_user = None
|
||||
if user.userName:
|
||||
existing_user = await prisma_client.db.litellm_usertable.find_unique(
|
||||
where={"user_id": user.userName}
|
||||
)
|
||||
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"error": f"User already exists with username: {user.userName}"},
|
||||
)
|
||||
|
||||
# Create user in database
|
||||
user_id = user.userName or str(uuid.uuid4())
|
||||
created_user = await new_user(
|
||||
data=NewUserRequest(
|
||||
user_id=user_id,
|
||||
user_email=user_email,
|
||||
user_alias=user.name.givenName,
|
||||
teams=[group.value for group in user.groups] if user.groups else None,
|
||||
metadata={
|
||||
"scim_metadata": LiteLLM_UserScimMetadata(
|
||||
givenName=user.name.givenName,
|
||||
familyName=user.name.familyName,
|
||||
).model_dump()
|
||||
},
|
||||
auto_create_key=False,
|
||||
),
|
||||
)
|
||||
scim_user = await ScimTransformations.transform_litellm_user_to_scim_user(
|
||||
user=created_user
|
||||
)
|
||||
return scim_user
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail={"error": f"Error creating user: {str(e)}"}
|
||||
)
|
||||
|
||||
|
||||
@scim_router.put(
|
||||
"/Users/{user_id}",
|
||||
response_model=SCIMUser,
|
||||
status_code=200,
|
||||
dependencies=[Depends(user_api_key_auth), Depends(set_scim_content_type)],
|
||||
)
|
||||
async def update_user(
|
||||
user_id: str = Path(..., title="User ID"),
|
||||
user: SCIMUser = Body(...),
|
||||
):
|
||||
"""
|
||||
Update a user according to SCIM v2 protocol
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail={"error": "No database connected"})
|
||||
try:
|
||||
return None
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail={"error": f"Error updating user: {str(e)}"}
|
||||
)
|
||||
|
||||
|
||||
@scim_router.delete(
|
||||
"/Users/{user_id}",
|
||||
status_code=204,
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def delete_user(
|
||||
user_id: str = Path(..., title="User ID"),
|
||||
):
|
||||
"""
|
||||
Delete a user according to SCIM v2 protocol
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail={"error": "No database connected"})
|
||||
|
||||
try:
|
||||
# Check if user exists
|
||||
existing_user = await prisma_client.db.litellm_usertable.find_unique(
|
||||
where={"user_id": user_id}
|
||||
)
|
||||
|
||||
if not existing_user:
|
||||
raise HTTPException(
|
||||
status_code=404, detail={"error": f"User not found with ID: {user_id}"}
|
||||
)
|
||||
|
||||
# Get teams user belongs to
|
||||
teams = []
|
||||
if existing_user.teams:
|
||||
for team_id in existing_user.teams:
|
||||
team = await prisma_client.db.litellm_teamtable.find_unique(
|
||||
where={"team_id": team_id}
|
||||
)
|
||||
if team:
|
||||
teams.append(team)
|
||||
|
||||
# Remove user from all teams
|
||||
for team in teams:
|
||||
current_members = team.members or []
|
||||
if user_id in current_members:
|
||||
new_members = [m for m in current_members if m != user_id]
|
||||
await prisma_client.db.litellm_teamtable.update(
|
||||
where={"team_id": team.team_id}, data={"members": new_members}
|
||||
)
|
||||
|
||||
# Delete user
|
||||
await prisma_client.db.litellm_usertable.delete(where={"user_id": user_id})
|
||||
|
||||
return Response(status_code=204)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail={"error": f"Error deleting user: {str(e)}"}
|
||||
)
|
||||
|
||||
|
||||
@scim_router.patch(
|
||||
"/Users/{user_id}",
|
||||
response_model=SCIMUser,
|
||||
status_code=200,
|
||||
dependencies=[Depends(user_api_key_auth), Depends(set_scim_content_type)],
|
||||
)
|
||||
async def patch_user(
|
||||
user_id: str = Path(..., title="User ID"),
|
||||
patch_ops: SCIMPatchOp = Body(...),
|
||||
):
|
||||
"""
|
||||
Patch a user according to SCIM v2 protocol
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail={"error": "No database connected"})
|
||||
|
||||
verbose_proxy_logger.debug("SCIM PATCH USER request: %s", patch_ops)
|
||||
|
||||
try:
|
||||
# Check if user exists
|
||||
existing_user = await prisma_client.db.litellm_usertable.find_unique(
|
||||
where={"user_id": user_id}
|
||||
)
|
||||
|
||||
if not existing_user:
|
||||
raise HTTPException(
|
||||
status_code=404, detail={"error": f"User not found with ID: {user_id}"}
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail={"error": f"Error patching user: {str(e)}"}
|
||||
)
|
||||
|
||||
|
||||
# Group Endpoints
|
||||
@scim_router.get(
|
||||
"/Groups",
|
||||
response_model=SCIMListResponse,
|
||||
status_code=200,
|
||||
dependencies=[Depends(user_api_key_auth), Depends(set_scim_content_type)],
|
||||
)
|
||||
async def get_groups(
|
||||
startIndex: int = Query(1, ge=1),
|
||||
count: int = Query(10, ge=1, le=100),
|
||||
filter: Optional[str] = Query(None),
|
||||
):
|
||||
"""
|
||||
Get a list of groups according to SCIM v2 protocol
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail={"error": "No database connected"})
|
||||
|
||||
try:
|
||||
# Parse filter if provided (basic support)
|
||||
where_conditions = {}
|
||||
if filter:
|
||||
# Very basic filter support - only handling displayName eq
|
||||
if "displayName eq" in filter:
|
||||
team_alias = filter.split("displayName eq ")[1].strip("\"'")
|
||||
where_conditions["team_alias"] = team_alias
|
||||
|
||||
# Get teams from database
|
||||
teams = await prisma_client.db.litellm_teamtable.find_many(
|
||||
where=where_conditions,
|
||||
skip=(startIndex - 1),
|
||||
take=count,
|
||||
order={"created_at": "desc"},
|
||||
)
|
||||
|
||||
# Get total count for pagination
|
||||
total_count = await prisma_client.db.litellm_teamtable.count(
|
||||
where=where_conditions
|
||||
)
|
||||
|
||||
# Convert to SCIM format
|
||||
scim_groups = []
|
||||
for team in teams:
|
||||
# Get team members
|
||||
members = []
|
||||
for member_id in team.members or []:
|
||||
member = await prisma_client.db.litellm_usertable.find_unique(
|
||||
where={"user_id": member_id}
|
||||
)
|
||||
if member:
|
||||
display_name = member.user_email or member.user_id
|
||||
members.append(
|
||||
SCIMMember(value=member.user_id, display=display_name)
|
||||
)
|
||||
|
||||
team_alias = getattr(team, "team_alias", team.team_id)
|
||||
team_created_at = team.created_at.isoformat() if team.created_at else None
|
||||
team_updated_at = team.updated_at.isoformat() if team.updated_at else None
|
||||
|
||||
scim_group = SCIMGroup(
|
||||
schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
||||
id=team.team_id,
|
||||
displayName=team_alias,
|
||||
members=members,
|
||||
meta={
|
||||
"resourceType": "Group",
|
||||
"created": team_created_at,
|
||||
"lastModified": team_updated_at,
|
||||
},
|
||||
)
|
||||
scim_groups.append(scim_group)
|
||||
|
||||
return SCIMListResponse(
|
||||
totalResults=total_count,
|
||||
startIndex=startIndex,
|
||||
itemsPerPage=min(count, len(scim_groups)),
|
||||
Resources=scim_groups,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail={"error": f"Error retrieving groups: {str(e)}"}
|
||||
)
|
||||
|
||||
|
||||
@scim_router.get(
|
||||
"/Groups/{group_id}",
|
||||
response_model=SCIMGroup,
|
||||
status_code=200,
|
||||
dependencies=[Depends(user_api_key_auth), Depends(set_scim_content_type)],
|
||||
)
|
||||
async def get_group(
|
||||
group_id: str = Path(..., title="Group ID"),
|
||||
):
|
||||
"""
|
||||
Get a single group by ID according to SCIM v2 protocol
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail={"error": "No database connected"})
|
||||
|
||||
try:
|
||||
team = await prisma_client.db.litellm_teamtable.find_unique(
|
||||
where={"team_id": group_id}
|
||||
)
|
||||
|
||||
if not team:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": f"Group not found with ID: {group_id}"},
|
||||
)
|
||||
|
||||
scim_group = await ScimTransformations.transform_litellm_team_to_scim_group(
|
||||
team
|
||||
)
|
||||
return scim_group
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail={"error": f"Error retrieving group: {str(e)}"}
|
||||
)
|
||||
|
||||
|
||||
@scim_router.post(
|
||||
"/Groups",
|
||||
response_model=SCIMGroup,
|
||||
status_code=201,
|
||||
dependencies=[Depends(user_api_key_auth), Depends(set_scim_content_type)],
|
||||
)
|
||||
async def create_group(
|
||||
group: SCIMGroup = Body(...),
|
||||
):
|
||||
"""
|
||||
Create a group according to SCIM v2 protocol
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail={"error": "No database connected"})
|
||||
|
||||
try:
|
||||
# Generate ID if not provided
|
||||
team_id = group.id or str(uuid.uuid4())
|
||||
|
||||
# Check if team already exists
|
||||
existing_team = await prisma_client.db.litellm_teamtable.find_unique(
|
||||
where={"team_id": team_id}
|
||||
)
|
||||
|
||||
if existing_team:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"error": f"Group already exists with ID: {team_id}"},
|
||||
)
|
||||
|
||||
# Extract members
|
||||
members_with_roles: List[Member] = []
|
||||
if group.members:
|
||||
for member in group.members:
|
||||
# Check if user exists
|
||||
user = await prisma_client.db.litellm_usertable.find_unique(
|
||||
where={"user_id": member.value}
|
||||
)
|
||||
if user:
|
||||
members_with_roles.append(Member(user_id=member.value, role="user"))
|
||||
|
||||
# Create team in database
|
||||
created_team = await new_team(
|
||||
data=NewTeamRequest(
|
||||
team_id=team_id,
|
||||
team_alias=group.displayName,
|
||||
members_with_roles=members_with_roles,
|
||||
),
|
||||
http_request=Request(scope={"type": "http", "path": "/scim/v2/Groups"}),
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
|
||||
)
|
||||
|
||||
scim_group = await ScimTransformations.transform_litellm_team_to_scim_group(
|
||||
created_team
|
||||
)
|
||||
return scim_group
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail={"error": f"Error creating group: {str(e)}"}
|
||||
)
|
||||
|
||||
|
||||
@scim_router.put(
|
||||
"/Groups/{group_id}",
|
||||
response_model=SCIMGroup,
|
||||
status_code=200,
|
||||
dependencies=[Depends(user_api_key_auth), Depends(set_scim_content_type)],
|
||||
)
|
||||
async def update_group(
|
||||
group_id: str = Path(..., title="Group ID"),
|
||||
group: SCIMGroup = Body(...),
|
||||
):
|
||||
"""
|
||||
Update a group according to SCIM v2 protocol
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail={"error": "No database connected"})
|
||||
|
||||
try:
|
||||
# Check if team exists
|
||||
existing_team = await prisma_client.db.litellm_teamtable.find_unique(
|
||||
where={"team_id": group_id}
|
||||
)
|
||||
|
||||
if not existing_team:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": f"Group not found with ID: {group_id}"},
|
||||
)
|
||||
|
||||
# Extract members
|
||||
member_ids = []
|
||||
if group.members:
|
||||
for member in group.members:
|
||||
# Check if user exists
|
||||
user = await prisma_client.db.litellm_usertable.find_unique(
|
||||
where={"user_id": member.value}
|
||||
)
|
||||
if user:
|
||||
member_ids.append(member.value)
|
||||
|
||||
# Update team in database
|
||||
existing_metadata = existing_team.metadata if existing_team.metadata else {}
|
||||
updated_team = await prisma_client.db.litellm_teamtable.update(
|
||||
where={"team_id": group_id},
|
||||
data={
|
||||
"team_alias": group.displayName,
|
||||
"members": member_ids,
|
||||
"metadata": {**existing_metadata, "scim_data": group.model_dump()},
|
||||
},
|
||||
)
|
||||
|
||||
# Handle user-team relationships
|
||||
current_members = existing_team.members or []
|
||||
|
||||
# Add new members to team
|
||||
for member_id in member_ids:
|
||||
if member_id not in current_members:
|
||||
user = await prisma_client.db.litellm_usertable.find_unique(
|
||||
where={"user_id": member_id}
|
||||
)
|
||||
if user:
|
||||
current_user_teams = user.teams or []
|
||||
if group_id not in current_user_teams:
|
||||
await prisma_client.db.litellm_usertable.update(
|
||||
where={"user_id": member_id},
|
||||
data={"teams": {"push": group_id}},
|
||||
)
|
||||
|
||||
# Remove former members from team
|
||||
for member_id in current_members:
|
||||
if member_id not in member_ids:
|
||||
user = await prisma_client.db.litellm_usertable.find_unique(
|
||||
where={"user_id": member_id}
|
||||
)
|
||||
if user:
|
||||
current_user_teams = user.teams or []
|
||||
if group_id in current_user_teams:
|
||||
new_teams = [t for t in current_user_teams if t != group_id]
|
||||
await prisma_client.db.litellm_usertable.update(
|
||||
where={"user_id": member_id}, data={"teams": new_teams}
|
||||
)
|
||||
|
||||
# Get updated members for response
|
||||
members = []
|
||||
for member_id in member_ids:
|
||||
user = await prisma_client.db.litellm_usertable.find_unique(
|
||||
where={"user_id": member_id}
|
||||
)
|
||||
if user:
|
||||
display_name = user.user_email or user.user_id
|
||||
members.append(SCIMMember(value=user.user_id, display=display_name))
|
||||
|
||||
team_created_at = (
|
||||
updated_team.created_at.isoformat() if updated_team.created_at else None
|
||||
)
|
||||
team_updated_at = (
|
||||
updated_team.updated_at.isoformat() if updated_team.updated_at else None
|
||||
)
|
||||
|
||||
return SCIMGroup(
|
||||
schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
||||
id=group_id,
|
||||
displayName=updated_team.team_alias or group_id,
|
||||
members=members,
|
||||
meta={
|
||||
"resourceType": "Group",
|
||||
"created": team_created_at,
|
||||
"lastModified": team_updated_at,
|
||||
},
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail={"error": f"Error updating group: {str(e)}"}
|
||||
)
|
||||
|
||||
|
||||
@scim_router.delete(
|
||||
"/Groups/{group_id}",
|
||||
status_code=204,
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def delete_group(
|
||||
group_id: str = Path(..., title="Group ID"),
|
||||
):
|
||||
"""
|
||||
Delete a group according to SCIM v2 protocol
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail={"error": "No database connected"})
|
||||
|
||||
try:
|
||||
# Check if team exists
|
||||
existing_team = await prisma_client.db.litellm_teamtable.find_unique(
|
||||
where={"team_id": group_id}
|
||||
)
|
||||
|
||||
if not existing_team:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": f"Group not found with ID: {group_id}"},
|
||||
)
|
||||
|
||||
# For each member, remove this team from their teams list
|
||||
for member_id in existing_team.members or []:
|
||||
user = await prisma_client.db.litellm_usertable.find_unique(
|
||||
where={"user_id": member_id}
|
||||
)
|
||||
if user:
|
||||
current_teams = user.teams or []
|
||||
if group_id in current_teams:
|
||||
new_teams = [t for t in current_teams if t != group_id]
|
||||
await prisma_client.db.litellm_usertable.update(
|
||||
where={"user_id": member_id}, data={"teams": new_teams}
|
||||
)
|
||||
|
||||
# Delete team
|
||||
await prisma_client.db.litellm_teamtable.delete(where={"team_id": group_id})
|
||||
|
||||
return Response(status_code=204)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail={"error": f"Error deleting group: {str(e)}"}
|
||||
)
|
||||
|
||||
|
||||
@scim_router.patch(
|
||||
"/Groups/{group_id}",
|
||||
response_model=SCIMGroup,
|
||||
status_code=200,
|
||||
dependencies=[Depends(user_api_key_auth), Depends(set_scim_content_type)],
|
||||
)
|
||||
async def patch_group(
|
||||
group_id: str = Path(..., title="Group ID"),
|
||||
patch_ops: SCIMPatchOp = Body(...),
|
||||
):
|
||||
"""
|
||||
Patch a group according to SCIM v2 protocol
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail={"error": "No database connected"})
|
||||
|
||||
verbose_proxy_logger.debug("SCIM PATCH GROUP request: %s", patch_ops)
|
||||
|
||||
try:
|
||||
# Check if group exists
|
||||
existing_team = await prisma_client.db.litellm_teamtable.find_unique(
|
||||
where={"team_id": group_id}
|
||||
)
|
||||
|
||||
if not existing_team:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": f"Group not found with ID: {group_id}"},
|
||||
)
|
||||
return None
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail={"error": f"Error patching group: {str(e)}"}
|
||||
)
|
|
@ -1,18 +1,6 @@
|
|||
model_list:
|
||||
- model_name: anhropic-auto-inject-cache-user-message
|
||||
- model_name: fake-openai-endpoint
|
||||
litellm_params:
|
||||
model: anhropic/claude-3-5-sonnet-20240620
|
||||
api_key: os.environ/ANTHROPIC_API_KEY
|
||||
cache_control_injection_points:
|
||||
- location: message
|
||||
role: user
|
||||
|
||||
- model_name: anhropic-auto-inject-cache-system-message
|
||||
litellm_params:
|
||||
model: anhropic/claude-3-5-sonnet-20240620
|
||||
api_key: os.environ/ANTHROPIC_API_KEY
|
||||
cache_control_injection_points:
|
||||
- location: message
|
||||
role: user
|
||||
|
||||
|
||||
model: openai/fake
|
||||
api_key: fake-key
|
||||
api_base: https://exampleopenaiendpoint-production.up.railway.app/
|
||||
|
|
|
@ -238,6 +238,7 @@ from litellm.proxy.management_endpoints.model_management_endpoints import (
|
|||
from litellm.proxy.management_endpoints.organization_endpoints import (
|
||||
router as organization_router,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.scim.scim_v2 import scim_router
|
||||
from litellm.proxy.management_endpoints.tag_management_endpoints import (
|
||||
router as tag_management_router,
|
||||
)
|
||||
|
@ -803,9 +804,9 @@ model_max_budget_limiter = _PROXY_VirtualKeyModelMaxBudgetLimiter(
|
|||
dual_cache=user_api_key_cache
|
||||
)
|
||||
litellm.logging_callback_manager.add_litellm_callback(model_max_budget_limiter)
|
||||
redis_usage_cache: Optional[
|
||||
RedisCache
|
||||
] = None # redis cache used for tracking spend, tpm/rpm limits
|
||||
redis_usage_cache: Optional[RedisCache] = (
|
||||
None # redis cache used for tracking spend, tpm/rpm limits
|
||||
)
|
||||
user_custom_auth = None
|
||||
user_custom_key_generate = None
|
||||
user_custom_sso = None
|
||||
|
@ -1131,9 +1132,9 @@ async def update_cache( # noqa: PLR0915
|
|||
_id = "team_id:{}".format(team_id)
|
||||
try:
|
||||
# Fetch the existing cost for the given user
|
||||
existing_spend_obj: Optional[
|
||||
LiteLLM_TeamTable
|
||||
] = await user_api_key_cache.async_get_cache(key=_id)
|
||||
existing_spend_obj: Optional[LiteLLM_TeamTable] = (
|
||||
await user_api_key_cache.async_get_cache(key=_id)
|
||||
)
|
||||
if existing_spend_obj is None:
|
||||
# do nothing if team not in api key cache
|
||||
return
|
||||
|
@ -1807,13 +1808,6 @@ class ProxyConfig:
|
|||
if master_key and master_key.startswith("os.environ/"):
|
||||
master_key = get_secret(master_key) # type: ignore
|
||||
|
||||
if not isinstance(master_key, str):
|
||||
raise Exception(
|
||||
"Master key must be a string. Current type - {}".format(
|
||||
type(master_key)
|
||||
)
|
||||
)
|
||||
|
||||
if master_key is not None and isinstance(master_key, str):
|
||||
litellm_master_key_hash = hash_token(master_key)
|
||||
### USER API KEY CACHE IN-MEMORY TTL ###
|
||||
|
@ -2812,9 +2806,9 @@ async def initialize( # noqa: PLR0915
|
|||
user_api_base = api_base
|
||||
dynamic_config[user_model]["api_base"] = api_base
|
||||
if api_version:
|
||||
os.environ[
|
||||
"AZURE_API_VERSION"
|
||||
] = api_version # set this for azure - litellm can read this from the env
|
||||
os.environ["AZURE_API_VERSION"] = (
|
||||
api_version # set this for azure - litellm can read this from the env
|
||||
)
|
||||
if max_tokens: # model-specific param
|
||||
dynamic_config[user_model]["max_tokens"] = max_tokens
|
||||
if temperature: # model-specific param
|
||||
|
@ -7756,9 +7750,9 @@ async def get_config_list(
|
|||
hasattr(sub_field_info, "description")
|
||||
and sub_field_info.description is not None
|
||||
):
|
||||
nested_fields[
|
||||
idx
|
||||
].field_description = sub_field_info.description
|
||||
nested_fields[idx].field_description = (
|
||||
sub_field_info.description
|
||||
)
|
||||
idx += 1
|
||||
|
||||
_stored_in_db = None
|
||||
|
@ -8176,6 +8170,7 @@ app.include_router(key_management_router)
|
|||
app.include_router(internal_user_router)
|
||||
app.include_router(team_router)
|
||||
app.include_router(ui_sso_router)
|
||||
app.include_router(scim_router)
|
||||
app.include_router(organization_router)
|
||||
app.include_router(customer_router)
|
||||
app.include_router(spend_management_router)
|
||||
|
|
|
@ -169,6 +169,7 @@ model LiteLLM_VerificationToken {
|
|||
budget_duration String?
|
||||
budget_reset_at DateTime?
|
||||
allowed_cache_controls String[] @default([])
|
||||
allowed_routes String[] @default([])
|
||||
model_spend Json @default("{}")
|
||||
model_max_budget Json @default("{}")
|
||||
budget_id String?
|
||||
|
|
82
litellm/types/proxy/management_endpoints/scim_v2.py
Normal file
82
litellm/types/proxy/management_endpoints/scim_v2.py
Normal file
|
@ -0,0 +1,82 @@
|
|||
from typing import Any, Dict, List, Literal, Optional, Union
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
class LiteLLM_UserScimMetadata(BaseModel):
|
||||
"""
|
||||
Scim metadata stored in LiteLLM_UserTable.metadata
|
||||
"""
|
||||
|
||||
givenName: Optional[str] = None
|
||||
familyName: Optional[str] = None
|
||||
|
||||
|
||||
# SCIM Resource Models
|
||||
class SCIMResource(BaseModel):
|
||||
schemas: List[str]
|
||||
id: Optional[str] = None
|
||||
externalId: Optional[str] = None
|
||||
meta: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class SCIMUserName(BaseModel):
|
||||
familyName: str
|
||||
givenName: str
|
||||
formatted: Optional[str] = None
|
||||
middleName: Optional[str] = None
|
||||
honorificPrefix: Optional[str] = None
|
||||
honorificSuffix: Optional[str] = None
|
||||
|
||||
|
||||
class SCIMUserEmail(BaseModel):
|
||||
value: EmailStr
|
||||
type: Optional[str] = None
|
||||
primary: Optional[bool] = None
|
||||
|
||||
|
||||
class SCIMUserGroup(BaseModel):
|
||||
value: str # Group ID
|
||||
display: Optional[str] = None # Group display name
|
||||
type: Optional[str] = "direct" # direct or indirect
|
||||
|
||||
|
||||
class SCIMUser(SCIMResource):
|
||||
userName: str
|
||||
name: SCIMUserName
|
||||
displayName: Optional[str] = None
|
||||
active: bool = True
|
||||
emails: Optional[List[SCIMUserEmail]] = None
|
||||
groups: Optional[List[SCIMUserGroup]] = None
|
||||
|
||||
|
||||
class SCIMMember(BaseModel):
|
||||
value: str # User ID
|
||||
display: Optional[str] = None # Username or email
|
||||
|
||||
|
||||
class SCIMGroup(SCIMResource):
|
||||
displayName: str
|
||||
members: Optional[List[SCIMMember]] = None
|
||||
|
||||
|
||||
# SCIM List Response Models
|
||||
class SCIMListResponse(BaseModel):
|
||||
schemas: List[str] = ["urn:ietf:params:scim:api:messages:2.0:ListResponse"]
|
||||
totalResults: int
|
||||
startIndex: Optional[int] = 1
|
||||
itemsPerPage: Optional[int] = 10
|
||||
Resources: Union[List[SCIMUser], List[SCIMGroup]]
|
||||
|
||||
|
||||
# SCIM PATCH Operation Models
|
||||
class SCIMPatchOperation(BaseModel):
|
||||
op: Literal["add", "remove", "replace"]
|
||||
path: Optional[str] = None
|
||||
value: Optional[Any] = None
|
||||
|
||||
|
||||
class SCIMPatchOp(BaseModel):
|
||||
schemas: List[str] = ["urn:ietf:params:scim:api:messages:2.0:PatchOp"]
|
||||
Operations: List[SCIMPatchOperation]
|
|
@ -169,6 +169,7 @@ model LiteLLM_VerificationToken {
|
|||
budget_duration String?
|
||||
budget_reset_at DateTime?
|
||||
allowed_cache_controls String[] @default([])
|
||||
allowed_routes String[] @default([])
|
||||
model_spend Json @default("{}")
|
||||
model_max_budget Json @default("{}")
|
||||
budget_id String?
|
||||
|
|
|
@ -0,0 +1,225 @@
|
|||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from typing import Optional, cast
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(
|
||||
0, os.path.abspath("../../../")
|
||||
) # Adds the parent directory to the system path
|
||||
|
||||
from litellm.proxy._types import LiteLLM_TeamTable, LiteLLM_UserTable, Member
|
||||
from litellm.proxy.management_endpoints.scim.scim_transformations import (
|
||||
ScimTransformations,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.scim_v2 import SCIMGroup, SCIMUser
|
||||
|
||||
|
||||
# Mock data
|
||||
@pytest.fixture
|
||||
def mock_user():
|
||||
return LiteLLM_UserTable(
|
||||
user_id="user-123",
|
||||
user_email="test@example.com",
|
||||
user_alias="Test User",
|
||||
teams=["team-1", "team-2"],
|
||||
created_at=None,
|
||||
updated_at=None,
|
||||
metadata={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_with_scim_metadata():
|
||||
return LiteLLM_UserTable(
|
||||
user_id="user-456",
|
||||
user_email="test2@example.com",
|
||||
user_alias="Test User 2",
|
||||
teams=["team-1"],
|
||||
created_at=None,
|
||||
updated_at=None,
|
||||
metadata={"scim_metadata": {"givenName": "Test", "familyName": "User"}},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_minimal():
|
||||
return LiteLLM_UserTable(
|
||||
user_id="user-789",
|
||||
user_email=None,
|
||||
user_alias=None,
|
||||
teams=[],
|
||||
created_at=None,
|
||||
updated_at=None,
|
||||
metadata={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_team():
|
||||
return LiteLLM_TeamTable(
|
||||
team_id="team-1",
|
||||
team_alias="Test Team",
|
||||
members_with_roles=[
|
||||
Member(user_id="user-123", user_email="test@example.com", role="admin"),
|
||||
Member(user_id="user-456", user_email="test2@example.com", role="user"),
|
||||
],
|
||||
created_at=None,
|
||||
updated_at=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_team_minimal():
|
||||
return LiteLLM_TeamTable(
|
||||
team_id="team-2",
|
||||
team_alias="Test Team 2",
|
||||
members_with_roles=[Member(user_id="user-789", user_email=None, role="user")],
|
||||
created_at=None,
|
||||
updated_at=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_prisma_client():
|
||||
mock_client = MagicMock()
|
||||
mock_db = MagicMock()
|
||||
mock_client.db = mock_db
|
||||
|
||||
mock_find_unique = AsyncMock()
|
||||
mock_db.litellm_teamtable.find_unique = mock_find_unique
|
||||
|
||||
return mock_client, mock_find_unique
|
||||
|
||||
|
||||
class TestScimTransformations:
|
||||
@pytest.mark.asyncio
|
||||
async def test_transform_litellm_user_to_scim_user(
|
||||
self, mock_user, mock_prisma_client
|
||||
):
|
||||
mock_client, mock_find_unique = mock_prisma_client
|
||||
|
||||
# Mock the team lookup
|
||||
team1 = LiteLLM_TeamTable(
|
||||
team_id="team-1", team_alias="Team One", members_with_roles=[]
|
||||
)
|
||||
team2 = LiteLLM_TeamTable(
|
||||
team_id="team-2", team_alias="Team Two", members_with_roles=[]
|
||||
)
|
||||
|
||||
mock_find_unique.side_effect = [team1, team2]
|
||||
|
||||
with patch("litellm.proxy.proxy_server.prisma_client", mock_client):
|
||||
scim_user = await ScimTransformations.transform_litellm_user_to_scim_user(
|
||||
mock_user
|
||||
)
|
||||
|
||||
assert scim_user.id == mock_user.user_id
|
||||
assert scim_user.userName == mock_user.user_email
|
||||
assert scim_user.displayName == mock_user.user_email
|
||||
assert scim_user.name.familyName == mock_user.user_alias
|
||||
assert scim_user.name.givenName == mock_user.user_alias
|
||||
assert len(scim_user.emails) == 1
|
||||
assert scim_user.emails[0].value == mock_user.user_email
|
||||
assert len(scim_user.groups) == 2
|
||||
assert scim_user.groups[0].value == "team-1"
|
||||
assert scim_user.groups[0].display == "Team One"
|
||||
assert scim_user.groups[1].value == "team-2"
|
||||
assert scim_user.groups[1].display == "Team Two"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transform_user_with_scim_metadata(
|
||||
self, mock_user_with_scim_metadata, mock_prisma_client
|
||||
):
|
||||
mock_client, mock_find_unique = mock_prisma_client
|
||||
|
||||
# Mock the team lookup
|
||||
team1 = LiteLLM_TeamTable(
|
||||
team_id="team-1", team_alias="Team One", members_with_roles=[]
|
||||
)
|
||||
mock_find_unique.return_value = team1
|
||||
|
||||
with patch("litellm.proxy.proxy_server.prisma_client", mock_client):
|
||||
scim_user = await ScimTransformations.transform_litellm_user_to_scim_user(
|
||||
mock_user_with_scim_metadata
|
||||
)
|
||||
|
||||
assert scim_user.name.givenName == "Test"
|
||||
assert scim_user.name.familyName == "User"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transform_litellm_team_to_scim_group(
|
||||
self, mock_team, mock_prisma_client
|
||||
):
|
||||
mock_client, _ = mock_prisma_client
|
||||
|
||||
with patch("litellm.proxy.proxy_server.prisma_client", mock_client):
|
||||
scim_group = await ScimTransformations.transform_litellm_team_to_scim_group(
|
||||
mock_team
|
||||
)
|
||||
|
||||
assert scim_group.id == mock_team.team_id
|
||||
assert scim_group.displayName == mock_team.team_alias
|
||||
assert len(scim_group.members) == 2
|
||||
assert scim_group.members[0].value == "test@example.com"
|
||||
assert scim_group.members[0].display == "test@example.com"
|
||||
assert scim_group.members[1].value == "test2@example.com"
|
||||
assert scim_group.members[1].display == "test2@example.com"
|
||||
|
||||
def test_get_scim_user_name(self, mock_user, mock_user_minimal):
|
||||
# User with email
|
||||
result = ScimTransformations._get_scim_user_name(mock_user)
|
||||
assert result == mock_user.user_email
|
||||
|
||||
# User without email
|
||||
result = ScimTransformations._get_scim_user_name(mock_user_minimal)
|
||||
assert result == ScimTransformations.DEFAULT_SCIM_DISPLAY_NAME
|
||||
|
||||
def test_get_scim_family_name(
|
||||
self, mock_user, mock_user_with_scim_metadata, mock_user_minimal
|
||||
):
|
||||
# User with alias
|
||||
result = ScimTransformations._get_scim_family_name(mock_user)
|
||||
assert result == mock_user.user_alias
|
||||
|
||||
# User with SCIM metadata
|
||||
result = ScimTransformations._get_scim_family_name(mock_user_with_scim_metadata)
|
||||
assert result == "User"
|
||||
|
||||
# User without alias or metadata
|
||||
result = ScimTransformations._get_scim_family_name(mock_user_minimal)
|
||||
assert result == ScimTransformations.DEFAULT_SCIM_FAMILY_NAME
|
||||
|
||||
def test_get_scim_given_name(
|
||||
self, mock_user, mock_user_with_scim_metadata, mock_user_minimal
|
||||
):
|
||||
# User with alias
|
||||
result = ScimTransformations._get_scim_given_name(mock_user)
|
||||
assert result == mock_user.user_alias
|
||||
|
||||
# User with SCIM metadata
|
||||
result = ScimTransformations._get_scim_given_name(mock_user_with_scim_metadata)
|
||||
assert result == "Test"
|
||||
|
||||
# User without alias or metadata
|
||||
result = ScimTransformations._get_scim_given_name(mock_user_minimal)
|
||||
assert result == ScimTransformations.DEFAULT_SCIM_NAME
|
||||
|
||||
def test_get_scim_member_value(self):
|
||||
# Member with email
|
||||
member_with_email = Member(
|
||||
user_id="user-123", user_email="test@example.com", role="admin"
|
||||
)
|
||||
result = ScimTransformations._get_scim_member_value(member_with_email)
|
||||
assert result == member_with_email.user_email
|
||||
|
||||
# Member without email
|
||||
member_without_email = Member(user_id="user-456", user_email=None, role="user")
|
||||
result = ScimTransformations._get_scim_member_value(member_without_email)
|
||||
assert result == ScimTransformations.DEFAULT_SCIM_MEMBER_VALUE
|
750
tests/scim_tests/scim_e2e_test.json
Normal file
750
tests/scim_tests/scim_e2e_test.json
Normal file
|
@ -0,0 +1,750 @@
|
|||
{
|
||||
"version": "1.0",
|
||||
"exported_at": 1715608731,
|
||||
"name": "Okta SCIM 2.0 SPEC Test",
|
||||
"description": "Basic tests to see if your SCIM server will work with Okta",
|
||||
"trigger_url": "https://api.runscope.com/radar/37d9f10e-e250-4071-9cec-1fa30e56b42b/trigger",
|
||||
"steps": [
|
||||
{
|
||||
"step_type": "request",
|
||||
"skipped": false,
|
||||
"note": "Required Test: Test Users endpoint",
|
||||
"auth": {},
|
||||
"multipart_form": [],
|
||||
"headers": {
|
||||
"Accept-Charset": [
|
||||
"utf-8"
|
||||
],
|
||||
"Content-Type": [
|
||||
"application/scim+json; charset=utf-8"
|
||||
],
|
||||
"Accept": [
|
||||
"application/scim+json"
|
||||
],
|
||||
"Authorization": [
|
||||
"{{auth}}"
|
||||
],
|
||||
"User-Agent": [
|
||||
"OKTA SCIM Integration"
|
||||
]
|
||||
},
|
||||
"method": "GET",
|
||||
"url": "{{SCIMBaseURL}}/Users?count=1&startIndex=1",
|
||||
"assertions": [
|
||||
{
|
||||
"comparison": "equal_number",
|
||||
"source": "response_status",
|
||||
"value": "200"
|
||||
},
|
||||
{
|
||||
"comparison": "not_empty",
|
||||
"source": "response_json",
|
||||
"value": null,
|
||||
"property": "Resources"
|
||||
},
|
||||
{
|
||||
"comparison": "has_value",
|
||||
"source": "response_json",
|
||||
"value": "urn:ietf:params:scim:api:messages:2.0:ListResponse",
|
||||
"property": "schemas"
|
||||
},
|
||||
{
|
||||
"comparison": "is_a_number",
|
||||
"source": "response_json",
|
||||
"value": null,
|
||||
"property": "itemsPerPage"
|
||||
},
|
||||
{
|
||||
"comparison": "is_a_number",
|
||||
"source": "response_json",
|
||||
"value": null,
|
||||
"property": "startIndex"
|
||||
},
|
||||
{
|
||||
"comparison": "is_a_number",
|
||||
"source": "response_json",
|
||||
"value": null,
|
||||
"property": "totalResults"
|
||||
},
|
||||
{
|
||||
"comparison": "not_empty",
|
||||
"source": "response_json",
|
||||
"value": null,
|
||||
"property": "Resources[0].id"
|
||||
},
|
||||
{
|
||||
"comparison": "not_empty",
|
||||
"source": "response_json",
|
||||
"value": null,
|
||||
"property": "Resources[0].name.familyName"
|
||||
},
|
||||
{
|
||||
"comparison": "not_empty",
|
||||
"source": "response_json",
|
||||
"value": null,
|
||||
"property": "Resources[0].name.givenName"
|
||||
},
|
||||
{
|
||||
"comparison": "not_empty",
|
||||
"source": "response_json",
|
||||
"value": null,
|
||||
"property": "Resources[0].userName"
|
||||
},
|
||||
{
|
||||
"comparison": "not_empty",
|
||||
"source": "response_json",
|
||||
"value": null,
|
||||
"property": "Resources[0].active"
|
||||
},
|
||||
{
|
||||
"comparison": "not_empty",
|
||||
"source": "response_json",
|
||||
"value": null,
|
||||
"property": "Resources[0].emails[0].value"
|
||||
}
|
||||
],
|
||||
"variables": [
|
||||
{
|
||||
"source": "response_json",
|
||||
"name": "ISVUserid",
|
||||
"property": "Resources[0].id"
|
||||
}
|
||||
],
|
||||
"scripts": [],
|
||||
"before_scripts": []
|
||||
},
|
||||
{
|
||||
"step_type": "pause",
|
||||
"skipped": false,
|
||||
"duration": 5
|
||||
},
|
||||
{
|
||||
"step_type": "request",
|
||||
"skipped": false,
|
||||
"note": "Required Test: Get Users/{{id}} ",
|
||||
"auth": {},
|
||||
"multipart_form": [],
|
||||
"headers": {
|
||||
"Accept-Charset": [
|
||||
"utf-8"
|
||||
],
|
||||
"Content-Type": [
|
||||
"application/scim+json; charset=utf-8"
|
||||
],
|
||||
"Accept": [
|
||||
"application/scim+json"
|
||||
],
|
||||
"Authorization": [
|
||||
"{{auth}}"
|
||||
],
|
||||
"User-Agent": [
|
||||
"OKTA SCIM Integration"
|
||||
]
|
||||
},
|
||||
"method": "GET",
|
||||
"url": "{{SCIMBaseURL}}/Users/{{ISVUserid}}",
|
||||
"assertions": [
|
||||
{
|
||||
"comparison": "equal_number",
|
||||
"source": "response_status",
|
||||
"value": "200"
|
||||
},
|
||||
{
|
||||
"comparison": "not_empty",
|
||||
"source": "response_json",
|
||||
"value": null,
|
||||
"property": "id"
|
||||
},
|
||||
{
|
||||
"comparison": "not_empty",
|
||||
"source": "response_json",
|
||||
"value": null,
|
||||
"property": "name.familyName"
|
||||
},
|
||||
{
|
||||
"comparison": "not_empty",
|
||||
"source": "response_json",
|
||||
"value": null,
|
||||
"property": "name.givenName"
|
||||
},
|
||||
{
|
||||
"comparison": "not_empty",
|
||||
"source": "response_json",
|
||||
"value": null,
|
||||
"property": "userName"
|
||||
},
|
||||
{
|
||||
"comparison": "not_empty",
|
||||
"source": "response_json",
|
||||
"value": null,
|
||||
"property": "active"
|
||||
},
|
||||
{
|
||||
"comparison": "not_empty",
|
||||
"source": "response_json",
|
||||
"value": null,
|
||||
"property": "emails[0].value"
|
||||
},
|
||||
{
|
||||
"comparison": "equal",
|
||||
"source": "response_json",
|
||||
"value": "{{ISVUserid}}",
|
||||
"property": "id"
|
||||
}
|
||||
],
|
||||
"variables": [],
|
||||
"scripts": [],
|
||||
"before_scripts": []
|
||||
},
|
||||
{
|
||||
"step_type": "pause",
|
||||
"skipped": false,
|
||||
"duration": 5
|
||||
},
|
||||
{
|
||||
"step_type": "request",
|
||||
"skipped": false,
|
||||
"note": "Required Test: Test invalid User by username",
|
||||
"auth": {},
|
||||
"multipart_form": [],
|
||||
"headers": {
|
||||
"Accept-Charset": [
|
||||
"utf-8"
|
||||
],
|
||||
"Content-Type": [
|
||||
"application/scim+json; charset=utf-8"
|
||||
],
|
||||
"Accept": [
|
||||
"application/scim+json"
|
||||
],
|
||||
"Authorization": [
|
||||
"{{auth}}"
|
||||
],
|
||||
"User-Agent": [
|
||||
"OKTA SCIM Integration"
|
||||
]
|
||||
},
|
||||
"method": "GET",
|
||||
"url": "{{SCIMBaseURL}}/Users?filter=userName eq \"{{InvalidUserEmail}}\"",
|
||||
"assertions": [
|
||||
{
|
||||
"comparison": "equal_number",
|
||||
"source": "response_status",
|
||||
"value": "200"
|
||||
},
|
||||
{
|
||||
"comparison": "has_value",
|
||||
"source": "response_json",
|
||||
"value": "urn:ietf:params:scim:api:messages:2.0:ListResponse",
|
||||
"property": "schemas"
|
||||
},
|
||||
{
|
||||
"comparison": "equal",
|
||||
"source": "response_json",
|
||||
"value": "0",
|
||||
"property": "totalResults"
|
||||
}
|
||||
],
|
||||
"variables": [],
|
||||
"scripts": [],
|
||||
"before_scripts": []
|
||||
},
|
||||
{
|
||||
"step_type": "pause",
|
||||
"skipped": false,
|
||||
"duration": 5
|
||||
},
|
||||
{
|
||||
"step_type": "request",
|
||||
"skipped": false,
|
||||
"note": "Required Test: Test invalid User by ID",
|
||||
"auth": {},
|
||||
"multipart_form": [],
|
||||
"headers": {
|
||||
"Accept-Charset": [
|
||||
"utf-8"
|
||||
],
|
||||
"Content-Type": [
|
||||
"application/scim+json; charset=utf-8"
|
||||
],
|
||||
"Authorization": [
|
||||
"{{auth}}"
|
||||
],
|
||||
"Accept": [
|
||||
"application/scim+json"
|
||||
],
|
||||
"User-Agent": [
|
||||
"OKTA SCIM Integration"
|
||||
]
|
||||
},
|
||||
"method": "GET",
|
||||
"url": "{{SCIMBaseURL}}/Users/{{UserIdThatDoesNotExist}}",
|
||||
"assertions": [
|
||||
{
|
||||
"comparison": "equal_number",
|
||||
"source": "response_status",
|
||||
"value": "404"
|
||||
},
|
||||
{
|
||||
"comparison": "not_empty",
|
||||
"source": "response_json",
|
||||
"value": null,
|
||||
"property": "detail"
|
||||
},
|
||||
{
|
||||
"comparison": "has_value",
|
||||
"source": "response_json",
|
||||
"value": "urn:ietf:params:scim:api:messages:2.0:Error",
|
||||
"property": "schemas"
|
||||
}
|
||||
],
|
||||
"variables": [],
|
||||
"scripts": [],
|
||||
"before_scripts": []
|
||||
},
|
||||
{
|
||||
"step_type": "pause",
|
||||
"skipped": false,
|
||||
"duration": 5
|
||||
},
|
||||
{
|
||||
"step_type": "request",
|
||||
"skipped": false,
|
||||
"note": "Required Test: Make sure random user doesn't exist",
|
||||
"auth": {},
|
||||
"multipart_form": [],
|
||||
"headers": {
|
||||
"Accept-Charset": [
|
||||
"utf-8"
|
||||
],
|
||||
"Content-Type": [
|
||||
"application/scim+json; charset=utf-8"
|
||||
],
|
||||
"Authorization": [
|
||||
"{{auth}}"
|
||||
],
|
||||
"Accept": [
|
||||
"application/scim+json"
|
||||
],
|
||||
"User-Agent": [
|
||||
"OKTA SCIM Integration"
|
||||
]
|
||||
},
|
||||
"method": "GET",
|
||||
"url": "{{SCIMBaseURL}}/Users?filter=userName eq \"{{randomEmail}}\"",
|
||||
"assertions": [
|
||||
{
|
||||
"comparison": "equal_number",
|
||||
"source": "response_status",
|
||||
"value": "200"
|
||||
},
|
||||
{
|
||||
"comparison": "equal_number",
|
||||
"source": "response_json",
|
||||
"value": "0",
|
||||
"property": "totalResults"
|
||||
},
|
||||
{
|
||||
"comparison": "has_value",
|
||||
"source": "response_json",
|
||||
"value": "urn:ietf:params:scim:api:messages:2.0:ListResponse",
|
||||
"property": "schemas"
|
||||
}
|
||||
],
|
||||
"variables": [],
|
||||
"scripts": [],
|
||||
"before_scripts": []
|
||||
},
|
||||
{
|
||||
"step_type": "pause",
|
||||
"skipped": false,
|
||||
"duration": 5
|
||||
},
|
||||
{
|
||||
"step_type": "request",
|
||||
"skipped": false,
|
||||
"note": "Required Test: Create Okta user with realistic values",
|
||||
"auth": {},
|
||||
"body": "{\"schemas\":[\"urn:ietf:params:scim:schemas:core:2.0:User\"],\"userName\":\"{{randomUsername}}\",\"name\":{\"givenName\":\"{{randomGivenName}}\",\"familyName\":\"{{randomFamilyName}}\"},\"emails\":[{\"primary\":true,\"value\":\"{{randomEmail}}\",\"type\":\"work\"}],\"displayName\":\"{{randomGivenName}} {{randomFamilyName}}\",\"active\":true}",
|
||||
"form": {},
|
||||
"multipart_form": [],
|
||||
"binary_body": null,
|
||||
"headers": {
|
||||
"Content-Type": [
|
||||
"application/json"
|
||||
],
|
||||
"Authorization": [
|
||||
"{{auth}}"
|
||||
],
|
||||
"Accept": [
|
||||
"application/scim+json; charset=utf-8"
|
||||
]
|
||||
},
|
||||
"method": "POST",
|
||||
"url": "{{SCIMBaseURL}}/Users",
|
||||
"assertions": [
|
||||
{
|
||||
"comparison": "equal_number",
|
||||
"source": "response_status",
|
||||
"value": "201"
|
||||
},
|
||||
{
|
||||
"comparison": "equal",
|
||||
"source": "response_json",
|
||||
"value": "true",
|
||||
"property": "active"
|
||||
},
|
||||
{
|
||||
"comparison": "not_empty",
|
||||
"source": "response_json",
|
||||
"value": null,
|
||||
"property": "id"
|
||||
},
|
||||
{
|
||||
"comparison": "equal",
|
||||
"source": "response_json",
|
||||
"value": "{{randomFamilyName}}",
|
||||
"property": "name.familyName"
|
||||
},
|
||||
{
|
||||
"comparison": "equal",
|
||||
"source": "response_json",
|
||||
"value": "{{randomGivenName}}",
|
||||
"property": "name.givenName"
|
||||
},
|
||||
{
|
||||
"comparison": "contains",
|
||||
"source": "response_json",
|
||||
"value": "urn:ietf:params:scim:schemas:core:2.0:User",
|
||||
"property": "schemas"
|
||||
},
|
||||
{
|
||||
"comparison": "equal",
|
||||
"source": "response_json",
|
||||
"value": "{{randomUsername}}",
|
||||
"property": "userName"
|
||||
}
|
||||
],
|
||||
"variables": [
|
||||
{
|
||||
"source": "response_json",
|
||||
"name": "idUserOne",
|
||||
"property": "id"
|
||||
},
|
||||
{
|
||||
"source": "response_json",
|
||||
"name": "randomUserEmail",
|
||||
"property": "emails[0].value"
|
||||
}
|
||||
],
|
||||
"scripts": [
|
||||
""
|
||||
],
|
||||
"before_scripts": []
|
||||
},
|
||||
{
|
||||
"step_type": "pause",
|
||||
"skipped": false,
|
||||
"duration": 5
|
||||
},
|
||||
{
|
||||
"step_type": "request",
|
||||
"skipped": false,
|
||||
"note": "Required Test: Verify that user was created",
|
||||
"auth": {},
|
||||
"multipart_form": [],
|
||||
"headers": {
|
||||
"Accept-Charset": [
|
||||
"utf-8"
|
||||
],
|
||||
"Content-Type": [
|
||||
"application/scim+json; charset=utf-8"
|
||||
],
|
||||
"Authorization": [
|
||||
"{{auth}}"
|
||||
],
|
||||
"Accept": [
|
||||
"application/scim+json"
|
||||
],
|
||||
"User-Agent": [
|
||||
"OKTA SCIM Integration"
|
||||
]
|
||||
},
|
||||
"method": "GET",
|
||||
"url": "{{SCIMBaseURL}}/Users/{{idUserOne}}",
|
||||
"assertions": [
|
||||
{
|
||||
"comparison": "equal_number",
|
||||
"source": "response_status",
|
||||
"value": "200"
|
||||
},
|
||||
{
|
||||
"comparison": "equal",
|
||||
"source": "response_json",
|
||||
"value": "{{randomUsername}}",
|
||||
"property": "userName"
|
||||
},
|
||||
{
|
||||
"comparison": "equal",
|
||||
"source": "response_json",
|
||||
"value": "{{randomFamilyName}}",
|
||||
"property": "name.familyName"
|
||||
},
|
||||
{
|
||||
"comparison": "equal",
|
||||
"source": "response_json",
|
||||
"value": "{{randomGivenName}}",
|
||||
"property": "name.givenName"
|
||||
}
|
||||
],
|
||||
"variables": [],
|
||||
"scripts": [],
|
||||
"before_scripts": []
|
||||
},
|
||||
{
|
||||
"step_type": "pause",
|
||||
"skipped": false,
|
||||
"duration": 10
|
||||
},
|
||||
{
|
||||
"step_type": "request",
|
||||
"skipped": false,
|
||||
"note": "Required Test: Expect failure when recreating user with same values",
|
||||
"auth": {},
|
||||
"body": "{\"schemas\":[\"urn:ietf:params:scim:schemas:core:2.0:User\"],\"userName\":\"{{randomUsername}}\",\"name\":{\"givenName\":\"{{randomGivenName}}\",\"familyName\":\"{{randomFamilyName}}\"},\"emails\":[{\"primary\":true,\"value\":\"{{randomUsername}}\",\"type\":\"work\"}],\"displayName\":\"{{randomGivenName}} {{randomFamilyName}}\",\"active\":true}",
|
||||
"form": {},
|
||||
"multipart_form": [],
|
||||
"binary_body": null,
|
||||
"headers": {
|
||||
"Content-Type": [
|
||||
"application/json"
|
||||
],
|
||||
"Authorization": [
|
||||
"{{auth}}"
|
||||
],
|
||||
"Accept": [
|
||||
"application/scim+json; charset=utf-8"
|
||||
]
|
||||
},
|
||||
"method": "POST",
|
||||
"url": "{{SCIMBaseURL}}/Users",
|
||||
"assertions": [
|
||||
{
|
||||
"comparison": "equal_number",
|
||||
"source": "response_status",
|
||||
"value": "409"
|
||||
}
|
||||
],
|
||||
"variables": [],
|
||||
"scripts": [],
|
||||
"before_scripts": []
|
||||
},
|
||||
{
|
||||
"step_type": "pause",
|
||||
"skipped": false,
|
||||
"duration": 5
|
||||
},
|
||||
{
|
||||
"step_type": "request",
|
||||
"skipped": false,
|
||||
"note": "Required Test: Username Case Sensitivity Check",
|
||||
"auth": {},
|
||||
"multipart_form": [],
|
||||
"headers": {
|
||||
"Accept-Charset": [
|
||||
"utf-8"
|
||||
],
|
||||
"Content-Type": [
|
||||
"application/scim+json; charset=utf-8"
|
||||
],
|
||||
"Authorization": [
|
||||
"{{auth}}"
|
||||
],
|
||||
"Accept": [
|
||||
"application/scim+json"
|
||||
],
|
||||
"User-Agent": [
|
||||
"OKTA SCIM Integration"
|
||||
]
|
||||
},
|
||||
"method": "GET",
|
||||
"url": "{{SCIMBaseURL}}/Users?filter=userName eq \"{{randomUsernameCaps}}\"",
|
||||
"assertions": [
|
||||
{
|
||||
"comparison": "equal_number",
|
||||
"source": "response_status",
|
||||
"value": "200"
|
||||
}
|
||||
],
|
||||
"variables": [],
|
||||
"scripts": [],
|
||||
"before_scripts": []
|
||||
},
|
||||
{
|
||||
"step_type": "pause",
|
||||
"skipped": false,
|
||||
"duration": 5
|
||||
},
|
||||
{
|
||||
"step_type": "request",
|
||||
"skipped": false,
|
||||
"note": "Optional Test: Verify Groups endpoint",
|
||||
"auth": {},
|
||||
"multipart_form": [],
|
||||
"headers": {
|
||||
"Accept-Charset": [
|
||||
"utf-8"
|
||||
],
|
||||
"Content-Type": [
|
||||
"application/scim+json; charset=utf-8"
|
||||
],
|
||||
"Accept": [
|
||||
"application/scim+json"
|
||||
],
|
||||
"Authorization": [
|
||||
"{{auth}}"
|
||||
],
|
||||
"User-Agent": [
|
||||
"OKTA SCIM Integration"
|
||||
]
|
||||
},
|
||||
"method": "GET",
|
||||
"url": "{{SCIMBaseURL}}/Groups",
|
||||
"assertions": [
|
||||
{
|
||||
"comparison": "equal_number",
|
||||
"source": "response_status",
|
||||
"value": "200"
|
||||
},
|
||||
{
|
||||
"comparison": "is_less_than",
|
||||
"source": "response_time",
|
||||
"value": "600"
|
||||
}
|
||||
],
|
||||
"variables": [],
|
||||
"scripts": [
|
||||
"var data = JSON.parse(response.body);\nvar max = data.totalResults;\nvar res = data.Resources;\nvar exists = false;\n\nif (max === 0)\n\tassert(\"nogroups\", \"No Groups found in the endpoint\");\nelse if (max >= 1 && Array.isArray(res)) {\n exists = true;\n assert.ok(exists, \"Resources is of type Array\");\n\tlog(exists);\n}"
|
||||
],
|
||||
"before_scripts": []
|
||||
},
|
||||
{
|
||||
"step_type": "pause",
|
||||
"skipped": false,
|
||||
"duration": 5
|
||||
},
|
||||
{
|
||||
"step_type": "request",
|
||||
"skipped": false,
|
||||
"note": "Required Test: Check status 401",
|
||||
"multipart_form": [],
|
||||
"headers": {
|
||||
"Accept": [
|
||||
"application/scim+json"
|
||||
],
|
||||
"Accept-Charset": [
|
||||
"utf-8"
|
||||
],
|
||||
"Authorization": [
|
||||
"non-token"
|
||||
],
|
||||
"Content-Type": [
|
||||
"application/scim+json; charset=utf-8"
|
||||
],
|
||||
"User-Agent": [
|
||||
"OKTA SCIM Integration"
|
||||
]
|
||||
},
|
||||
"auth": {},
|
||||
"method": "GET",
|
||||
"url": "{{SCIMBaseURL}}/Users?filter=userName eq \"{{randomUsernameCaps}}\"",
|
||||
"assertions": [
|
||||
{
|
||||
"comparison": "equal_number",
|
||||
"source": "response_status",
|
||||
"value": "401"
|
||||
},
|
||||
{
|
||||
"comparison": "not_empty",
|
||||
"source": "response_json",
|
||||
"value": null,
|
||||
"property": "detail"
|
||||
},
|
||||
{
|
||||
"comparison": "equal",
|
||||
"source": "response_json",
|
||||
"value": "401",
|
||||
"property": "status"
|
||||
},
|
||||
{
|
||||
"comparison": "has_value",
|
||||
"source": "response_json",
|
||||
"value": "urn:ietf:params:scim:api:messages:2.0:Error",
|
||||
"property": "schemas"
|
||||
}
|
||||
],
|
||||
"variables": [],
|
||||
"scripts": [],
|
||||
"before_scripts": []
|
||||
},
|
||||
{
|
||||
"step_type": "pause",
|
||||
"skipped": false,
|
||||
"duration": 5
|
||||
},
|
||||
{
|
||||
"step_type": "request",
|
||||
"skipped": false,
|
||||
"note": "Required Test: Check status 404",
|
||||
"multipart_form": [],
|
||||
"headers": {
|
||||
"Accept": [
|
||||
"application/scim+json"
|
||||
],
|
||||
"Accept-Charset": [
|
||||
"utf-8"
|
||||
],
|
||||
"Authorization": [
|
||||
"{{auth}}"
|
||||
],
|
||||
"Content-Type": [
|
||||
"application/scim+json; charset=utf-8"
|
||||
],
|
||||
"User-Agent": [
|
||||
"OKTA SCIM Integration"
|
||||
]
|
||||
},
|
||||
"auth": {},
|
||||
"method": "GET",
|
||||
"url": "{{SCIMBaseURL}}/Users/00919288221112222",
|
||||
"assertions": [
|
||||
{
|
||||
"comparison": "equal_number",
|
||||
"source": "response_status",
|
||||
"value": "404"
|
||||
},
|
||||
{
|
||||
"comparison": "not_empty",
|
||||
"source": "response_json",
|
||||
"value": null,
|
||||
"property": "detail"
|
||||
},
|
||||
{
|
||||
"comparison": "equal",
|
||||
"source": "response_json",
|
||||
"value": "404",
|
||||
"property": "status"
|
||||
},
|
||||
{
|
||||
"comparison": "has_value",
|
||||
"source": "response_json",
|
||||
"value": "urn:ietf:params:scim:api:messages:2.0:Error",
|
||||
"property": "schemas"
|
||||
}
|
||||
],
|
||||
"variables": [],
|
||||
"scripts": [],
|
||||
"before_scripts": []
|
||||
}
|
||||
]
|
||||
}
|
|
@ -300,6 +300,7 @@ export default function CreateKeyPage() {
|
|||
accessToken={accessToken}
|
||||
showSSOBanner={showSSOBanner}
|
||||
premiumUser={premiumUser}
|
||||
proxySettings={proxySettings}
|
||||
/>
|
||||
) : page == "api_ref" ? (
|
||||
<APIRef proxySettings={proxySettings} />
|
||||
|
|
209
ui/litellm-dashboard/src/components/SCIM.tsx
Normal file
209
ui/litellm-dashboard/src/components/SCIM.tsx
Normal file
|
@ -0,0 +1,209 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
Title,
|
||||
Text,
|
||||
Grid,
|
||||
Col,
|
||||
Button as TremorButton,
|
||||
Callout,
|
||||
TextInput,
|
||||
Divider,
|
||||
} from "@tremor/react";
|
||||
import { message, Form } from "antd";
|
||||
import { keyCreateCall } from "./networking";
|
||||
import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||
import {
|
||||
LinkOutlined,
|
||||
KeyOutlined,
|
||||
CopyOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
PlusCircleOutlined
|
||||
} from "@ant-design/icons";
|
||||
|
||||
interface SCIMConfigProps {
|
||||
accessToken: string | null;
|
||||
userID: string | null;
|
||||
proxySettings: any;
|
||||
}
|
||||
|
||||
const SCIMConfig: React.FC<SCIMConfigProps> = ({ accessToken, userID, proxySettings }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [isCreatingToken, setIsCreatingToken] = useState(false);
|
||||
const [tokenData, setTokenData] = useState<any>(null);
|
||||
const [baseUrl, setBaseUrl] = useState("<your_proxy_base_url>");
|
||||
|
||||
useEffect(() => {
|
||||
let url = "<your_proxy_base_url>";
|
||||
|
||||
if (proxySettings && proxySettings.PROXY_BASE_URL && proxySettings.PROXY_BASE_URL !== undefined) {
|
||||
url = proxySettings.PROXY_BASE_URL;
|
||||
} else if (typeof window !== 'undefined') {
|
||||
// Use the current origin as the base URL if no proxy URL is set
|
||||
url = window.location.origin;
|
||||
}
|
||||
|
||||
setBaseUrl(url);
|
||||
}, [proxySettings]);
|
||||
|
||||
const scimBaseUrl = `${baseUrl}/scim/v2`;
|
||||
|
||||
const handleCreateSCIMToken = async (values: any) => {
|
||||
if (!accessToken || !userID) {
|
||||
message.error("You need to be logged in to create a SCIM token");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsCreatingToken(true);
|
||||
|
||||
const formData = {
|
||||
key_alias: values.key_alias || "SCIM Access Token",
|
||||
team_id: null,
|
||||
models: [],
|
||||
allowed_routes: ["/scim/*"],
|
||||
};
|
||||
|
||||
const response = await keyCreateCall(accessToken, userID, formData);
|
||||
setTokenData(response);
|
||||
message.success("SCIM token created successfully");
|
||||
} catch (error) {
|
||||
console.error("Error creating SCIM token:", error);
|
||||
message.error("Failed to create SCIM token");
|
||||
} finally {
|
||||
setIsCreatingToken(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid numItems={1}>
|
||||
<Card>
|
||||
<div className="flex items-center mb-4">
|
||||
<Title>SCIM Configuration</Title>
|
||||
</div>
|
||||
<Text className="text-gray-600">
|
||||
System for Cross-domain Identity Management (SCIM) allows you to automatically provision and manage users and groups in LiteLLM.
|
||||
</Text>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Step 1: SCIM URL */}
|
||||
<div>
|
||||
<div className="flex items-center mb-2">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-blue-100 text-blue-700 mr-2">
|
||||
1
|
||||
</div>
|
||||
<Title className="text-lg flex items-center">
|
||||
<LinkOutlined className="h-5 w-5 mr-2" />
|
||||
SCIM Tenant URL
|
||||
</Title>
|
||||
</div>
|
||||
<Text className="text-gray-600 mb-3">
|
||||
Use this URL in your identity provider SCIM integration settings.
|
||||
</Text>
|
||||
<div className="flex items-center">
|
||||
<TextInput
|
||||
value={scimBaseUrl}
|
||||
disabled={true}
|
||||
className="flex-grow"
|
||||
/>
|
||||
<CopyToClipboard
|
||||
text={scimBaseUrl}
|
||||
onCopy={() => message.success("URL copied to clipboard")}
|
||||
>
|
||||
<TremorButton variant="primary" className="ml-2 flex items-center">
|
||||
<CopyOutlined className="h-4 w-4 mr-1" />
|
||||
Copy
|
||||
</TremorButton>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 2: SCIM Token */}
|
||||
<div>
|
||||
<div className="flex items-center mb-2">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-blue-100 text-blue-700 mr-2">
|
||||
2
|
||||
</div>
|
||||
<Title className="text-lg flex items-center">
|
||||
<KeyOutlined className="h-5 w-5 mr-2" />
|
||||
Authentication Token
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<Callout title="Using SCIM" color="blue" className="mb-4">
|
||||
You need a SCIM token to authenticate with the SCIM API. Create one below and use it in your SCIM provider configuration.
|
||||
</Callout>
|
||||
|
||||
{!tokenData ? (
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleCreateSCIMToken}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item
|
||||
name="key_alias"
|
||||
label="Token Name"
|
||||
rules={[{ required: true, message: "Please enter a name for your token" }]}
|
||||
>
|
||||
<TextInput placeholder="SCIM Access Token" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<TremorButton
|
||||
variant="primary"
|
||||
type="submit"
|
||||
loading={isCreatingToken}
|
||||
className="flex items-center"
|
||||
>
|
||||
<KeyOutlined className="h-4 w-4 mr-1" />
|
||||
Create SCIM Token
|
||||
</TremorButton>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
) : (
|
||||
<Card className="border border-yellow-300 bg-yellow-50">
|
||||
<div className="flex items-center mb-2 text-yellow-800">
|
||||
<ExclamationCircleOutlined className="h-5 w-5 mr-2" />
|
||||
<Title className="text-lg text-yellow-800">Your SCIM Token</Title>
|
||||
</div>
|
||||
<Text className="text-yellow-800 mb-4 font-medium">
|
||||
Make sure to copy this token now. You will not be able to see it again.
|
||||
</Text>
|
||||
<div className="flex items-center">
|
||||
<TextInput
|
||||
value={tokenData.token}
|
||||
className="flex-grow mr-2 bg-white"
|
||||
type="password"
|
||||
disabled={true}
|
||||
/>
|
||||
<CopyToClipboard
|
||||
text={tokenData.token}
|
||||
onCopy={() => message.success("Token copied to clipboard")}
|
||||
>
|
||||
<TremorButton variant="primary" className="flex items-center">
|
||||
<CopyOutlined className="h-4 w-4 mr-1" />
|
||||
Copy
|
||||
</TremorButton>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
<TremorButton
|
||||
className="mt-4 flex items-center"
|
||||
variant="secondary"
|
||||
onClick={() => setTokenData(null)}
|
||||
>
|
||||
<PlusCircleOutlined className="h-4 w-4 mr-1" />
|
||||
Create Another Token
|
||||
</TremorButton>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default SCIMConfig;
|
|
@ -32,12 +32,18 @@ import {
|
|||
Grid,
|
||||
Callout,
|
||||
Divider,
|
||||
TabGroup,
|
||||
TabList,
|
||||
Tab,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
} from "@tremor/react";
|
||||
import { PencilAltIcon } from "@heroicons/react/outline";
|
||||
import OnboardingModal from "./onboarding_link";
|
||||
import { InvitationLink } from "./onboarding_link";
|
||||
import SSOModals from "./SSOModals";
|
||||
import { ssoProviderConfigs } from './SSOModals';
|
||||
import SCIMConfig from "./SCIM";
|
||||
|
||||
interface AdminPanelProps {
|
||||
searchParams: any;
|
||||
|
@ -45,6 +51,7 @@ interface AdminPanelProps {
|
|||
setTeams: React.Dispatch<React.SetStateAction<Team[] | null>>;
|
||||
showSSOBanner: boolean;
|
||||
premiumUser: boolean;
|
||||
proxySettings?: any;
|
||||
}
|
||||
import { useBaseUrl } from "./constants";
|
||||
|
||||
|
@ -67,6 +74,7 @@ const AdminPanel: React.FC<AdminPanelProps> = ({
|
|||
accessToken,
|
||||
showSSOBanner,
|
||||
premiumUser,
|
||||
proxySettings,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [memberForm] = Form.useForm();
|
||||
|
@ -515,45 +523,51 @@ const AdminPanel: React.FC<AdminPanelProps> = ({
|
|||
<div className="w-full m-2 mt-2 p-8">
|
||||
<Title level={4}>Admin Access </Title>
|
||||
<Paragraph>Go to 'Internal Users' page to add other admins.</Paragraph>
|
||||
<Grid >
|
||||
<Card>
|
||||
<Title level={4}> ✨ Security Settings</Title>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', marginTop: '1rem' }}>
|
||||
<div>
|
||||
<Button onClick={() => premiumUser === true ? setIsAddSSOModalVisible(true) : message.error("Only premium users can add SSO")}>Add SSO</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button onClick={handleShowAllowedIPs}>Allowed IPs</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-start mb-4">
|
||||
<SSOModals
|
||||
isAddSSOModalVisible={isAddSSOModalVisible}
|
||||
isInstructionsModalVisible={isInstructionsModalVisible}
|
||||
handleAddSSOOk={handleAddSSOOk}
|
||||
handleAddSSOCancel={handleAddSSOCancel}
|
||||
handleShowInstructions={handleShowInstructions}
|
||||
handleInstructionsOk={handleInstructionsOk}
|
||||
handleInstructionsCancel={handleInstructionsCancel}
|
||||
form={form}
|
||||
/>
|
||||
<Modal
|
||||
title="Manage Allowed IP Addresses"
|
||||
width={800}
|
||||
visible={isAllowedIPModalVisible}
|
||||
onCancel={() => setIsAllowedIPModalVisible(false)}
|
||||
footer={[
|
||||
<Button className="mx-1"key="add" onClick={() => setIsAddIPModalVisible(true)}>
|
||||
Add IP Address
|
||||
</Button>,
|
||||
<Button key="close" onClick={() => setIsAllowedIPModalVisible(false)}>
|
||||
Close
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<Table>
|
||||
<TabGroup>
|
||||
<TabList>
|
||||
<Tab>Security Settings</Tab>
|
||||
<Tab>SCIM</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<Card>
|
||||
<Title level={4}> ✨ Security Settings</Title>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', marginTop: '1rem' }}>
|
||||
<div>
|
||||
<Button onClick={() => premiumUser === true ? setIsAddSSOModalVisible(true) : message.error("Only premium users can add SSO")}>Add SSO</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button onClick={handleShowAllowedIPs}>Allowed IPs</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-start mb-4">
|
||||
<SSOModals
|
||||
isAddSSOModalVisible={isAddSSOModalVisible}
|
||||
isInstructionsModalVisible={isInstructionsModalVisible}
|
||||
handleAddSSOOk={handleAddSSOOk}
|
||||
handleAddSSOCancel={handleAddSSOCancel}
|
||||
handleShowInstructions={handleShowInstructions}
|
||||
handleInstructionsOk={handleInstructionsOk}
|
||||
handleInstructionsCancel={handleInstructionsCancel}
|
||||
form={form}
|
||||
/>
|
||||
<Modal
|
||||
title="Manage Allowed IP Addresses"
|
||||
width={800}
|
||||
visible={isAllowedIPModalVisible}
|
||||
onCancel={() => setIsAllowedIPModalVisible(false)}
|
||||
footer={[
|
||||
<Button className="mx-1"key="add" onClick={() => setIsAddIPModalVisible(true)}>
|
||||
Add IP Address
|
||||
</Button>,
|
||||
<Button key="close" onClick={() => setIsAllowedIPModalVisible(false)}>
|
||||
Close
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>IP Address</TableHeaderCell>
|
||||
|
@ -621,7 +635,16 @@ const AdminPanel: React.FC<AdminPanelProps> = ({
|
|||
<b>{nonSssoUrl}</b>{" "}
|
||||
</a>
|
||||
</Callout>
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<SCIMConfig
|
||||
accessToken={accessToken}
|
||||
userID={admins && admins.length > 0 ? admins[0].user_id : null}
|
||||
proxySettings={proxySettings}
|
||||
/>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue