diff --git a/backend/routes/allowlist.py b/backend/routes/allowlist.py index 00c4e0070e6..3794be05e9b 100644 --- a/backend/routes/allowlist.py +++ b/backend/routes/allowlist.py @@ -22,6 +22,7 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = ( "/customer/", "/end_user/", "/sso/", + "/cli/session/", "/login", "/v2/login", "/v3/login", diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260813210000_add_cli_session_table/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260813210000_add_cli_session_table/migration.sql new file mode 100644 index 00000000000..fb59ffec528 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260813210000_add_cli_session_table/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "LiteLLM_CLISessionTable" ( + "session_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "team_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + "revoked_at" TIMESTAMP(3), + "revoked_by" TEXT, + + CONSTRAINT "LiteLLM_CLISessionTable_pkey" PRIMARY KEY ("session_id") +); + +-- CreateIndex +CREATE INDEX "LiteLLM_CLISessionTable_user_id_idx" ON "LiteLLM_CLISessionTable"("user_id"); + +-- CreateIndex +CREATE INDEX "LiteLLM_CLISessionTable_expires_at_idx" ON "LiteLLM_CLISessionTable"("expires_at"); diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index d9959677116..36c9040eb01 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -412,6 +412,23 @@ model LiteLLM_SSOIdentityAssertion { updated_at DateTime @default(now()) @updatedAt @map("updated_at") } +// One row per `lite login` session. The CLI credential is a self-contained encrypted +// blob rather than a virtual key, so this registry is what makes a session listable +// and revocable. session_id is the sha256 of the session token, matching how +// LiteLLM_VerificationToken stores its token. +model LiteLLM_CLISessionTable { + session_id String @id + user_id String + team_id String? + created_at DateTime @default(now()) @map("created_at") + expires_at DateTime @map("expires_at") + revoked_at DateTime? @map("revoked_at") + revoked_by String? + + @@index([user_id]) + @@index([expires_at]) +} + // Generate Tokens for Proxy model LiteLLM_VerificationToken { token String @id diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 5d52af3b364..2b4969de892 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -3146,6 +3146,7 @@ class ExperimentalUIJWTToken: team_models: Sequence[str] | None = None, team_model_aliases: Mapping[str, str] | None = None, max_budget: float | None = None, + session_id: str | None = None, ) -> str: """ Generate a JWT token for CLI authentication with configurable expiration. @@ -3159,6 +3160,8 @@ class ExperimentalUIJWTToken: team_alias: Team alias for the selected team, if available team_models: Model allowlist granted by the selected team team_model_aliases: Team model aliases for the selected team + session_id: Session token to embed, so the caller can register the session + in the CLI session registry before handing the credential out Returns: Encrypted JWT token string @@ -3185,7 +3188,7 @@ class ExperimentalUIJWTToken: # Use first team if user has teams _team_id = user_info.teams[0] if len(user_info.teams) > 0 else None - session_token: Final = f"{CLI_SESSION_KEY_PREFIX}-{secrets.token_urlsafe(16)}" + session_token: Final = session_id or f"{CLI_SESSION_KEY_PREFIX}-{secrets.token_urlsafe(16)}" session_alias: Final = f"{CLI_SESSION_KEY_PREFIX}-{user_info.user_id}" valid_token: Final = UserAPIKeyAuth( diff --git a/litellm/proxy/auth/cli_session_registry.py b/litellm/proxy/auth/cli_session_registry.py new file mode 100644 index 00000000000..f66d5432d7a --- /dev/null +++ b/litellm/proxy/auth/cli_session_registry.py @@ -0,0 +1,204 @@ +"""Registry of `lite login` sessions. + +The CLI credential is a self-contained encrypted ``UserAPIKeyAuth`` blob rather than a +virtual key, so nothing about it is stored server side and the auth path authenticates +it by decrypting it. This registry is the server-side record that makes a session +listable and revocable: one row per login, keyed by the sha256 of the session token. + +A session with no row predates the registry and still authenticates until it expires; +only a row with ``revoked_at`` set is refused. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from datetime import timedelta +from typing import TYPE_CHECKING, Final, Protocol + +from litellm.constants import CLI_JWT_EXPIRATION_HOURS, DEFAULT_IN_MEMORY_TTL +from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler +from litellm.proxy.utils import PrismaClient, hash_token +from litellm.repositories.table_repositories import CLISessionRepository +from litellm.types.cli_session import CLISessionListResponse, CLISessionResponse +from litellm.utils import get_utc_datetime + +if TYPE_CHECKING: + from litellm.caching.dual_cache import DualCache + +_REVOCATION_CACHE_KEY_PREFIX: Final = "cli_session_revoked" + + +class _CLISessionRecord(Protocol): + def model_dump(self) -> Mapping[str, object]: ... + + +class _CLISessionTable(Protocol): + async def find_unique(self, where: Mapping[str, object]) -> _CLISessionRecord | None: ... + + async def find_many( + self, + where: Mapping[str, object], + order: Mapping[str, object], + skip: int, + take: int, + ) -> Sequence[_CLISessionRecord]: ... + + async def count(self, where: Mapping[str, object]) -> int: ... + + async def create(self, data: Mapping[str, object]) -> _CLISessionRecord: ... + + async def update(self, where: Mapping[str, object], data: Mapping[str, object]) -> _CLISessionRecord | None: ... + + +def _cli_session_table(prisma_client: PrismaClient) -> _CLISessionTable: + table: Final[_CLISessionTable] = CLISessionRepository(prisma_client).table + return table + + +def _revocation_cache_key(session_id: str) -> str: + return f"{_REVOCATION_CACHE_KEY_PREFIX}:{session_id}" + + +def cli_session_id(session_token: str) -> str: + """The registry id for a CLI session token. Never the token itself.""" + return hash_token(session_token) + + +async def record_cli_session( + *, + prisma_client: PrismaClient, + session_token: str, + user_id: str, + team_id: str | None, +) -> CLISessionResponse: + """Register a freshly minted session. Raises if the row cannot be written, so a + session that could never be revoked is never handed to the CLI.""" + created: Final = await _cli_session_table(prisma_client).create( + data={ # mutable-ok: prisma payloads are plain dicts + "session_id": cli_session_id(session_token), + "user_id": user_id, + "team_id": team_id, + "expires_at": get_utc_datetime() + timedelta(hours=CLI_JWT_EXPIRATION_HOURS), + } + ) + return CLISessionResponse.model_validate(created.model_dump()) + + +async def is_cli_session_revoked( + *, + session_token: str, + prisma_client: PrismaClient | None, + user_api_key_cache: DualCache, +) -> bool: + """Whether an operator has revoked this session. + + Cached for ``DEFAULT_IN_MEMORY_TTL`` so a session costs one lookup per cache + interval per replica rather than one per request. That TTL is also the bound on + how long a revocation takes to reach a replica that did not serve the revoke. + + A lookup that cannot reach the database follows the proxy-wide + ``allow_requests_on_db_unavailable`` posture rather than inventing its own: an + operator who opted into serving during an outage keeps serving CLI sessions, + and one who did not gets the same failure every other DB-backed auth read gives. + """ + if prisma_client is None: + return False + + session_id: Final = cli_session_id(session_token) + cache_key: Final = _revocation_cache_key(session_id) + cached: Final = await user_api_key_cache.async_get_cache(key=cache_key) + if cached is not None: + return bool(cached) + + try: + session: Final = await _get_cli_session(prisma_client=prisma_client, session_id=session_id) + except Exception as e: # noqa: BLE001 # handle_db_exception takes any exception and re-raises what it does not recognise + PrismaDBExceptionHandler.handle_db_exception(e) + return False + + revoked: Final = session is not None and session.revoked_at is not None + await user_api_key_cache.async_set_cache(key=cache_key, value=revoked, ttl=DEFAULT_IN_MEMORY_TTL) + return revoked + + +async def _get_cli_session(*, prisma_client: PrismaClient, session_id: str) -> CLISessionResponse | None: + record: Final = await _cli_session_table(prisma_client).find_unique( + where={"session_id": session_id} # mutable-ok: prisma query filters are dict-shaped + ) + return None if record is None else CLISessionResponse.model_validate(record.model_dump()) + + +async def list_cli_sessions( + *, + prisma_client: PrismaClient, + page: int, + page_size: int, +) -> CLISessionListResponse: + """Sessions that have not expired yet, newest first. Expired rows are dead weight: + the blob's own expiry already refuses them.""" + where: Final[Mapping[str, object]] = { # mutable-ok: prisma query filters are dict-shaped + "expires_at": {"gt": get_utc_datetime()} # mutable-ok: prisma query filters are dict-shaped + } + table: Final = _cli_session_table(prisma_client) + records: Final = await table.find_many( + where=where, + order={"created_at": "desc"}, # mutable-ok: prisma order is a plain dict + skip=(page - 1) * page_size, + take=page_size, + ) + return CLISessionListResponse( + sessions=tuple(CLISessionResponse.model_validate(record.model_dump()) for record in records), + total_count=await table.count(where=where), + ) + + +async def revoke_cli_session( + *, + prisma_client: PrismaClient, + user_api_key_cache: DualCache, + session_id: str, + revoked_by: str | None, +) -> CLISessionResponse | None: + """Revoke a session, or return ``None`` if no such session is registered. + + Re-revoking keeps the original ``revoked_at`` so the audit trail records when + access was actually cut off. + """ + existing: Final = await _get_cli_session(prisma_client=prisma_client, session_id=session_id) + if existing is None: + return None + + revoked: Final = ( + existing + if existing.revoked_at is not None + else await _mark_cli_session_revoked( + prisma_client=prisma_client, + session_id=session_id, + revoked_by=revoked_by, + ) + ) + if revoked is None: + return None + + await user_api_key_cache.async_set_cache( + key=_revocation_cache_key(session_id), + value=True, + ttl=DEFAULT_IN_MEMORY_TTL, + ) + return revoked + + +async def _mark_cli_session_revoked( + *, + prisma_client: PrismaClient, + session_id: str, + revoked_by: str | None, +) -> CLISessionResponse | None: + record: Final = await _cli_session_table(prisma_client).update( + where={"session_id": session_id}, # mutable-ok: prisma query filters are dict-shaped + data={ # mutable-ok: prisma payloads are plain dicts + "revoked_at": get_utc_datetime(), + "revoked_by": revoked_by, + }, + ) + return None if record is None else CLISessionResponse.model_validate(record.model_dump()) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 28d76e6799c..17f310b1b59 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -70,6 +70,7 @@ from litellm.proxy.auth.auth_utils import ( pre_db_read_auth_checks, route_in_additonal_public_routes, ) +from litellm.proxy.auth.cli_session_registry import is_cli_session_revoked from litellm.proxy.auth.handle_jwt import JWTAuthManager, JWTHandler from litellm.proxy.auth.network import TrustedProxyConfig, resolve_network_context from litellm.proxy.auth.oauth2_check import Oauth2Handler @@ -1726,6 +1727,23 @@ async def _user_api_key_auth_builder( ): valid_token = ExperimentalUIJWTToken.get_key_object_from_ui_hash_key(api_key) + if ( + valid_token is not None + and valid_token.is_session_token + and valid_token.token is not None + and await is_cli_session_revoked( + session_token=valid_token.token, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + ) + ): + raise ProxyException( + message="Authentication Error - This CLI session was revoked. Run `lite login` to start a new one.", + type=ProxyErrorTypes.auth_error, + code=status.HTTP_401_UNAUTHORIZED, + param=abbreviate_api_key(api_key=api_key), + ) + if ( valid_token is not None and isinstance(valid_token, UserAPIKeyAuth) diff --git a/litellm/proxy/client/README.md b/litellm/proxy/client/README.md index 1fff68677cc..89b380ab7ba 100644 --- a/litellm/proxy/client/README.md +++ b/litellm/proxy/client/README.md @@ -382,7 +382,7 @@ Keychain storage needs the `keyring` package, which ships with `pip install 'lit `lite logout` clears both stores. If the keychain is locked at that moment it says so, and re-running it once the keychain is unlocked finishes the job. -The stored credential is a short-lived, per-session agent token, not a managed virtual key. It is scoped to the user and team you logged in as and inherits their models and budgets; spend is tracked against the shared team and user budgets rather than a separate per-session cap, so multiple logins or several concurrent agents all draw down the same allowance. It is short-lived by design (default 24h, configurable via `LITELLM_CLI_JWT_EXPIRATION_HOURS`); re-run `lite login` to refresh it and pick up your latest team and user settings. `lite auth print-token` (usable as Claude Code's `apiKeyHelper`) prints it while fresh and fails once it expires -- there is no silent renewal. It is accepted on a default deployment without `EXPERIMENTAL_UI_LOGIN`, does not appear in the Keys UI, and cannot be rotated or revoked mid-session. A credential from `lite login --pkce` is the exception: it carries a refresh token, so the CLI renews the key shortly before it expires and `lite logout` revokes the refresh token on the proxy (see [Browser sign-in with PKCE](https://docs.litellm.ai/docs/proxy/cli_sso#browser-sign-in-with-pkce)). Only the holder can end a `--pkce` session early, with `lite logout`; an admin has no button for it, but every renewal re-reads the user on the proxy, so deactivating the user or removing them from the team makes the next renewal fail and the key runs out within `LITELLM_CLI_JWT_EXPIRATION_HOURS`. On a proxy with more than one worker or replica, configure Redis (`litellm_settings.cache` with Redis `cache_params`, or `general_settings.coordination_redis`) so a refresh token stays single-use and `lite logout` holds on every worker; without Redis each worker keeps its own record. For a long-lived, rotatable, Keys-UI-visible credential, create a dedicated virtual key in the dashboard and pass it via `--api-key` or `LITELLM_PROXY_API_KEY`. +The stored credential is a short-lived, per-session agent token, not a managed virtual key. It is scoped to the user and team you logged in as and inherits their models and budgets; spend is tracked against the shared team and user budgets rather than a separate per-session cap, so multiple logins or several concurrent agents all draw down the same allowance. It is short-lived by design (default 24h, configurable via `LITELLM_CLI_JWT_EXPIRATION_HOURS`); re-run `lite login` to refresh it and pick up your latest team and user settings. `lite auth print-token` (usable as Claude Code's `apiKeyHelper`) prints it while fresh and fails once it expires -- there is no silent renewal. It is accepted on a default deployment without `EXPERIMENTAL_UI_LOGIN`, does not appear in the Keys UI, and cannot be rotated, though an admin can list it under CLI Sessions in the dashboard and revoke it mid-session. A credential from `lite login --pkce` is the exception: it carries a refresh token, so the CLI renews the key shortly before it expires and `lite logout` revokes the refresh token on the proxy (see [Browser sign-in with PKCE](https://docs.litellm.ai/docs/proxy/cli_sso#browser-sign-in-with-pkce)). A `--pkce` session is not one of the rows the CLI Sessions page lists, so only the holder can end it early, with `lite logout`; an admin has no revoke button for it, but every renewal re-reads the user on the proxy, so deactivating the user or removing them from the team makes the next renewal fail and the key runs out within `LITELLM_CLI_JWT_EXPIRATION_HOURS`. On a proxy with more than one worker or replica, configure Redis (`litellm_settings.cache` with Redis `cache_params`, or `general_settings.coordination_redis`) so a refresh token stays single-use and `lite logout` holds on every worker; without Redis each worker keeps its own record. For a long-lived, rotatable, Keys-UI-visible credential, create a dedicated virtual key in the dashboard and pass it via `--api-key` or `LITELLM_PROXY_API_KEY`. ### Usage diff --git a/litellm/proxy/client/cli/README.md b/litellm/proxy/client/cli/README.md index fe417396317..1d87456f380 100644 --- a/litellm/proxy/client/cli/README.md +++ b/litellm/proxy/client/cli/README.md @@ -501,7 +501,7 @@ To pin the model, pass the agent's own model flag (for example `lite claude --mo The token minted by `lite login` is a short-lived, per-session agent credential, not a managed virtual key. It is scoped to the user and team you authenticated as, inherits that user's and team's models and budgets, and is enforced on the proxy exactly like a virtual key on the same team (guardrails, routing, logging, spend). Spend is tracked against the shared team and user budgets, so running several agents (or logging in more than once) does not hand each session its own separate budget; they all draw down the same team/user allowance. There is no separate per-session cap, so sustained agent use is not capped at a small chat-session limit. -The credential is short-lived by design (default 24h, configurable via `LITELLM_CLI_JWT_EXPIRATION_HOURS`); run `lite login` again to refresh it, which also re-reads your latest team and user settings. It does not appear in the Keys UI and cannot be rotated or revoked mid-session. `lite auth print-token` (usable as Claude Code's `apiKeyHelper`) prints it while it's still fresh and fails once it expires -- there is no silent renewal, so a long-running session needs a fresh `lite login` once a day. `lite claude`, `lite codex`, and `lite opencode` work with it on a default deployment; `EXPERIMENTAL_UI_LOGIN` is not required. `lite login --pkce` is the exception to the daily re-login: it signs in through your system browser with OAuth authorization code and PKCE and stores a refresh token next to the key, so every `lite` command and `lite auth print-token` renew the key on their own shortly before it expires, `lite whoami` shows when the current key expires, and `lite logout` revokes the refresh token on the proxy (it needs a proxy that serves `/.well-known/litellm-cli-auth`; see [Browser sign-in with PKCE](https://docs.litellm.ai/docs/proxy/cli_sso#browser-sign-in-with-pkce)). When a renewal is refused, for example after a `lite logout` run from another copy of the credential, the command prints why on stderr and, once the key has run out, tells you to run `lite login --pkce` again. Only the holder can end a `--pkce` session early, with `lite logout`; an admin has no button for it, but every renewal re-reads the user on the proxy, so deactivating the user or removing them from the team makes the next renewal fail and the key runs out within `LITELLM_CLI_JWT_EXPIRATION_HOURS`. On a proxy with more than one worker or replica, configure Redis (`litellm_settings.cache` with Redis `cache_params`, or `general_settings.coordination_redis`) so a refresh token stays single-use and `lite logout` holds on every worker; without Redis each worker keeps its own record. If you need a long-lived, rotatable key that shows up in the Keys UI, create a dedicated virtual key in the dashboard and pass it via `--api-key` or `LITELLM_PROXY_API_KEY` instead. +The credential is short-lived by design (default 24h, configurable via `LITELLM_CLI_JWT_EXPIRATION_HOURS`); run `lite login` again to refresh it, which also re-reads your latest team and user settings. It does not appear in the Keys UI and cannot be rotated, but an admin can list it under CLI Sessions in the dashboard and revoke it mid-session, which stops it authenticating within one cache interval. `lite auth print-token` (usable as Claude Code's `apiKeyHelper`) prints it while it's still fresh and fails once it expires -- there is no silent renewal, so a long-running session needs a fresh `lite login` once a day. `lite claude`, `lite codex`, and `lite opencode` work with it on a default deployment; `EXPERIMENTAL_UI_LOGIN` is not required. `lite login --pkce` is the exception to the daily re-login: it signs in through your system browser with OAuth authorization code and PKCE and stores a refresh token next to the key, so every `lite` command and `lite auth print-token` renew the key on their own shortly before it expires, `lite whoami` shows when the current key expires, and `lite logout` revokes the refresh token on the proxy (it needs a proxy that serves `/.well-known/litellm-cli-auth`; see [Browser sign-in with PKCE](https://docs.litellm.ai/docs/proxy/cli_sso#browser-sign-in-with-pkce)). When a renewal is refused, for example after a `lite logout` run from another copy of the credential, the command prints why on stderr and, once the key has run out, tells you to run `lite login --pkce` again. A `--pkce` session is not one of the rows the CLI Sessions page lists, so only the holder can end it early, with `lite logout`; an admin has no revoke button for it, but every renewal re-reads the user on the proxy, so deactivating the user or removing them from the team makes the next renewal fail and the key runs out within `LITELLM_CLI_JWT_EXPIRATION_HOURS`. On a proxy with more than one worker or replica, configure Redis (`litellm_settings.cache` with Redis `cache_params`, or `general_settings.coordination_redis`) so a refresh token stays single-use and `lite logout` holds on every worker; without Redis each worker keeps its own record. If you need a long-lived, rotatable key that shows up in the Keys UI, create a dedicated virtual key in the dashboard and pass it via `--api-key` or `LITELLM_PROXY_API_KEY` instead. ### Route Every Claude Code Session Through the Proxy diff --git a/litellm/proxy/management_endpoints/cli_session_endpoints.py b/litellm/proxy/management_endpoints/cli_session_endpoints.py new file mode 100644 index 00000000000..9e5e5aa75f7 --- /dev/null +++ b/litellm/proxy/management_endpoints/cli_session_endpoints.py @@ -0,0 +1,62 @@ +"""Operator surface for `lite login` sessions: list them, and cut one off mid-session.""" + +from typing import Annotated, Final + +from fastapi import APIRouter, Depends, HTTPException, Query, status + +from litellm.proxy._types import ( + CommonProxyErrors, + LitellmUserRoles, + UserAPIKeyAuth, + user_api_key_has_admin_view, +) +from litellm.proxy.auth.cli_session_registry import list_cli_sessions, revoke_cli_session +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.utils import get_prisma_client_or_throw +from litellm.types.cli_session import CLISessionListResponse, CLISessionResponse + +router: Final = APIRouter() + + +@router.get("/cli/session/list", response_model=CLISessionListResponse) +async def list_cli_sessions_endpoint( + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + page: Annotated[int, Query(ge=1)] = 1, + page_size: Annotated[int, Query(ge=1, le=100)] = 50, +) -> CLISessionListResponse: + if not user_api_key_has_admin_view(user_api_key_dict): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=CommonProxyErrors.not_allowed_access.value, + ) + return await list_cli_sessions( + prisma_client=get_prisma_client_or_throw(CommonProxyErrors.db_not_connected_error.value), + page=page, + page_size=page_size, + ) + + +@router.post("/cli/session/{session_id}/revoke", response_model=CLISessionResponse) +async def revoke_cli_session_endpoint( + session_id: str, + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], +) -> CLISessionResponse: + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=CommonProxyErrors.not_allowed_access.value, + ) + from litellm.proxy.proxy_server import user_api_key_cache + + revoked: Final = await revoke_cli_session( + prisma_client=get_prisma_client_or_throw(CommonProxyErrors.db_not_connected_error.value), + user_api_key_cache=user_api_key_cache, + session_id=session_id, + revoked_by=user_api_key_dict.user_id, + ) + if revoked is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"CLI session not found: {session_id}", + ) + return revoked diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index 3c135650de9..bb583ab19c3 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -49,6 +49,7 @@ from litellm._logging import verbose_proxy_logger from litellm._uuid import uuid from litellm.caching.dual_cache import DualCache from litellm.constants import ( + CLI_SESSION_KEY_PREFIX, CLI_SSO_CLAIM_MAP, CLI_SSO_CLAIM_MAX_SCALAR_LENGTH, CLI_SSO_SESSION_CACHE_KEY_PREFIX, @@ -92,6 +93,7 @@ from litellm.proxy.auth.auth_utils import ( _get_request_ip_address, _has_user_setup_sso, ) +from litellm.proxy.auth.cli_session_registry import record_cli_session from litellm.proxy.auth.handle_jwt import JWTHandler from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_utils.admin_ui_utils import ( @@ -120,6 +122,7 @@ from litellm.proxy.utils import ( PrismaClient, ProxyLogging, get_custom_url, + get_prisma_client_or_throw, get_server_root_path, ) from litellm.repositories.table_repositories import SSOConfigRepository @@ -2540,6 +2543,7 @@ async def cli_poll_key( models=session_data.get("models", []), ) + session_token: Final = f"{CLI_SESSION_KEY_PREFIX}-{secrets.token_urlsafe(16)}" jwt_token: Final = ExperimentalUIJWTToken.get_cli_jwt_auth_token( user_info=user_info, team_id=team_id, @@ -2547,6 +2551,16 @@ async def cli_poll_key( team_models=selected_team.team_models, team_model_aliases=selected_team.team_model_aliases, max_budget=None, + session_id=session_token, + ) + + # A session we cannot register is a session no operator could ever revoke, + # so registration failing has to stop the credential being handed out. + await record_cli_session( + prisma_client=get_prisma_client_or_throw(CommonProxyErrors.db_not_connected_error.value), + session_token=session_token, + user_id=user_id, + team_id=team_id, ) # Delete cache entry (single-use) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 0abcdeaf3f6..e0ba837f176 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -435,6 +435,9 @@ from litellm.proxy.management_endpoints.cache_settings_endpoints import ( from litellm.proxy.management_endpoints.callback_management_endpoints import ( router as callback_management_endpoints_router, ) +from litellm.proxy.management_endpoints.cli_session_endpoints import ( + router as cli_session_router, +) from litellm.proxy.management_endpoints.common_utils import ( _user_has_admin_privileges, _user_has_admin_view, @@ -17472,6 +17475,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(cli_session_router) app.include_router(organization_router) app.include_router(customer_router) app.include_router(management_v1_router) diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index d9959677116..36c9040eb01 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -412,6 +412,23 @@ model LiteLLM_SSOIdentityAssertion { updated_at DateTime @default(now()) @updatedAt @map("updated_at") } +// One row per `lite login` session. The CLI credential is a self-contained encrypted +// blob rather than a virtual key, so this registry is what makes a session listable +// and revocable. session_id is the sha256 of the session token, matching how +// LiteLLM_VerificationToken stores its token. +model LiteLLM_CLISessionTable { + session_id String @id + user_id String + team_id String? + created_at DateTime @default(now()) @map("created_at") + expires_at DateTime @map("expires_at") + revoked_at DateTime? @map("revoked_at") + revoked_by String? + + @@index([user_id]) + @@index([expires_at]) +} + // Generate Tokens for Proxy model LiteLLM_VerificationToken { token String @id diff --git a/litellm/repositories/table_repositories.py b/litellm/repositories/table_repositories.py index 131f4d377ef..fda01cd8c66 100644 --- a/litellm/repositories/table_repositories.py +++ b/litellm/repositories/table_repositories.py @@ -146,6 +146,10 @@ class AccessGroupRepository(PrismaTableRepository): table_name = "litellm_accessgrouptable" +class CLISessionRepository(PrismaTableRepository): + table_name = "litellm_clisessiontable" + + class SSOConfigRepository(PrismaTableRepository): table_name = "litellm_ssoconfig" diff --git a/litellm/types/cli_session.py b/litellm/types/cli_session.py new file mode 100644 index 00000000000..e98b662fe15 --- /dev/null +++ b/litellm/types/cli_session.py @@ -0,0 +1,24 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class CLISessionResponse(BaseModel): + """An operator-visible `lite login` session. + + ``session_id`` is the sha256 of the session token, so it identifies the session + without being usable as a credential. + """ + + session_id: str + user_id: str + team_id: str | None = None + created_at: datetime + expires_at: datetime + revoked_at: datetime | None = None + revoked_by: str | None = None + + +class CLISessionListResponse(BaseModel): + sessions: tuple[CLISessionResponse, ...] + total_count: int diff --git a/schema.prisma b/schema.prisma index d9959677116..36c9040eb01 100644 --- a/schema.prisma +++ b/schema.prisma @@ -412,6 +412,23 @@ model LiteLLM_SSOIdentityAssertion { updated_at DateTime @default(now()) @updatedAt @map("updated_at") } +// One row per `lite login` session. The CLI credential is a self-contained encrypted +// blob rather than a virtual key, so this registry is what makes a session listable +// and revocable. session_id is the sha256 of the session token, matching how +// LiteLLM_VerificationToken stores its token. +model LiteLLM_CLISessionTable { + session_id String @id + user_id String + team_id String? + created_at DateTime @default(now()) @map("created_at") + expires_at DateTime @map("expires_at") + revoked_at DateTime? @map("revoked_at") + revoked_by String? + + @@index([user_id]) + @@index([expires_at]) +} + // Generate Tokens for Proxy model LiteLLM_VerificationToken { token String @id diff --git a/tests/test_litellm/proxy/auth/test_cli_session_registry.py b/tests/test_litellm/proxy/auth/test_cli_session_registry.py new file mode 100644 index 00000000000..367fe1124be --- /dev/null +++ b/tests/test_litellm/proxy/auth/test_cli_session_registry.py @@ -0,0 +1,342 @@ +import json +import os +import sys +import time +from datetime import datetime, timedelta, timezone + +import pytest +from prisma.engine.errors import EngineConnectionError + +sys.path.insert(0, os.path.abspath("../..")) + +from litellm.caching.dual_cache import DualCache +from litellm.caching.in_memory_cache import InMemoryCache +from litellm.constants import DEFAULT_IN_MEMORY_TTL +from litellm.proxy.auth.cli_session_registry import ( + cli_session_id, + is_cli_session_revoked, + list_cli_sessions, + record_cli_session, + revoke_cli_session, +) +from litellm.proxy.utils import hash_token + +SESSION_TOKEN = "cli-session-abc123" + + +class FakeRow: + def __init__(self, data: dict): + self._data = dict(data) + + def model_dump(self) -> dict: + return dict(self._data) + + +class FakeCLISessionTable: + """In-memory stand-in for the prisma table actions on LiteLLM_CLISessionTable.""" + + def __init__(self, rows: dict | None = None): + self.rows = dict(rows or {}) + self.find_unique_calls = 0 + self.created: list[dict] = [] + self.update_calls: list[dict] = [] + + async def find_unique(self, where): + self.find_unique_calls += 1 + row = self.rows.get(where["session_id"]) + return None if row is None else FakeRow(row) + + async def create(self, data): + self.created.append(dict(data)) + row = { + "created_at": datetime.now(timezone.utc), + "revoked_at": None, + "revoked_by": None, + **dict(data), + } + self.rows[row["session_id"]] = row + return FakeRow(row) + + async def update(self, where, data): + self.update_calls.append(dict(data)) + row = self.rows.get(where["session_id"]) + if row is None: + return None + row.update(data) + return FakeRow(row) + + async def find_many(self, where, order, skip, take): + gt = where["expires_at"]["gt"] + matching = [r for r in self.rows.values() if r["expires_at"] > gt] + ordered = sorted(matching, key=lambda r: r["created_at"], reverse=True) + return [FakeRow(r) for r in ordered[skip : skip + take]] + + async def count(self, where): + gt = where["expires_at"]["gt"] + return len([r for r in self.rows.values() if r["expires_at"] > gt]) + + +class FakeDB: + def __init__(self, table): + self.litellm_clisessiontable = table + + +class FakePrismaClient: + def __init__(self, table): + self.db = FakeDB(table) + + +class FakeRedisCache: + """Round-trips through JSON exactly as the real Redis backend does, so a value + that only survives the in-memory path is caught.""" + + def __init__(self): + self.store: dict[str, str] = {} + + async def async_set_cache(self, key, value, **kwargs): + self.store[key] = json.dumps(value) + + async def async_get_cache(self, key, parent_otel_span=None, **kwargs): + raw = self.store.get(key) + return None if raw is None else json.loads(raw) + + +def _session_row(*, revoked_at=None, expires_in_hours: float = 24.0, session_id=None, user_id="u-1"): + now = datetime.now(timezone.utc) + return { + "session_id": session_id or hash_token(SESSION_TOKEN), + "user_id": user_id, + "team_id": "t-1", + "created_at": now, + "expires_at": now + timedelta(hours=expires_in_hours), + "revoked_at": revoked_at, + "revoked_by": None, + } + + +def _cache() -> DualCache: + return DualCache(in_memory_cache=InMemoryCache()) + + +@pytest.mark.asyncio +async def test_unrevoked_session_is_not_revoked(): + table = FakeCLISessionTable({hash_token(SESSION_TOKEN): _session_row()}) + + assert ( + await is_cli_session_revoked( + session_token=SESSION_TOKEN, + prisma_client=FakePrismaClient(table), + user_api_key_cache=_cache(), + ) + is False + ) + + +@pytest.mark.asyncio +async def test_revoked_session_is_revoked(): + table = FakeCLISessionTable({hash_token(SESSION_TOKEN): _session_row(revoked_at=datetime.now(timezone.utc))}) + + assert ( + await is_cli_session_revoked( + session_token=SESSION_TOKEN, + prisma_client=FakePrismaClient(table), + user_api_key_cache=_cache(), + ) + is True + ) + + +@pytest.mark.asyncio +async def test_session_with_no_row_still_authenticates(): + """Sessions minted before this registry existed have no row. They must keep + working until they expire rather than being locked out by the upgrade.""" + table = FakeCLISessionTable() + + assert ( + await is_cli_session_revoked( + session_token=SESSION_TOKEN, + prisma_client=FakePrismaClient(table), + user_api_key_cache=_cache(), + ) + is False + ) + + +@pytest.mark.asyncio +async def test_lookup_is_cached_for_one_cache_interval(): + """Without the cache every CLI request would cost a DB read. The TTL is also the + bound this feature advertises on how fast a revoke reaches another replica.""" + table = FakeCLISessionTable({hash_token(SESSION_TOKEN): _session_row()}) + cache = _cache() + prisma = FakePrismaClient(table) + + for _ in range(3): + await is_cli_session_revoked(session_token=SESSION_TOKEN, prisma_client=prisma, user_api_key_cache=cache) + + assert table.find_unique_calls == 1 + expires_at = cache.in_memory_cache.ttl_dict[f"cli_session_revoked:{hash_token(SESSION_TOKEN)}"] + assert expires_at - time.time() == pytest.approx(DEFAULT_IN_MEMORY_TTL, abs=1) + + +@pytest.mark.asyncio +async def test_revoking_replica_refuses_the_session_immediately(): + """The pod that served the revoke must not keep honouring the cached 'not + revoked' answer it wrote earlier in the same interval.""" + session_id = hash_token(SESSION_TOKEN) + table = FakeCLISessionTable({session_id: _session_row()}) + cache = _cache() + prisma = FakePrismaClient(table) + + assert ( + await is_cli_session_revoked(session_token=SESSION_TOKEN, prisma_client=prisma, user_api_key_cache=cache) + is False + ) + + await revoke_cli_session( + prisma_client=prisma, + user_api_key_cache=cache, + session_id=session_id, + revoked_by="admin-1", + ) + + calls_before = table.find_unique_calls + assert ( + await is_cli_session_revoked(session_token=SESSION_TOKEN, prisma_client=prisma, user_api_key_cache=cache) + is True + ) + assert table.find_unique_calls == calls_before + + +@pytest.mark.asyncio +async def test_revocation_reaches_an_observing_replica_through_redis(): + """A second replica that primed its own 'not revoked' answer must pick the + revocation up from Redis once its local entry lapses, without needing the DB.""" + session_id = hash_token(SESSION_TOKEN) + table = FakeCLISessionTable({session_id: _session_row()}) + prisma = FakePrismaClient(table) + redis = FakeRedisCache() + revoking_replica = DualCache(in_memory_cache=InMemoryCache(), redis_cache=redis) + observing_replica = DualCache(in_memory_cache=InMemoryCache(), redis_cache=redis) + + assert ( + await is_cli_session_revoked( + session_token=SESSION_TOKEN, prisma_client=prisma, user_api_key_cache=observing_replica + ) + is False + ) + + await revoke_cli_session( + prisma_client=prisma, + user_api_key_cache=revoking_replica, + session_id=session_id, + revoked_by="admin-1", + ) + + observing_replica.in_memory_cache.cache_dict.pop(f"cli_session_revoked:{session_id}") + calls_before = table.find_unique_calls + assert ( + await is_cli_session_revoked( + session_token=SESSION_TOKEN, prisma_client=prisma, user_api_key_cache=observing_replica + ) + is True + ) + assert table.find_unique_calls == calls_before + + +@pytest.mark.asyncio +async def test_revoking_twice_keeps_the_first_revocation_time(): + session_id = hash_token(SESSION_TOKEN) + first_revoked_at = datetime.now(timezone.utc) - timedelta(hours=2) + table = FakeCLISessionTable({session_id: _session_row(revoked_at=first_revoked_at)}) + + revoked = await revoke_cli_session( + prisma_client=FakePrismaClient(table), + user_api_key_cache=_cache(), + session_id=session_id, + revoked_by="admin-2", + ) + + assert revoked is not None + assert revoked.revoked_at == first_revoked_at + assert table.update_calls == [] + + +@pytest.mark.asyncio +async def test_revoking_an_unknown_session_returns_none(): + revoked = await revoke_cli_session( + prisma_client=FakePrismaClient(FakeCLISessionTable()), + user_api_key_cache=_cache(), + session_id="not-a-session", + revoked_by="admin-1", + ) + + assert revoked is None + + +@pytest.mark.asyncio +async def test_recorded_session_is_keyed_by_the_hash_not_the_token(): + """The registry id is safe to hand to an operator and safe to log; the session + token itself must never be persisted.""" + table = FakeCLISessionTable() + + recorded = await record_cli_session( + prisma_client=FakePrismaClient(table), + session_token=SESSION_TOKEN, + user_id="u-1", + team_id="t-1", + ) + + assert recorded.session_id == hash_token(SESSION_TOKEN) + assert cli_session_id(SESSION_TOKEN) == hash_token(SESSION_TOKEN) + assert SESSION_TOKEN not in json.dumps(table.created, default=str) + assert table.created[0]["expires_at"] > datetime.now(timezone.utc) + + +@pytest.mark.asyncio +async def test_no_db_connection_does_not_refuse_the_session(): + assert ( + await is_cli_session_revoked(session_token=SESSION_TOKEN, prisma_client=None, user_api_key_cache=_cache()) + is False + ) + + +class UnreachableCLISessionTable(FakeCLISessionTable): + async def find_unique(self, where): + raise EngineConnectionError("Could not connect to the query engine") + + +@pytest.mark.asyncio +async def test_db_outage_follows_the_proxy_wide_posture(monkeypatch): + """The revocation lookup must not invent its own availability policy. An operator + who opted into serving during a DB outage keeps serving CLI sessions; one who did + not gets the same failure every other DB-backed auth read gives.""" + import litellm.proxy.proxy_server as proxy_server + + prisma = FakePrismaClient(UnreachableCLISessionTable()) + + monkeypatch.setattr(proxy_server, "general_settings", {"allow_requests_on_db_unavailable": True}, raising=False) + assert ( + await is_cli_session_revoked( + session_token=SESSION_TOKEN, prisma_client=prisma, user_api_key_cache=_cache() + ) + is False + ) + + monkeypatch.setattr(proxy_server, "general_settings", {"allow_requests_on_db_unavailable": False}, raising=False) + with pytest.raises(EngineConnectionError): + await is_cli_session_revoked(session_token=SESSION_TOKEN, prisma_client=prisma, user_api_key_cache=_cache()) + + +@pytest.mark.asyncio +async def test_listing_hides_expired_sessions(): + table = FakeCLISessionTable( + { + "live": _session_row(session_id="live", expires_in_hours=1), + "dead": _session_row(session_id="dead", expires_in_hours=-1), + } + ) + + listed = await list_cli_sessions(prisma_client=FakePrismaClient(table), page=1, page_size=50) + + assert listed.total_count == 1 + assert [s.session_id for s in listed.sessions] == ["live"] diff --git a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py index 6a117985820..ce662621345 100644 --- a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py +++ b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py @@ -5631,8 +5631,12 @@ def _proxy_attrs_for_db_lookup(): ``_user_api_key_auth_builder`` down to the DB key lookup.""" proxy_logging_obj = MagicMock() proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None) + # A CLI session token is checked against the session registry on the way through, + # so the stand-in DB has to answer that read. + prisma_client = MagicMock() + prisma_client.db.litellm_clisessiontable.find_unique = AsyncMock(return_value=None) return { - "prisma_client": MagicMock(), + "prisma_client": prisma_client, "user_api_key_cache": DualCache(), "proxy_logging_obj": proxy_logging_obj, "master_key": "sk-test-master", @@ -6735,3 +6739,80 @@ class TestLitellmReceivedAtStamping: assert result == earlier assert request.state.litellm_received_at == earlier + + +def _mint_teamless_cli_session_token(monkeypatch, *, user_id="cli-admin"): + """A CLI session with no team, so the revocation check is exercised without the + team-grant resolution a team-bound token drags in.""" + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-salt-cli-test") + from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken + + user_info = LiteLLM_UserTable( + user_id=user_id, + user_email="cli@example.com", + user_role=LitellmUserRoles.PROXY_ADMIN.value, + models=["gpt-3.5-turbo"], + max_budget=100.0, + ) + return ExperimentalUIJWTToken.get_cli_jwt_auth_token(user_info) + + +def _revocation_cache(session_id: str, revoked: bool) -> DualCache: + cache = DualCache() + cache.in_memory_cache.set_cache(f"cli_session_revoked:{session_id}", revoked, ttl=60) + return cache + + +def _registry_id_for(cli_token: str) -> str: + from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken + from litellm.proxy.auth.cli_session_registry import cli_session_id + + decoded = ExperimentalUIJWTToken.get_key_object_from_ui_hash_key(cli_token) + assert decoded is not None and decoded.token is not None + return cli_session_id(decoded.token) + + +async def _auth_with_cli_token(cli_token: str, cache: DualCache): + mock_request = MagicMock() + mock_request.url.path = "/v1/messages" + mock_request.method = "POST" + mock_request.headers = {"authorization": f"Bearer {cli_token}"} + mock_request.query_params = {} + + with ( + patch("litellm.proxy.proxy_server.master_key", "sk-master"), + patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), + patch("litellm.proxy.proxy_server.user_api_key_cache", cache), + ): + return await user_api_key_auth(request=mock_request, api_key=f"Bearer {cli_token}") + + +@pytest.mark.asyncio +async def test_revoked_cli_session_token_is_refused(monkeypatch): + """The security half of CLI session revocation. A `lite login` credential is a + self-contained blob that decrypts successfully forever, so nothing refuses a + revoked session unless the auth path consults the session registry.""" + monkeypatch.delenv("EXPERIMENTAL_UI_LOGIN", raising=False) + cli_token = _mint_teamless_cli_session_token(monkeypatch) + cache = _revocation_cache(_registry_id_for(cli_token), revoked=True) + + with pytest.raises(ProxyException) as exc_info: + await _auth_with_cli_token(cli_token, cache) + + assert exc_info.value.type == ProxyErrorTypes.auth_error + assert int(exc_info.value.code) == status.HTTP_401_UNAUTHORIZED + assert "revoked" in exc_info.value.message.lower() + + +@pytest.mark.asyncio +async def test_unrevoked_cli_session_token_still_authenticates(monkeypatch): + """Control for the revocation check: an unrevoked session must be unaffected, + so a passing revocation test cannot be a blanket refusal of CLI tokens.""" + monkeypatch.delenv("EXPERIMENTAL_UI_LOGIN", raising=False) + cli_token = _mint_teamless_cli_session_token(monkeypatch) + cache = _revocation_cache(_registry_id_for(cli_token), revoked=False) + + result = await _auth_with_cli_token(cli_token, cache) + + assert result.user_id == "cli-admin" + assert result.token is not None and result.token.startswith("cli-session-") diff --git a/tests/test_litellm/proxy/management_endpoints/test_cli_session_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_cli_session_endpoints.py new file mode 100644 index 00000000000..5d4813c4eb4 --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/test_cli_session_endpoints.py @@ -0,0 +1,114 @@ +import os +import sys +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +import pytest +from fastapi import HTTPException + +sys.path.insert(0, os.path.abspath("../../../")) + +from litellm.caching.dual_cache import DualCache +from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.management_endpoints.cli_session_endpoints import ( + list_cli_sessions_endpoint, + revoke_cli_session_endpoint, +) + +from tests.test_litellm.proxy.auth.test_cli_session_registry import ( + FakeCLISessionTable, + FakePrismaClient, +) + + +def _row(session_id: str, *, revoked_at=None): + now = datetime.now(timezone.utc) + return { + "session_id": session_id, + "user_id": "u-1", + "team_id": "t-1", + "created_at": now, + "expires_at": now + timedelta(hours=24), + "revoked_at": revoked_at, + "revoked_by": None, + } + + +def _caller(role: LitellmUserRoles, user_id: str = "admin-1") -> UserAPIKeyAuth: + return UserAPIKeyAuth(token="sk-hash", user_id=user_id, user_role=role) + + +def _proxy(table: FakeCLISessionTable): + return ( + patch("litellm.proxy.proxy_server.prisma_client", FakePrismaClient(table)), + patch("litellm.proxy.proxy_server.user_api_key_cache", DualCache()), + ) + + +@pytest.mark.asyncio +async def test_list_returns_registered_sessions(): + table = FakeCLISessionTable({"s-1": _row("s-1")}) + prisma_patch, cache_patch = _proxy(table) + + with prisma_patch, cache_patch: + listed = await list_cli_sessions_endpoint(user_api_key_dict=_caller(LitellmUserRoles.PROXY_ADMIN)) + + assert listed.total_count == 1 + assert listed.sessions[0].session_id == "s-1" + assert listed.sessions[0].user_id == "u-1" + + +@pytest.mark.asyncio +async def test_admin_viewer_can_list_but_not_revoke(): + """Read-only admins get the visibility half without the ability to cut a user off.""" + table = FakeCLISessionTable({"s-1": _row("s-1")}) + viewer = _caller(LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY) + prisma_patch, cache_patch = _proxy(table) + + with prisma_patch, cache_patch: + assert (await list_cli_sessions_endpoint(user_api_key_dict=viewer)).total_count == 1 + + with pytest.raises(HTTPException) as exc_info: + await revoke_cli_session_endpoint(session_id="s-1", user_api_key_dict=viewer) + + assert exc_info.value.status_code == 403 + assert table.rows["s-1"]["revoked_at"] is None + + +@pytest.mark.asyncio +async def test_internal_user_cannot_see_other_peoples_sessions(): + table = FakeCLISessionTable({"s-1": _row("s-1")}) + prisma_patch, cache_patch = _proxy(table) + + with prisma_patch, cache_patch: + with pytest.raises(HTTPException) as exc_info: + await list_cli_sessions_endpoint(user_api_key_dict=_caller(LitellmUserRoles.INTERNAL_USER)) + + assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_revoke_marks_the_session_and_attributes_the_operator(): + table = FakeCLISessionTable({"s-1": _row("s-1")}) + prisma_patch, cache_patch = _proxy(table) + + with prisma_patch, cache_patch: + revoked = await revoke_cli_session_endpoint( + session_id="s-1", + user_api_key_dict=_caller(LitellmUserRoles.PROXY_ADMIN, user_id="admin-7"), + ) + + assert revoked.revoked_at is not None + assert revoked.revoked_by == "admin-7" + + +@pytest.mark.asyncio +async def test_revoking_an_unknown_session_is_a_404(): + table = FakeCLISessionTable() + prisma_patch, cache_patch = _proxy(table) + + with prisma_patch, cache_patch: + with pytest.raises(HTTPException) as exc_info: + await revoke_cli_session_endpoint(session_id="nope", user_api_key_dict=_caller(LitellmUserRoles.PROXY_ADMIN)) + + assert exc_info.value.status_code == 404 diff --git a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py index 3facbf07889..d1832abe0f3 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py +++ b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py @@ -2,6 +2,7 @@ import asyncio import json import os from contextlib import asynccontextmanager +from datetime import datetime, timezone from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -2522,6 +2523,27 @@ class TestCustomUISSO: assert result.status_code == 303 +def _cli_session_registry_prisma() -> MagicMock: + """`cli_poll_key` registers the session in LiteLLM_CLISessionTable before handing + the credential out, so the poll needs a DB whose CLI-session table accepts the write.""" + prisma = MagicMock() + now = datetime.now(timezone.utc) + prisma.db.litellm_clisessiontable.create = AsyncMock( + return_value=SimpleNamespace( + model_dump=lambda: { + "session_id": "hashed-session-id", + "user_id": "registered-user", + "team_id": None, + "created_at": now, + "expires_at": now, + "revoked_at": None, + "revoked_by": None, + } + ) + ) + return prisma + + class TestCLIKeyRegenerationFlow: """Test the end-to-end CLI key regeneration flow""" @@ -3501,7 +3523,7 @@ class TestCLIKeyRegenerationFlow: with ( patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache), patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache), - patch("litellm.proxy.proxy_server.prisma_client"), + patch("litellm.proxy.proxy_server.prisma_client", _cli_session_registry_prisma()), patch( "litellm.proxy.auth.auth_checks.ExperimentalUIJWTToken.get_cli_jwt_auth_token", return_value=mock_jwt_token, @@ -3654,6 +3676,7 @@ class TestCLIKeyRegenerationFlow: with ( patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache), + patch("litellm.proxy.proxy_server.prisma_client", _cli_session_registry_prisma()), patch( "litellm.proxy.auth.auth_checks.ExperimentalUIJWTToken.get_cli_jwt_auth_token", return_value="minted-token", @@ -3718,6 +3741,7 @@ class TestCLIKeyRegenerationFlow: with ( patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache), + patch("litellm.proxy.proxy_server.prisma_client", _cli_session_registry_prisma()), patch( "litellm.proxy.auth.auth_checks.ExperimentalUIJWTToken.get_cli_jwt_auth_token", return_value="minted-token", @@ -3760,6 +3784,7 @@ class TestCLIKeyRegenerationFlow: with ( patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache), + patch("litellm.proxy.proxy_server.prisma_client", _cli_session_registry_prisma()), patch( "litellm.proxy.auth.auth_checks.ExperimentalUIJWTToken.get_cli_jwt_auth_token", return_value="minted-token", @@ -3813,7 +3838,7 @@ class TestCLIKeyRegenerationFlow: with ( patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache), patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache), - patch("litellm.proxy.proxy_server.prisma_client"), + patch("litellm.proxy.proxy_server.prisma_client", _cli_session_registry_prisma()), patch( "litellm.proxy.auth.auth_checks.ExperimentalUIJWTToken.get_cli_jwt_auth_token", return_value=mock_jwt_token, @@ -3870,6 +3895,7 @@ class TestCLIKeyRegenerationFlow: with ( patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache), patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache), + patch("litellm.proxy.proxy_server.prisma_client", _cli_session_registry_prisma()), patch( "litellm.proxy.auth.auth_checks.ExperimentalUIJWTToken.get_cli_jwt_auth_token", return_value=mock_jwt_token, @@ -8099,7 +8125,7 @@ async def test_cli_poll_key_tolerates_missing_user_row(): with ( patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache), patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache), - patch("litellm.proxy.proxy_server.prisma_client"), + patch("litellm.proxy.proxy_server.prisma_client", _cli_session_registry_prisma()), patch( "litellm.proxy.auth.auth_checks.ExperimentalUIJWTToken.get_cli_jwt_auth_token", return_value=mock_jwt_token, @@ -8628,3 +8654,120 @@ class TestPersistReturnToCookieSharedHelper: resp = Response() _persist_return_to_cookie(resp, "https://cp.example.com/ui?page=models") assert "litellm_cp_return_to=" in self._cookie(resp) + + +def _cli_poll_flow_cache(session_data: dict) -> MagicMock: + from litellm.proxy.management_endpoints.ui_sso import _hash_cli_sso_secret + + cache = MagicMock(redis_cache=None) + cache.get_cache.return_value = { + "poll_secret_hash": _hash_cli_sso_secret("poll-secret"), + "sso_complete": True, + "user_code_verified": True, + "session_data": session_data, + } + return cache + + +@pytest.mark.asyncio +async def test_cli_poll_key_registers_the_session_it_mints(monkeypatch): + """The registry row is what makes a CLI session listable and revocable, so the + poll must register the session token it actually handed out, keyed by its hash.""" + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-salt-cli-registry") + from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken + from litellm.proxy.auth.cli_session_registry import cli_session_id + from litellm.proxy.management_endpoints.ui_sso import cli_poll_key + + session_data = { + "user_id": "registered-user", + "user_role": "internal_user", + "teams": ["team-r"], + "team_details": [{"team_id": "team-r", "team_alias": "Team R", "team_models": []}], + "models": ["gpt-4"], + } + mock_cache = _cli_poll_flow_cache(session_data) + prisma = _cli_session_registry_prisma() + + with ( + patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache), + patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache), + patch("litellm.proxy.proxy_server.prisma_client", prisma), + ): + result = await cli_poll_key( + key_id="cli-registered-session", + team_id="team-r", + x_litellm_cli_poll_secret="poll-secret", + ) + + minted = ExperimentalUIJWTToken.get_key_object_from_ui_hash_key(result["key"]) + assert minted is not None and minted.token is not None + + prisma.db.litellm_clisessiontable.create.assert_awaited_once() + recorded = prisma.db.litellm_clisessiontable.create.await_args.kwargs["data"] + assert recorded["session_id"] == cli_session_id(minted.token) + assert recorded["session_id"] != minted.token + assert recorded["user_id"] == "registered-user" + assert recorded["team_id"] == "team-r" + + +@pytest.mark.asyncio +async def test_cli_poll_key_withholds_the_credential_when_registration_fails(monkeypatch): + """A session that cannot be registered is a session no operator could ever + revoke, so the poll must fail rather than hand out an unrevocable credential.""" + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-salt-cli-registry") + from litellm.proxy.management_endpoints.ui_sso import cli_poll_key + + session_data = { + "user_id": "registered-user", + "user_role": "internal_user", + "teams": [], + "team_details": [], + "models": ["gpt-4"], + } + mock_cache = _cli_poll_flow_cache(session_data) + prisma = _cli_session_registry_prisma() + prisma.db.litellm_clisessiontable.create = AsyncMock(side_effect=Exception("registry write failed")) + + with ( + patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache), + patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache), + patch("litellm.proxy.proxy_server.prisma_client", prisma), + ): + with pytest.raises(HTTPException) as exc_info: + await cli_poll_key( + key_id="cli-unregistered-session", + team_id=None, + x_litellm_cli_poll_secret="poll-secret", + ) + + assert exc_info.value.status_code == 500 + + +@pytest.mark.asyncio +async def test_cli_poll_key_registers_a_distinct_session_per_login(monkeypatch): + """Session ids are the registry's primary key and the unit of revocation, so two + logins sharing one id would collide on insert and make one revoke cut off both.""" + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-salt-cli-registry") + from litellm.proxy.management_endpoints.ui_sso import cli_poll_key + + session_data = { + "user_id": "registered-user", + "user_role": "internal_user", + "teams": [], + "team_details": [], + "models": ["gpt-4"], + } + mock_cache = _cli_poll_flow_cache(session_data) + prisma = _cli_session_registry_prisma() + + with ( + patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache), + patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache), + patch("litellm.proxy.proxy_server.prisma_client", prisma), + ): + for key_id in ("cli-login-session-alpha", "cli-login-session-bravo"): + await cli_poll_key(key_id=key_id, team_id=None, x_litellm_cli_poll_secret="poll-secret") + + registered = [call.kwargs["data"]["session_id"] for call in prisma.db.litellm_clisessiontable.create.await_args_list] + assert len(registered) == 2 + assert len(set(registered)) == 2 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cli-sessions/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cli-sessions/page.tsx new file mode 100644 index 00000000000..10b6bf43614 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cli-sessions/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import CLISessionsPageContent from "@/components/CLISessionsPage/CLISessionsPage"; +import { AdminOnlyNotice } from "@/components/shared/AdminOnlyNotice"; +import { proxyAdminTierRoles } from "@/utils/roles"; + +export default function CLISessions() { + const { userRole } = useAuthorized(); + if (!proxyAdminTierRoles.includes(userRole || "")) { + return ; + } + return ; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/cliSessions/useCLISessions.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/cliSessions/useCLISessions.ts new file mode 100644 index 00000000000..ce53a3b8d8f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/cliSessions/useCLISessions.ts @@ -0,0 +1,34 @@ +import { keepPreviousData, useQueryClient } from "@tanstack/react-query"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { $api } from "@/lib/http/api"; +import type { components } from "@/lib/http/schema"; +import { proxyAdminTierRoles } from "@/utils/roles"; + +export type CLISessionResponse = components["schemas"]["CLISessionResponse"]; + +const CLI_SESSION_LIST_KEY = ["get", "/cli/session/list"] as const; + +export const useCLISessions = (page: number, pageSize: number) => { + const { accessToken, userRole } = useAuthorized(); + + return $api.useQuery( + "get", + "/cli/session/list", + { params: { query: { page, page_size: pageSize } } }, + { + enabled: Boolean(accessToken) && proxyAdminTierRoles.includes(userRole || ""), + staleTime: 30000, + placeholderData: keepPreviousData, + }, + ); +}; + +export const useRevokeCLISession = () => { + const queryClient = useQueryClient(); + + return $api.useMutation("post", "/cli/session/{session_id}/revoke", { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: CLI_SESSION_LIST_KEY }); + }, + }); +}; diff --git a/ui/litellm-dashboard/src/components/CLISessionsPage/CLISessionsPage.test.tsx b/ui/litellm-dashboard/src/components/CLISessionsPage/CLISessionsPage.test.tsx new file mode 100644 index 00000000000..e6ed9f301fc --- /dev/null +++ b/ui/litellm-dashboard/src/components/CLISessionsPage/CLISessionsPage.test.tsx @@ -0,0 +1,53 @@ +/* @vitest-environment jsdom */ +import { screen } from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; +import { renderWithProviders } from "../../../tests/test-utils"; +import CLISessionsPage from "./CLISessionsPage"; +import { effectiveSessionRole, isViewOnlySessionRole } from "@/utils/roles"; + +const session = { + session_id: "a1b2c3d4e5f6", + user_id: "cli-user-1", + team_id: null, + created_at: "2026-08-13T10:00:00Z", + expires_at: "2026-08-14T10:00:00Z", + revoked_at: null, + revoked_by: null, +}; + +// The page reads identity straight off the session cookie, so the raw proxy role is +// what a test needs to vary; useAuthorized derives the rest exactly as it does live. +const rawUserRole = vi.fn(() => "proxy_admin"); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ + accessToken: "sk-test", + userRole: effectiveSessionRole(rawUserRole()), + isViewOnly: isViewOnlySessionRole(rawUserRole()), + }), +})); + +vi.mock("@/app/(dashboard)/hooks/cliSessions/useCLISessions", () => ({ + useCLISessions: () => ({ data: { sessions: [session], total_count: 1 }, isLoading: false }), + useRevokeCLISession: () => ({ mutate: vi.fn(), isPending: false }), +})); + +beforeEach(() => { + rawUserRole.mockReturnValue("proxy_admin"); +}); + +it("should offer Revoke to a proxy admin", () => { + renderWithProviders(); + + expect(screen.getByRole("button", { name: "Revoke" })).toBeInTheDocument(); +}); + +it("should not offer Revoke to a read-only admin, whom the proxy refuses on the revoke route", () => { + // effectiveSessionRole normalizes proxy_admin_viewer to "Admin" for read parity, so a + // role-only check would hand this user a control that always comes back 403. + rawUserRole.mockReturnValue("proxy_admin_viewer"); + + renderWithProviders(); + + expect(screen.getByText("cli-user-1")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument(); +}); diff --git a/ui/litellm-dashboard/src/components/CLISessionsPage/CLISessionsPage.tsx b/ui/litellm-dashboard/src/components/CLISessionsPage/CLISessionsPage.tsx new file mode 100644 index 00000000000..89a9fc09ab7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CLISessionsPage/CLISessionsPage.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { PaginationState } from "@tanstack/react-table"; +import { Terminal } from "lucide-react"; +import { useCallback, useState } from "react"; + +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useCLISessions, useRevokeCLISession } from "@/app/(dashboard)/hooks/cliSessions/useCLISessions"; +import { PageHeader } from "@/components/shared/PageHeader"; +import { isProxyAdminRole } from "@/utils/roles"; + +import { CLISessionsTable } from "./CLISessionsTable/CLISessionsTable"; + +export default function CLISessionsPage() { + // `effectiveSessionRole` normalizes proxy_admin_viewer to "Admin" for read parity, + // so the role alone cannot tell a revoker from a reader; isViewOnly can. + const { userRole, isViewOnly } = useAuthorized(); + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 50 }); + + const { data, isLoading } = useCLISessions(pagination.pageIndex + 1, pagination.pageSize); + const revoke = useRevokeCLISession(); + + const onRevoke = useCallback( + (sessionId: string) => revoke.mutate({ params: { path: { session_id: sessionId } } }), + [revoke], + ); + + return ( +
+ } + /> + +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/CLISessionsPage/CLISessionsTable/CLISessionsTable.test.tsx b/ui/litellm-dashboard/src/components/CLISessionsPage/CLISessionsTable/CLISessionsTable.test.tsx new file mode 100644 index 00000000000..4769316fce1 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CLISessionsPage/CLISessionsTable/CLISessionsTable.test.tsx @@ -0,0 +1,82 @@ +import { screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { vi, it, expect, beforeEach } from "vitest"; +import { renderWithProviders } from "../../../../tests/test-utils"; +import { CLISessionsTable } from "./CLISessionsTable"; +import type { CLISessionResponse } from "@/app/(dashboard)/hooks/cliSessions/useCLISessions"; + +const makeSession = (overrides: Partial = {}): CLISessionResponse => ({ + session_id: "a1b2c3d4e5f6", + user_id: "cli-user-1", + team_id: "team-1", + created_at: "2026-08-13T10:00:00Z", + expires_at: "2026-08-14T10:00:00Z", + revoked_at: null, + revoked_by: null, + ...overrides, +}); + +const defaultProps = { + sessions: [makeSession()], + totalCount: 1, + isLoading: false, + isRevoking: false, + canRevoke: true, + onRevoke: vi.fn(), + pagination: { pageIndex: 0, pageSize: 50 }, + onPaginationChange: vi.fn(), +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +it("should show who the session belongs to, when it was issued and when it expires", () => { + renderWithProviders(); + + expect(screen.getByText("cli-user-1")).toBeInTheDocument(); + expect(screen.getByText("a1b2c3d4e5f6")).toBeInTheDocument(); + expect(screen.getByText("team-1")).toBeInTheDocument(); + expect(screen.getByText("Active")).toBeInTheDocument(); +}); + +it("should revoke the session the operator confirmed, not some other row", async () => { + const user = userEvent.setup(); + const sessions = [ + makeSession({ session_id: "keep-me", user_id: "user-a" }), + makeSession({ session_id: "kill-me", user_id: "user-b" }), + ]; + renderWithProviders(); + + const targetRow = screen.getByText("user-b").closest("tr")!; + await user.click(within(targetRow).getByRole("button", { name: "Revoke" })); + await user.click(screen.getByRole("alertdialog").querySelector("button:last-of-type") as HTMLElement); + + expect(defaultProps.onRevoke).toHaveBeenCalledTimes(1); + expect(defaultProps.onRevoke).toHaveBeenCalledWith("kill-me"); +}); + +it("should not offer a revoke action for an already revoked session", () => { + renderWithProviders( + , + ); + + expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument(); + expect(screen.getAllByText("Revoked").length).toBeGreaterThan(0); +}); + +it("should not offer a revoke control to a read-only admin, who the proxy refuses anyway", () => { + renderWithProviders(); + + expect(screen.getByText("cli-user-1")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument(); +}); + +it("should show the empty state when no sessions are active", () => { + renderWithProviders(); + + expect(screen.getByText("No active CLI sessions")).toBeInTheDocument(); +}); diff --git a/ui/litellm-dashboard/src/components/CLISessionsPage/CLISessionsTable/CLISessionsTable.tsx b/ui/litellm-dashboard/src/components/CLISessionsPage/CLISessionsTable/CLISessionsTable.tsx new file mode 100644 index 00000000000..7da48f3a34a --- /dev/null +++ b/ui/litellm-dashboard/src/components/CLISessionsPage/CLISessionsTable/CLISessionsTable.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table"; +import { Inbox } from "lucide-react"; +import { useMemo, useState } from "react"; + +import type { CLISessionResponse } from "@/app/(dashboard)/hooks/cliSessions/useCLISessions"; +import { DataTable } from "@/components/shared/DataTable"; + +import { getCLISessionsTableColumns } from "./CLISessionsTableColumns"; + +interface CLISessionsTableProps { + sessions: CLISessionResponse[]; + totalCount: number; + isLoading: boolean; + isRevoking: boolean; + canRevoke: boolean; + onRevoke: (sessionId: string) => void; + pagination: PaginationState; + onPaginationChange: OnChangeFn; +} + +const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }]; + +function EmptyState() { + return ( +
+
+ +
+
No active CLI sessions
+
Sessions created by `lite login` will show up here.
+
+ ); +} + +export function CLISessionsTable({ + sessions, + totalCount, + isLoading, + isRevoking, + canRevoke, + onRevoke, + pagination, + onPaginationChange, +}: CLISessionsTableProps) { + const [sorting, setSorting] = useState(DEFAULT_SORTING); + + const columns = useMemo( + () => getCLISessionsTableColumns(onRevoke, isRevoking, canRevoke), + [onRevoke, isRevoking, canRevoke], + ); + + return ( + session.session_id || String(index)} + sortingMode="client" + sorting={sorting} + onSortingChange={setSorting} + paginationMode="server" + pagination={pagination} + onPaginationChange={onPaginationChange} + rowCount={totalCount} + isLoading={isLoading} + loadingMessage="Loading CLI sessions…" + noDataMessage={} + size="compact" + /> + ); +} diff --git a/ui/litellm-dashboard/src/components/CLISessionsPage/CLISessionsTable/CLISessionsTableColumns.tsx b/ui/litellm-dashboard/src/components/CLISessionsPage/CLISessionsTable/CLISessionsTableColumns.tsx new file mode 100644 index 00000000000..747c4377d8c --- /dev/null +++ b/ui/litellm-dashboard/src/components/CLISessionsPage/CLISessionsTable/CLISessionsTableColumns.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; + +import type { CLISessionResponse } from "@/app/(dashboard)/hooks/cliSessions/useCLISessions"; +import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { DateCell, IdCell } from "@/components/shared/table_cells"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; + +function RevokeCell({ + session, + onRevoke, + isRevoking, +}: { + session: CLISessionResponse; + onRevoke: (sessionId: string) => void; + isRevoking: boolean; +}) { + if (session.revoked_at) { + return ( + + Revoked + + ); + } + + return ( + + + Revoke + + } + /> + + + Revoke this CLI session? + + {session.user_id} stops authenticating with this session within one cache interval. Running `lite login` + again starts a new one. + + + + Cancel + onRevoke(session.session_id)}> + Revoke + + + + + ); +} + +export const getCLISessionsTableColumns = ( + onRevoke: (sessionId: string) => void, + isRevoking: boolean, + canRevoke: boolean, +): ColumnDef[] => [ + { + id: "session_id", + accessorKey: "session_id", + meta: { title: "Session ID" }, + header: "Session ID", + size: 160, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "user_id", + accessorKey: "user_id", + meta: { title: "User" }, + header: "User", + size: 160, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "team_id", + accessorKey: "team_id", + meta: { title: "Team" }, + header: "Team", + size: 140, + enableSorting: false, + cell: ({ row }) => + row.original.team_id ? ( + + ) : ( + - + ), + }, + { + id: "created_at", + accessorKey: "created_at", + meta: { title: "Issued" }, + header: ({ column }) => , + size: 130, + enableSorting: true, + cell: ({ row }) => , + }, + { + id: "expires_at", + accessorKey: "expires_at", + meta: { title: "Expires" }, + header: ({ column }) => , + size: 130, + enableSorting: true, + cell: ({ row }) => , + }, + { + id: "status", + meta: { title: "Status" }, + header: "Status", + size: 90, + enableSorting: false, + cell: ({ row }) => + row.original.revoked_at ? ( + Revoked + ) : ( + Active + ), + }, + ...(canRevoke + ? [ + { + id: "actions", + meta: { title: "Actions" }, + header: "", + size: 90, + enableSorting: false, + cell: ({ row }) => , + } satisfies ColumnDef, + ] + : []), +]; diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index b58d7bd0d02..f6eff18c413 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -67,6 +67,7 @@ import { cn } from "@/lib/cva.config"; import { rolesWithCapability } from "../utils/capabilities"; import { all_admin_roles, + proxyAdminTierRoles, internalUserRoles, isAdminRole, isUserTeamAdminForAnyTeam, @@ -120,6 +121,13 @@ const menuGroups: MenuGroup[] = [ groupLabel: "AI GATEWAY", items: [ { key: "api-keys", page: "api-keys", label: "Virtual Keys", icon: }, + { + key: "cli-sessions", + page: "cli-sessions", + label: "CLI Sessions", + icon: , + roles: proxyAdminTierRoles, + }, { key: "llm-playground", page: "llm-playground", diff --git a/ui/litellm-dashboard/src/components/page_metadata.ts b/ui/litellm-dashboard/src/components/page_metadata.ts index 0f2ff639bb3..5c73dfd98d7 100644 --- a/ui/litellm-dashboard/src/components/page_metadata.ts +++ b/ui/litellm-dashboard/src/components/page_metadata.ts @@ -6,6 +6,7 @@ // Page descriptions for UI Settings configuration export const pageDescriptions: Record = { "api-keys": "Manage virtual keys for API access and authentication", + "cli-sessions": "Review active `lite login` sessions and revoke them mid-session", "llm-playground": "Interactive playground for testing LLM requests", models: "Configure and manage LLM models and endpoints", agents: "Create and manage AI agents", diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 67ec35d36a0..cb56a4839b9 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -1827,6 +1827,40 @@ export interface paths { patch?: never; trace?: never; }; + "/cli/session/list": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Cli Sessions Endpoint */ + get: operations["list_cli_sessions_endpoint_cli_session_list_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/cli/session/{session_id}/revoke": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Revoke Cli Session Endpoint */ + post: operations["revoke_cli_session_endpoint_cli_session__session_id__revoke_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/cloudzero/delete": { parameters: { query?: never; @@ -23036,6 +23070,42 @@ export interface components { /** Total Requested */ total_requested: number; }; + /** CLISessionListResponse */ + CLISessionListResponse: { + /** Sessions */ + sessions: components["schemas"]["CLISessionResponse"][]; + /** Total Count */ + total_count: number; + }; + /** + * CLISessionResponse + * @description An operator-visible `lite login` session. + * + * ``session_id`` is the sha256 of the session token, so it identifies the session + * without being usable as a credential. + */ + CLISessionResponse: { + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Expires At + * Format: date-time + */ + expires_at: string; + /** Revoked At */ + revoked_at?: string | null; + /** Revoked By */ + revoked_by?: string | null; + /** Session Id */ + session_id: string; + /** Team Id */ + team_id?: string | null; + /** User Id */ + user_id: string; + }; /** CacheActivityErrorBucket */ CacheActivityErrorBucket: { /** Call Type */ @@ -39804,6 +39874,69 @@ export interface operations { }; }; }; + list_cli_sessions_endpoint_cli_session_list_get: { + parameters: { + query?: { + page?: number; + page_size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CLISessionListResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + revoke_cli_session_endpoint_cli_session__session_id__revoke_post: { + parameters: { + query?: never; + header?: never; + path: { + session_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CLISessionResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; delete_cloudzero_settings_cloudzero_delete_delete: { parameters: { query?: never; diff --git a/ui/litellm-dashboard/src/utils/migratedPages.ts b/ui/litellm-dashboard/src/utils/migratedPages.ts index 73ab71ce4ac..22ca4865ee7 100644 --- a/ui/litellm-dashboard/src/utils/migratedPages.ts +++ b/ui/litellm-dashboard/src/utils/migratedPages.ts @@ -10,6 +10,7 @@ import { serverRootPath } from "@/components/networking"; */ export const MIGRATED_PAGES: Record = { "api-keys": "api-keys", + "cli-sessions": "cli-sessions", models: "models-and-endpoints", api_ref: "api-reference", // Legacy alias: older bookmarks used the hyphenated ?page=api-reference form. diff --git a/ui/litellm-dashboard/src/utils/roles.ts b/ui/litellm-dashboard/src/utils/roles.ts index 62a5f02cc39..acc04c95d6d 100644 --- a/ui/litellm-dashboard/src/utils/roles.ts +++ b/ui/litellm-dashboard/src/utils/roles.ts @@ -12,6 +12,11 @@ export const old_admin_roles = ["Admin", "Admin Viewer"]; export const v2_admin_role_names = ["proxy_admin", "proxy_admin_viewer", "org_admin"]; export const all_admin_roles = [...old_admin_roles, ...v2_admin_role_names]; +// The roles the proxy's own admin-view check accepts: proxy admin and proxy admin +// viewer only. `all_admin_roles` is wider because it also carries org_admin, who the +// proxy refuses on tenant-wide reads. +export const proxyAdminTierRoles = [...old_admin_roles, "proxy_admin", "proxy_admin_viewer"]; + export const internalUserRoles = ["Internal User", "Internal Viewer", "internal_user", "internal_user_viewer"]; export const rolesAllowedToSeeUsage = ["Admin", "Admin Viewer", "Internal User", "Internal Viewer"]; export const rolesWithWriteAccess = ["Internal User", "Admin", "proxy_admin"];