mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
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>
This commit is contained in:
parent
b9008cca35
commit
e89a991702
21 changed files with 1218 additions and 0 deletions
|
|
@ -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");
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
0
litellm/proxy/secure_share/__init__.py
Normal file
0
litellm/proxy/secure_share/__init__.py
Normal file
207
litellm/proxy/secure_share/secure_share_endpoints.py
Normal file
207
litellm/proxy/secure_share/secure_share_endpoints.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
0
tests/test_litellm/proxy/secure_share/__init__.py
Normal file
0
tests/test_litellm/proxy/secure_share/__init__.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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<SecureShareCreateProps> = ({ 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<CreatedShare | null>(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 (
|
||||
<Card>
|
||||
<Title level={4}>
|
||||
<LockOutlined /> Secure share created
|
||||
</Title>
|
||||
<Paragraph>
|
||||
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.
|
||||
</Paragraph>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input readOnly value={created.link} />
|
||||
<Button icon={<CopyOutlined />} onClick={copyLink}>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
<Paragraph className="mt-4">
|
||||
<Text type="secondary">Expires at {new Date(created.expiresAt).toLocaleString()}</Text>
|
||||
</Paragraph>
|
||||
<Button type="primary" onClick={reset}>
|
||||
Share another
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Title level={4}>
|
||||
<LockOutlined /> Secure Share
|
||||
</Title>
|
||||
<Paragraph>
|
||||
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.
|
||||
</Paragraph>
|
||||
|
||||
<div className="mb-4">
|
||||
<Text>Credential</Text>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
placeholder="Paste the API key or secret to share"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<Text>Password</Text>
|
||||
<Input.Password
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={`At least ${MIN_PASSWORD_LENGTH} characters`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<Text>Confirm password</Text>
|
||||
<Input.Password
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Re-enter the password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<Text>Expires after</Text>
|
||||
<Select value={expiry} onChange={setExpiry} options={EXPIRY_OPTIONS} className="w-full" />
|
||||
</div>
|
||||
|
||||
<Button type="primary" loading={isSubmitting} onClick={handleCreate}>
|
||||
Create secure link
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecureShareCreate;
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
import { CopyOutlined, LockOutlined } from "@ant-design/icons";
|
||||
import { Button, Card, Input, Typography } from "antd";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import { getSecureShareCall } from "@/components/networking";
|
||||
import NotificationManager from "@/components/molecules/notifications_manager";
|
||||
import { decryptSecret } from "./crypto";
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
interface SecureShareViewProps {
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
||||
const SecureShareView: React.FC<SecureShareViewProps> = ({ accessToken }) => {
|
||||
const searchParams = useSearchParams();
|
||||
const shareId = searchParams.get("id");
|
||||
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [secret, setSecret] = useState<string | null>(null);
|
||||
|
||||
const handleReveal = async () => {
|
||||
if (!accessToken) {
|
||||
NotificationManager.error("You must be logged in to view a secure share.");
|
||||
return;
|
||||
}
|
||||
if (!shareId) {
|
||||
NotificationManager.error("This link is missing its share id.");
|
||||
return;
|
||||
}
|
||||
if (password.length === 0) {
|
||||
NotificationManager.error("Enter the password you were given.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const share = await getSecureShareCall(accessToken, shareId);
|
||||
try {
|
||||
const plaintext = await decryptSecret(
|
||||
{ ciphertext: share.ciphertext, salt: share.salt, iv: share.iv },
|
||||
password,
|
||||
);
|
||||
setSecret(plaintext);
|
||||
} catch {
|
||||
NotificationManager.error("Wrong password, or this link has been tampered with.");
|
||||
}
|
||||
} catch (error) {
|
||||
NotificationManager.fromBackend(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copySecret = async () => {
|
||||
if (secret === null) return;
|
||||
await navigator.clipboard.writeText(secret);
|
||||
NotificationManager.success("Secret copied to clipboard.");
|
||||
};
|
||||
|
||||
if (!shareId) {
|
||||
return (
|
||||
<Card>
|
||||
<Title level={4}>Secure Share</Title>
|
||||
<Paragraph>This link is missing its share id.</Paragraph>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (secret !== null) {
|
||||
return (
|
||||
<Card>
|
||||
<Title level={4}>
|
||||
<LockOutlined /> Shared credential
|
||||
</Title>
|
||||
<Paragraph>
|
||||
<Text type="secondary">Decrypted in your browser. Copy it now; the link expires automatically.</Text>
|
||||
</Paragraph>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input.TextArea readOnly rows={4} value={secret} />
|
||||
<Button icon={<CopyOutlined />} onClick={copySecret}>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Title level={4}>
|
||||
<LockOutlined /> Open secure share
|
||||
</Title>
|
||||
<Paragraph>Enter the password you received to decrypt this credential in your browser.</Paragraph>
|
||||
<div className="mb-4">
|
||||
<Input.Password
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onPressEnter={handleReveal}
|
||||
placeholder="Password"
|
||||
/>
|
||||
</div>
|
||||
<Button type="primary" loading={isLoading} onClick={handleReveal}>
|
||||
Reveal secret
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecureShareView;
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { webcrypto } from "node:crypto";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { decryptSecret, encryptSecret } from "./crypto";
|
||||
|
||||
beforeAll(() => {
|
||||
if (typeof globalThis.crypto?.subtle === "undefined") {
|
||||
Object.defineProperty(globalThis, "crypto", { value: webcrypto, configurable: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("secure share crypto", () => {
|
||||
it("round-trips a secret with the correct password", async () => {
|
||||
const secret = "sk-super-secret-value-123";
|
||||
const payload = await encryptSecret(secret, "correct horse battery staple");
|
||||
|
||||
expect(payload.ciphertext).not.toContain(secret);
|
||||
await expect(decryptSecret(payload, "correct horse battery staple")).resolves.toBe(secret);
|
||||
});
|
||||
|
||||
it("produces a fresh salt and iv on every call", async () => {
|
||||
const first = await encryptSecret("value", "password123");
|
||||
const second = await encryptSecret("value", "password123");
|
||||
|
||||
expect(first.salt).not.toBe(second.salt);
|
||||
expect(first.iv).not.toBe(second.iv);
|
||||
expect(first.ciphertext).not.toBe(second.ciphertext);
|
||||
});
|
||||
|
||||
it("rejects decryption with the wrong password", async () => {
|
||||
const payload = await encryptSecret("value", "password123");
|
||||
await expect(decryptSecret(payload, "wrong-password")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects decryption when the ciphertext is tampered with", async () => {
|
||||
const payload = await encryptSecret("value", "password123");
|
||||
const tampered = { ...payload, ciphertext: btoa("tampered-ciphertext-bytes") };
|
||||
await expect(decryptSecret(tampered, "password123")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
const PBKDF2_ITERATIONS = 210_000;
|
||||
const SALT_BYTES = 16;
|
||||
const IV_BYTES = 12;
|
||||
const AES_KEY_BITS = 256;
|
||||
|
||||
export interface EncryptedPayload {
|
||||
ciphertext: string;
|
||||
salt: string;
|
||||
iv: string;
|
||||
}
|
||||
|
||||
function toBase64(bytes: Uint8Array<ArrayBuffer>): string {
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function fromBase64(value: string): Uint8Array<ArrayBuffer> {
|
||||
const binary = atob(value);
|
||||
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function deriveKey(password: string, salt: Uint8Array<ArrayBuffer>): Promise<CryptoKey> {
|
||||
const keyMaterial = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, [
|
||||
"deriveKey",
|
||||
]);
|
||||
const kdfParams: Pbkdf2Params = { name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" };
|
||||
return crypto.subtle.deriveKey(kdfParams, keyMaterial, { name: "AES-GCM", length: AES_KEY_BITS }, false, [
|
||||
"encrypt",
|
||||
"decrypt",
|
||||
]);
|
||||
}
|
||||
|
||||
export async function encryptSecret(plaintext: string, password: string): Promise<EncryptedPayload> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
|
||||
const key = await deriveKey(password, salt);
|
||||
const cipher = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(plaintext));
|
||||
return {
|
||||
ciphertext: toBase64(new Uint8Array(cipher)),
|
||||
salt: toBase64(salt),
|
||||
iv: toBase64(iv),
|
||||
};
|
||||
}
|
||||
|
||||
export async function decryptSecret(payload: EncryptedPayload, password: string): Promise<string> {
|
||||
const salt = fromBase64(payload.salt);
|
||||
const iv = fromBase64(payload.iv);
|
||||
const key = await deriveKey(password, salt);
|
||||
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, fromBase64(payload.ciphertext));
|
||||
return new TextDecoder().decode(plain);
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
"use client";
|
||||
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import LoadingScreen from "@/components/common_components/LoadingScreen";
|
||||
import { isAdminRole } from "@/utils/roles";
|
||||
import { Card, Typography } from "antd";
|
||||
import { Suspense } from "react";
|
||||
import SecureShareCreate from "./_components/SecureShareCreate";
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
function SecureSharePageContent() {
|
||||
const { isLoading, isAuthorized, accessToken, userRole } = useAuthorized();
|
||||
if (isLoading || !isAuthorized) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
if (!userRole || !isAdminRole(userRole)) {
|
||||
return (
|
||||
<Card>
|
||||
<Title level={4}>Secure Share</Title>
|
||||
<Paragraph>Only proxy admins can create secure shares.</Paragraph>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return <SecureShareCreate accessToken={accessToken} />;
|
||||
}
|
||||
|
||||
export default function SecureSharePage() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingScreen />}>
|
||||
<SecureSharePageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import LoadingScreen from "@/components/common_components/LoadingScreen";
|
||||
import { Suspense } from "react";
|
||||
import SecureShareView from "../_components/SecureShareView";
|
||||
|
||||
function SecureShareViewPageContent() {
|
||||
const { isLoading, isAuthorized, accessToken } = useAuthorized();
|
||||
if (isLoading || !isAuthorized) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
return <SecureShareView accessToken={accessToken} />;
|
||||
}
|
||||
|
||||
export default function SecureShareViewPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingScreen />}>
|
||||
<SecureShareViewPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import {
|
|||
FolderOutlined,
|
||||
KeyOutlined,
|
||||
LineChartOutlined,
|
||||
LockOutlined,
|
||||
PlayCircleOutlined,
|
||||
RobotOutlined,
|
||||
SafetyOutlined,
|
||||
|
|
@ -272,6 +273,13 @@ const menuGroups: MenuGroup[] = [
|
|||
icon: <CreditCardOutlined />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "secure-share",
|
||||
page: "secure-share",
|
||||
label: "Secure Share",
|
||||
icon: <LockOutlined />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3196,6 +3196,62 @@ export const updateCacheSettingsCall = async (accessToken: string, cacheSettings
|
|||
}
|
||||
};
|
||||
|
||||
export interface SecureShareCreatePayload {
|
||||
ciphertext: string;
|
||||
salt: string;
|
||||
iv: string;
|
||||
expiry: string;
|
||||
}
|
||||
|
||||
export interface SecureShareCreateResponse {
|
||||
share_id: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export interface SecureShareGetResponse {
|
||||
share_id: string;
|
||||
ciphertext: string;
|
||||
salt: string;
|
||||
iv: string;
|
||||
expires_at: string;
|
||||
created_by: string;
|
||||
}
|
||||
|
||||
export const createSecureShareCall = async (
|
||||
accessToken: string,
|
||||
payload: SecureShareCreatePayload,
|
||||
): Promise<SecureShareCreateResponse> => {
|
||||
try {
|
||||
return await apiClient.post<SecureShareCreateResponse>(`/secure_share/create`, {
|
||||
accessToken,
|
||||
body: payload,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to create secure share:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getSecureShareCall = async (accessToken: string, shareId: string): Promise<SecureShareGetResponse> => {
|
||||
try {
|
||||
return await apiClient.get<SecureShareGetResponse>(`/secure_share/${encodeURIComponent(shareId)}`, {
|
||||
accessToken,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch secure share:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteSecureShareCall = async (accessToken: string, shareId: string) => {
|
||||
try {
|
||||
return await apiClient.delete(`/secure_share/${encodeURIComponent(shareId)}`, { accessToken });
|
||||
} catch (error) {
|
||||
console.error("Failed to delete secure share:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getPassThroughEndpointsCall = async (accessToken: string, teamId?: string | null) => {
|
||||
try {
|
||||
let path = `/config/pass_through_endpoint`;
|
||||
|
|
|
|||
187
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
187
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -12197,6 +12197,41 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/secure_share/create": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Create Secure Share */
|
||||
post: operations["create_secure_share_secure_share_create_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/secure_share/{share_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get Secure Share */
|
||||
get: operations["get_secure_share_secure_share__share_id__get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** Delete Secure Share */
|
||||
delete: operations["delete_secure_share_secure_share__share_id__delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/settings": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -30536,6 +30571,59 @@ export interface components {
|
|||
/** Timeout */
|
||||
timeout?: number | null;
|
||||
};
|
||||
/** SecureShareCreateRequest */
|
||||
SecureShareCreateRequest: {
|
||||
/**
|
||||
* Ciphertext
|
||||
* @description base64 AES-256-GCM ciphertext of the secret
|
||||
*/
|
||||
ciphertext: string;
|
||||
/** @description how long the share stays retrievable */
|
||||
expiry: components["schemas"]["SecureShareExpiry"];
|
||||
/**
|
||||
* Iv
|
||||
* @description base64 AES-GCM initialization vector
|
||||
*/
|
||||
iv: string;
|
||||
/**
|
||||
* Salt
|
||||
* @description base64 PBKDF2 salt used to derive the AES key
|
||||
*/
|
||||
salt: string;
|
||||
};
|
||||
/** SecureShareCreateResponse */
|
||||
SecureShareCreateResponse: {
|
||||
/**
|
||||
* Expires At
|
||||
* Format: date-time
|
||||
*/
|
||||
expires_at: string;
|
||||
/** Share Id */
|
||||
share_id: string;
|
||||
};
|
||||
/**
|
||||
* SecureShareExpiry
|
||||
* @enum {string}
|
||||
*/
|
||||
SecureShareExpiry: "1h" | "6h" | "1d" | "7d";
|
||||
/** SecureShareGetResponse */
|
||||
SecureShareGetResponse: {
|
||||
/** Ciphertext */
|
||||
ciphertext: string;
|
||||
/** Created By */
|
||||
created_by: string;
|
||||
/**
|
||||
* Expires At
|
||||
* Format: date-time
|
||||
*/
|
||||
expires_at: string;
|
||||
/** Iv */
|
||||
iv: string;
|
||||
/** Salt */
|
||||
salt: string;
|
||||
/** Share Id */
|
||||
share_id: string;
|
||||
};
|
||||
/**
|
||||
* Skill
|
||||
* @description Represents a skill from the Anthropic Skills API
|
||||
|
|
@ -48467,6 +48555,105 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
create_secure_share_secure_share_create_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["SecureShareCreateRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SecureShareCreateResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_secure_share_secure_share__share_id__get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description id returned by /secure_share/create */
|
||||
share_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SecureShareGetResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete_secure_share_secure_share__share_id__delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description id returned by /secure_share/create */
|
||||
share_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
active_callbacks_settings_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export const MIGRATED_PAGES: Record<string, string> = {
|
|||
users: "users",
|
||||
teams: "teams",
|
||||
organizations: "organizations",
|
||||
"secure-share": "secure-share",
|
||||
};
|
||||
|
||||
function uiBase(): string {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue