From e89a991702ee0a1e17ef5234115a4c0a37a357e4 Mon Sep 17 00:00:00 2001 From: Mubashir Osmani Date: Fri, 10 Jul 2026 19:49:04 +0000 Subject: [PATCH] feat(proxy): add Secure Share for end-to-end encrypted credential links Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../migration.sql | 15 + .../litellm_proxy_extras/schema.prisma | 12 + litellm/proxy/proxy_server.py | 4 + litellm/proxy/schema.prisma | 12 + litellm/proxy/secure_share/__init__.py | 0 .../secure_share/secure_share_endpoints.py | 207 +++++++++++++ litellm/repositories/__init__.py | 2 + litellm/repositories/table_repositories.py | 4 + schema.prisma | 12 + .../proxy/secure_share/__init__.py | 0 .../test_secure_share_endpoints.py | 275 ++++++++++++++++++ .../_components/SecureShareCreate.tsx | 163 +++++++++++ .../_components/SecureShareView.tsx | 111 +++++++ .../secure-share/_components/crypto.test.ts | 39 +++ .../secure-share/_components/crypto.ts | 54 ++++ .../src/app/(dashboard)/secure-share/page.tsx | 34 +++ .../(dashboard)/secure-share/view/page.tsx | 22 ++ .../src/components/leftnav.tsx | 8 + .../src/components/networking.tsx | 56 ++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 187 ++++++++++++ .../src/utils/migratedPages.ts | 1 + 21 files changed, 1218 insertions(+) create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260710193000_add_secure_share_table/migration.sql create mode 100644 litellm/proxy/secure_share/__init__.py create mode 100644 litellm/proxy/secure_share/secure_share_endpoints.py create mode 100644 tests/test_litellm/proxy/secure_share/__init__.py create mode 100644 tests/test_litellm/proxy/secure_share/test_secure_share_endpoints.py create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/secure-share/_components/SecureShareCreate.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/secure-share/_components/SecureShareView.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/secure-share/_components/crypto.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/secure-share/_components/crypto.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/secure-share/page.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/secure-share/view/page.tsx diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260710193000_add_secure_share_table/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260710193000_add_secure_share_table/migration.sql new file mode 100644 index 00000000000..3d8d20448b3 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260710193000_add_secure_share_table/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "LiteLLM_SecureShareTable" ( + "share_id" TEXT NOT NULL, + "ciphertext" TEXT NOT NULL, + "salt" TEXT NOT NULL, + "iv" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "created_by" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "LiteLLM_SecureShareTable_pkey" PRIMARY KEY ("share_id") +); + +-- CreateIndex +CREATE INDEX "LiteLLM_SecureShareTable_expires_at_idx" ON "LiteLLM_SecureShareTable"("expires_at"); diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index fb4d8d0b5a3..dfc5cae2291 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -1410,3 +1410,15 @@ model LiteLLM_WorkflowMessage { @@unique([run_id, sequence_number]) @@index([run_id]) } + +model LiteLLM_SecureShareTable { + share_id String @id @default(uuid()) + ciphertext String + salt String + iv String + expires_at DateTime + created_by String + created_at DateTime @default(now()) + + @@index([expires_at]) +} diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 4114bda47c9..ceb04a8d5d0 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -358,6 +358,9 @@ from litellm.proxy.management_endpoints.budget_management_endpoints import ( from litellm.proxy.management_endpoints.cache_settings_endpoints import ( router as cache_settings_router, ) +from litellm.proxy.secure_share.secure_share_endpoints import ( + router as secure_share_router, +) from litellm.proxy.management_endpoints.callback_management_endpoints import ( router as callback_management_endpoints_router, ) @@ -15811,6 +15814,7 @@ app.include_router(cost_tracking_settings_router) app.include_router(router_settings_router) app.include_router(fallback_management_router) app.include_router(cache_settings_router) +app.include_router(secure_share_router) app.include_router(user_agent_analytics_router) app.include_router(enterprise_router) app.include_router(ui_discovery_endpoints_router) diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index fb4d8d0b5a3..dfc5cae2291 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -1410,3 +1410,15 @@ model LiteLLM_WorkflowMessage { @@unique([run_id, sequence_number]) @@index([run_id]) } + +model LiteLLM_SecureShareTable { + share_id String @id @default(uuid()) + ciphertext String + salt String + iv String + expires_at DateTime + created_by String + created_at DateTime @default(now()) + + @@index([expires_at]) +} diff --git a/litellm/proxy/secure_share/__init__.py b/litellm/proxy/secure_share/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/proxy/secure_share/secure_share_endpoints.py b/litellm/proxy/secure_share/secure_share_endpoints.py new file mode 100644 index 00000000000..baf26c4fe49 --- /dev/null +++ b/litellm/proxy/secure_share/secure_share_endpoints.py @@ -0,0 +1,207 @@ +""" +SECURE SHARE + +Endpoints backing the dashboard "Secure Share" feature: proxy admins share a +credential with an internal user over a temporary, end-to-end-encrypted link. + +The browser encrypts the secret with AES-256-GCM under a key derived from an +admin-chosen password (PBKDF2-SHA256). Only the resulting ciphertext and the +crypto parameters needed to decrypt it (PBKDF2 salt, GCM iv) reach the server, +alongside an expiry. The plaintext secret and the password never leave the +client, so a database or server compromise yields ciphertext without the +password required to open it. + +POST /secure_share/create - store an encrypted share (proxy admin only) +GET /secure_share/{share_id} - fetch an unexpired share (admins + internal users) +DELETE /secure_share/{share_id} - revoke a share early (proxy admin only) +""" + +import base64 +import binascii +from datetime import datetime, timedelta, timezone +from enum import Enum +from typing import Final + +from fastapi import APIRouter, Depends, HTTPException, Path, status +from pydantic import BaseModel, Field, field_validator + +from litellm.proxy._types import ( + CommonProxyErrors, + LitellmUserRoles, + UserAPIKeyAuth, +) +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.repositories.table_repositories import SecureShareRepository + +router = APIRouter() + +MAX_CIPHERTEXT_BYTES: Final[int] = 256 * 1024 + +_READ_ALLOWED_ROLES: Final[frozenset[str]] = frozenset( + { + LitellmUserRoles.PROXY_ADMIN.value, + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, + LitellmUserRoles.INTERNAL_USER.value, + LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value, + } +) + + +class SecureShareExpiry(str, Enum): + ONE_HOUR = "1h" + SIX_HOURS = "6h" + ONE_DAY = "1d" + SEVEN_DAYS = "7d" + + @property + def duration(self) -> timedelta: + return { + SecureShareExpiry.ONE_HOUR: timedelta(hours=1), + SecureShareExpiry.SIX_HOURS: timedelta(hours=6), + SecureShareExpiry.ONE_DAY: timedelta(days=1), + SecureShareExpiry.SEVEN_DAYS: timedelta(days=7), + }[self] + + +def _validate_b64(value: str, *, field_name: str, max_bytes: int) -> str: + try: + raw = base64.b64decode(value, validate=True) + except (binascii.Error, ValueError) as e: + raise ValueError(f"{field_name} must be base64-encoded") from e + if len(raw) == 0: + raise ValueError(f"{field_name} must not be empty") + if len(raw) > max_bytes: + raise ValueError(f"{field_name} exceeds the {max_bytes}-byte limit") + return value + + +class SecureShareCreateRequest(BaseModel): + ciphertext: str = Field(description="base64 AES-256-GCM ciphertext of the secret") + salt: str = Field(description="base64 PBKDF2 salt used to derive the AES key") + iv: str = Field(description="base64 AES-GCM initialization vector") + expiry: SecureShareExpiry = Field(description="how long the share stays retrievable") + + @field_validator("ciphertext") + @classmethod + def _check_ciphertext(cls, value: str) -> str: + return _validate_b64(value, field_name="ciphertext", max_bytes=MAX_CIPHERTEXT_BYTES) + + @field_validator("salt") + @classmethod + def _check_salt(cls, value: str) -> str: + return _validate_b64(value, field_name="salt", max_bytes=64) + + @field_validator("iv") + @classmethod + def _check_iv(cls, value: str) -> str: + return _validate_b64(value, field_name="iv", max_bytes=64) + + +class SecureShareCreateResponse(BaseModel): + share_id: str + expires_at: datetime + + +class SecureShareGetResponse(BaseModel): + share_id: str + ciphertext: str + salt: str + iv: str + expires_at: datetime + created_by: str + + +def _secure_share_repository() -> SecureShareRepository: + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + raise HTTPException(status_code=500, detail={"error": CommonProxyErrors.db_not_connected_error.value}) + return SecureShareRepository(prisma_client) + + +def _require_proxy_admin(user_api_key_dict: UserAPIKeyAuth) -> None: + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value: + raise HTTPException(status_code=403, detail={"error": CommonProxyErrors.not_allowed_access.value}) + + +def _require_read_access(user_api_key_dict: UserAPIKeyAuth) -> None: + if user_api_key_dict.user_role not in _READ_ALLOWED_ROLES: + raise HTTPException( + status_code=403, + detail={"error": "Only proxy admins and internal users can view secure shares."}, + ) + + +@router.post( + "/secure_share/create", + tags=["Secure Share"], + dependencies=[Depends(user_api_key_auth)], + response_model=SecureShareCreateResponse, +) +async def create_secure_share( + request: SecureShareCreateRequest, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +) -> SecureShareCreateResponse: + _require_proxy_admin(user_api_key_dict) + + from litellm.proxy.proxy_server import litellm_proxy_admin_name + + repository = _secure_share_repository() + expires_at = datetime.now(timezone.utc) + request.expiry.duration + created = await repository.table.create( + data={ + "ciphertext": request.ciphertext, + "salt": request.salt, + "iv": request.iv, + "expires_at": expires_at, + "created_by": user_api_key_dict.user_id or litellm_proxy_admin_name, + } + ) + return SecureShareCreateResponse.model_validate(created, from_attributes=True) + + +@router.get( + "/secure_share/{share_id}", + tags=["Secure Share"], + dependencies=[Depends(user_api_key_auth)], + response_model=SecureShareGetResponse, +) +async def get_secure_share( + share_id: str = Path(description="id returned by /secure_share/create"), + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +) -> SecureShareGetResponse: + _require_read_access(user_api_key_dict) + + repository = _secure_share_repository() + row = await repository.table.find_unique(where={"share_id": share_id}) + if row is None: + raise HTTPException(status_code=404, detail={"error": "Secure share not found."}) + + share = SecureShareGetResponse.model_validate(row, from_attributes=True) + if _is_expired(share.expires_at): + await repository.table.delete(where={"share_id": share_id}) + raise HTTPException(status_code=status.HTTP_410_GONE, detail={"error": "Secure share has expired."}) + return share + + +@router.delete( + "/secure_share/{share_id}", + tags=["Secure Share"], + dependencies=[Depends(user_api_key_auth)], +) +async def delete_secure_share( + share_id: str = Path(description="id returned by /secure_share/create"), + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +) -> dict[str, str]: + _require_proxy_admin(user_api_key_dict) + + repository = _secure_share_repository() + deleted = await repository.table.delete(where={"share_id": share_id}) + if deleted is None: + raise HTTPException(status_code=404, detail={"error": "Secure share not found."}) + return {"share_id": share_id, "status": "deleted"} + + +def _is_expired(expires_at: datetime) -> bool: + reference = expires_at if expires_at.tzinfo is not None else expires_at.replace(tzinfo=timezone.utc) + return reference <= datetime.now(timezone.utc) diff --git a/litellm/repositories/__init__.py b/litellm/repositories/__init__.py index 4451f0865da..1551c028d5f 100644 --- a/litellm/repositories/__init__.py +++ b/litellm/repositories/__init__.py @@ -46,6 +46,7 @@ from litellm.repositories.table_repositories import ( PrismaTableRepository, PromptRepository, SearchToolsRepository, + SecureShareRepository, SkillsRepository, SpendLogGuardrailIndexRepository, SpendLogsRepository, @@ -101,6 +102,7 @@ __all__ = [ "DeletedTeamRepository", "SkillsRepository", "CacheConfigRepository", + "SecureShareRepository", "ManagedVectorStoreIndexRepository", "WorkflowMessageRepository", "DailyTagSpendRepository", diff --git a/litellm/repositories/table_repositories.py b/litellm/repositories/table_repositories.py index 7ce4607e1ca..6235652cea3 100644 --- a/litellm/repositories/table_repositories.py +++ b/litellm/repositories/table_repositories.py @@ -161,6 +161,10 @@ class CacheConfigRepository(PrismaTableRepository): table_name = "litellm_cacheconfig" +class SecureShareRepository(PrismaTableRepository): + table_name = "litellm_securesharetable" + + class ManagedVectorStoreIndexRepository(PrismaTableRepository): table_name = "litellm_managedvectorstoreindextable" diff --git a/schema.prisma b/schema.prisma index fb4d8d0b5a3..dfc5cae2291 100644 --- a/schema.prisma +++ b/schema.prisma @@ -1410,3 +1410,15 @@ model LiteLLM_WorkflowMessage { @@unique([run_id, sequence_number]) @@index([run_id]) } + +model LiteLLM_SecureShareTable { + share_id String @id @default(uuid()) + ciphertext String + salt String + iv String + expires_at DateTime + created_by String + created_at DateTime @default(now()) + + @@index([expires_at]) +} diff --git a/tests/test_litellm/proxy/secure_share/__init__.py b/tests/test_litellm/proxy/secure_share/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/proxy/secure_share/test_secure_share_endpoints.py b/tests/test_litellm/proxy/secure_share/test_secure_share_endpoints.py new file mode 100644 index 00000000000..6b0ebc6eb4e --- /dev/null +++ b/tests/test_litellm/proxy/secure_share/test_secure_share_endpoints.py @@ -0,0 +1,275 @@ +""" +Unit tests for secure share endpoints +""" + +import base64 +import os +import sys +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import HTTPException +from pydantic import ValidationError + +sys.path.insert(0, os.path.abspath("../../../..")) + +from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.secure_share.secure_share_endpoints import ( + SecureShareCreateRequest, + SecureShareExpiry, + create_secure_share, + delete_secure_share, + get_secure_share, +) + + +def _b64(raw: bytes) -> str: + return base64.b64encode(raw).decode() + + +def _valid_request(expiry: SecureShareExpiry = SecureShareExpiry.ONE_HOUR) -> SecureShareCreateRequest: + return SecureShareCreateRequest( + ciphertext=_b64(b"encrypted-secret-bytes"), + salt=_b64(b"0123456789abcdef"), + iv=_b64(b"0123456789ab"), + expiry=expiry, + ) + + +def _admin() -> UserAPIKeyAuth: + return UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin", user_id="admin-user") + + +def _fake_table() -> MagicMock: + table = MagicMock() + table.create = AsyncMock() + table.find_unique = AsyncMock() + table.delete = AsyncMock() + return table + + +def _patched_prisma(table: MagicMock): + prisma_client = MagicMock() + prisma_client.db.litellm_securesharetable = table + return patch.multiple( + "litellm.proxy.proxy_server", + prisma_client=prisma_client, + litellm_proxy_admin_name="default_admin", + ) + + +@pytest.mark.parametrize( + "expiry, expected", + [ + (SecureShareExpiry.ONE_HOUR, timedelta(hours=1)), + (SecureShareExpiry.SIX_HOURS, timedelta(hours=6)), + (SecureShareExpiry.ONE_DAY, timedelta(days=1)), + (SecureShareExpiry.SEVEN_DAYS, timedelta(days=7)), + ], +) +def test_expiry_durations(expiry: SecureShareExpiry, expected: timedelta): + assert expiry.duration == expected + + +def test_create_request_rejects_non_base64(): + with pytest.raises(ValidationError): + SecureShareCreateRequest(ciphertext="not base64!!", salt=_b64(b"salt"), iv=_b64(b"iv"), expiry="1h") + + +def test_create_request_rejects_oversized_ciphertext(): + with pytest.raises(ValidationError): + SecureShareCreateRequest( + ciphertext=_b64(b"x" * (256 * 1024 + 1)), + salt=_b64(b"salt"), + iv=_b64(b"iv"), + expiry="1h", + ) + + +def test_create_request_rejects_unknown_expiry(): + with pytest.raises(ValidationError): + SecureShareCreateRequest(ciphertext=_b64(b"c"), salt=_b64(b"s"), iv=_b64(b"i"), expiry="30d") + + +@pytest.mark.asyncio +async def test_create_stores_ciphertext_and_computes_expiry(): + request = _valid_request(SecureShareExpiry.ONE_DAY) + expires_at = datetime.now(timezone.utc) + timedelta(days=1) + table = _fake_table() + table.create.return_value = SimpleNamespace(share_id="share-123", expires_at=expires_at) + + before = datetime.now(timezone.utc) + with _patched_prisma(table): + result = await create_secure_share(request=request, user_api_key_dict=_admin()) + after = datetime.now(timezone.utc) + + assert result.share_id == "share-123" + stored = table.create.call_args.kwargs["data"] + assert stored["ciphertext"] == request.ciphertext + assert stored["salt"] == request.salt + assert stored["iv"] == request.iv + assert stored["created_by"] == "admin-user" + assert before + timedelta(days=1) <= stored["expires_at"] <= after + timedelta(days=1) + + +@pytest.mark.asyncio +async def test_create_falls_back_to_admin_name_when_no_user_id(): + table = _fake_table() + table.create.return_value = SimpleNamespace( + share_id="s", expires_at=datetime.now(timezone.utc) + timedelta(hours=1) + ) + caller = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin", user_id=None) + + with _patched_prisma(table): + await create_secure_share(request=_valid_request(), user_api_key_dict=caller) + + assert table.create.call_args.kwargs["data"]["created_by"] == "default_admin" + + +@pytest.mark.asyncio +async def test_create_forbidden_for_non_admin(): + table = _fake_table() + caller = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, api_key="sk-user", user_id="u1") + + with _patched_prisma(table): + with pytest.raises(HTTPException) as exc: + await create_secure_share(request=_valid_request(), user_api_key_dict=caller) + + assert exc.value.status_code == 403 + table.create.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "role", + [ + LitellmUserRoles.PROXY_ADMIN, + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + LitellmUserRoles.INTERNAL_USER, + LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, + ], +) +async def test_get_returns_unexpired_share_for_allowed_roles(role: LitellmUserRoles): + expires_at = datetime.now(timezone.utc) + timedelta(hours=1) + table = _fake_table() + table.find_unique.return_value = SimpleNamespace( + share_id="share-1", + ciphertext=_b64(b"c"), + salt=_b64(b"s"), + iv=_b64(b"i"), + expires_at=expires_at, + created_by="admin-user", + ) + caller = UserAPIKeyAuth(user_role=role, api_key="sk", user_id="u") + + with _patched_prisma(table): + result = await get_secure_share(share_id="share-1", user_api_key_dict=caller) + + assert result.share_id == "share-1" + assert result.ciphertext == _b64(b"c") + table.delete.assert_not_called() + + +@pytest.mark.asyncio +async def test_get_forbidden_for_customer_role(): + table = _fake_table() + caller = UserAPIKeyAuth(user_role=LitellmUserRoles.CUSTOMER, api_key="sk", user_id="u") + + with _patched_prisma(table): + with pytest.raises(HTTPException) as exc: + await get_secure_share(share_id="share-1", user_api_key_dict=caller) + + assert exc.value.status_code == 403 + table.find_unique.assert_not_called() + + +@pytest.mark.asyncio +async def test_get_missing_share_returns_404(): + table = _fake_table() + table.find_unique.return_value = None + + with _patched_prisma(table): + with pytest.raises(HTTPException) as exc: + await get_secure_share(share_id="missing", user_api_key_dict=_admin()) + + assert exc.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_expired_share_is_deleted_and_returns_410(): + expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) + table = _fake_table() + table.find_unique.return_value = SimpleNamespace( + share_id="share-old", + ciphertext=_b64(b"c"), + salt=_b64(b"s"), + iv=_b64(b"i"), + expires_at=expires_at, + created_by="admin-user", + ) + + with _patched_prisma(table): + with pytest.raises(HTTPException) as exc: + await get_secure_share(share_id="share-old", user_api_key_dict=_admin()) + + assert exc.value.status_code == 410 + table.delete.assert_awaited_once_with(where={"share_id": "share-old"}) + + +@pytest.mark.asyncio +async def test_get_treats_naive_expiry_as_utc(): + expires_at = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=1) + table = _fake_table() + table.find_unique.return_value = SimpleNamespace( + share_id="share-naive", + ciphertext=_b64(b"c"), + salt=_b64(b"s"), + iv=_b64(b"i"), + expires_at=expires_at, + created_by="admin-user", + ) + + with _patched_prisma(table): + with pytest.raises(HTTPException) as exc: + await get_secure_share(share_id="share-naive", user_api_key_dict=_admin()) + + assert exc.value.status_code == 410 + + +@pytest.mark.asyncio +async def test_delete_forbidden_for_non_admin(): + table = _fake_table() + caller = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, api_key="sk", user_id="u") + + with _patched_prisma(table): + with pytest.raises(HTTPException) as exc: + await delete_secure_share(share_id="share-1", user_api_key_dict=caller) + + assert exc.value.status_code == 403 + table.delete.assert_not_called() + + +@pytest.mark.asyncio +async def test_delete_removes_share_for_admin(): + table = _fake_table() + table.delete.return_value = SimpleNamespace(share_id="share-1") + + with _patched_prisma(table): + result = await delete_secure_share(share_id="share-1", user_api_key_dict=_admin()) + + assert result == {"share_id": "share-1", "status": "deleted"} + table.delete.assert_awaited_once_with(where={"share_id": "share-1"}) + + +@pytest.mark.asyncio +async def test_delete_missing_share_returns_404(): + table = _fake_table() + table.delete.return_value = None + + with _patched_prisma(table): + with pytest.raises(HTTPException) as exc: + await delete_secure_share(share_id="missing", user_api_key_dict=_admin()) + + assert exc.value.status_code == 404 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/secure-share/_components/SecureShareCreate.tsx b/ui/litellm-dashboard/src/app/(dashboard)/secure-share/_components/SecureShareCreate.tsx new file mode 100644 index 00000000000..bd5bc13e7e1 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/secure-share/_components/SecureShareCreate.tsx @@ -0,0 +1,163 @@ +import { CopyOutlined, LockOutlined } from "@ant-design/icons"; +import { Button, Card, Input, Select, Typography } from "antd"; +import React, { useState } from "react"; +import { createSecureShareCall } from "@/components/networking"; +import NotificationManager from "@/components/molecules/notifications_manager"; +import { encryptSecret } from "./crypto"; + +const { Title, Text, Paragraph } = Typography; + +const EXPIRY_OPTIONS = [ + { value: "1h", label: "1 hour" }, + { value: "6h", label: "6 hours" }, + { value: "1d", label: "1 day" }, + { value: "7d", label: "7 days" }, +]; + +const MIN_PASSWORD_LENGTH = 8; + +interface SecureShareCreateProps { + accessToken: string | null; +} + +interface CreatedShare { + link: string; + expiresAt: string; +} + +function buildShareLink(shareId: string): string { + const base = window.location.href.split(/[?#]/)[0].replace(/\/$/, ""); + return `${base}/view?id=${encodeURIComponent(shareId)}`; +} + +const SecureShareCreate: React.FC = ({ accessToken }) => { + const [secret, setSecret] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [expiry, setExpiry] = useState("1d"); + const [isSubmitting, setIsSubmitting] = useState(false); + const [created, setCreated] = useState(null); + + const reset = () => { + setSecret(""); + setPassword(""); + setConfirmPassword(""); + setExpiry("1d"); + setCreated(null); + }; + + const handleCreate = async () => { + if (!accessToken) { + NotificationManager.error("You must be logged in to create a secure share."); + return; + } + if (secret.trim().length === 0) { + NotificationManager.error("Enter the credential you want to share."); + return; + } + if (password.length < MIN_PASSWORD_LENGTH) { + NotificationManager.error(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`); + return; + } + if (password !== confirmPassword) { + NotificationManager.error("Passwords do not match."); + return; + } + + setIsSubmitting(true); + try { + const encrypted = await encryptSecret(secret, password); + const payload = { ...encrypted, expiry }; + const response = await createSecureShareCall(accessToken, payload); + setCreated({ link: buildShareLink(response.share_id), expiresAt: response.expires_at }); + NotificationManager.success("Secure share created. The secret was encrypted in your browser."); + } catch (error) { + NotificationManager.fromBackend(error); + } finally { + setIsSubmitting(false); + } + }; + + const copyLink = async () => { + if (!created) return; + await navigator.clipboard.writeText(created.link); + NotificationManager.success("Link copied to clipboard."); + }; + + if (created) { + return ( + + + <LockOutlined /> Secure share created + + + Send this link to the recipient. Share the password separately (not in the same channel). The recipient must + be logged in as a proxy admin or internal user to open it. + +
+ + +
+ + Expires at {new Date(created.expiresAt).toLocaleString()} + + +
+ ); + } + + return ( + + + <LockOutlined /> Secure Share + + + Share a credential over a temporary, end-to-end encrypted link. The secret is encrypted in your browser with a + key derived from the password you choose; the server only ever stores ciphertext. + + +
+ Credential + setSecret(e.target.value)} + placeholder="Paste the API key or secret to share" + /> +
+ +
+ Password + setPassword(e.target.value)} + placeholder={`At least ${MIN_PASSWORD_LENGTH} characters`} + /> +
+ +
+ Confirm password + setConfirmPassword(e.target.value)} + placeholder="Re-enter the password" + /> +
+ +
+ Expires after +