[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:
Ishaan Jaff 2025-04-16 19:21:47 -07:00 committed by GitHub
parent 7ca553b235
commit 6220f3e7b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 2512 additions and 131 deletions

View file

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "LiteLLM_VerificationToken" ADD COLUMN "allowed_routes" TEXT[] DEFAULT ARRAY[]::TEXT[];

View file

@ -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?

View file

@ -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

View file

@ -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,

View file

@ -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:
"""

View file

@ -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 []

View 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"
}
]
}
```

View 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

View 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)}"}
)

View file

@ -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/

View file

@ -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)

View file

@ -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?

View 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]

View file

@ -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?

View file

@ -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

View 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": []
}
]
}

View file

@ -300,6 +300,7 @@ export default function CreateKeyPage() {
accessToken={accessToken}
showSSOBanner={showSSOBanner}
premiumUser={premiumUser}
proxySettings={proxySettings}
/>
) : page == "api_ref" ? (
<APIRef proxySettings={proxySettings} />

View 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;

View file

@ -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 &apos;Internal Users&apos; 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>
);
};