mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-15 23:31:29 +00:00
feat(proxy): move secure share into create-key flow as one-time link
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
e89a991702
commit
c643f6d1af
13 changed files with 182 additions and 211 deletions
|
|
@ -31,6 +31,7 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = (
|
|||
"/oauth/",
|
||||
"/invitation/",
|
||||
"/jwt/",
|
||||
"/secure_share/",
|
||||
# Models & routing config
|
||||
"/model/",
|
||||
"/v1/model/info",
|
||||
|
|
|
|||
|
|
@ -11,8 +11,11 @@ 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.
|
||||
|
||||
Links are one-time: a successful GET returns the ciphertext once and deletes
|
||||
the row, so the encrypted payload is exposed to the network at most once.
|
||||
|
||||
POST /secure_share/create - store an encrypted share (proxy admin only)
|
||||
GET /secure_share/{share_id} - fetch an unexpired share (admins + internal users)
|
||||
GET /secure_share/{share_id} - fetch and consume a share once (admins + internal users)
|
||||
DELETE /secure_share/{share_id} - revoke a share early (proxy admin only)
|
||||
"""
|
||||
|
||||
|
|
@ -178,8 +181,8 @@ async def get_secure_share(
|
|||
raise HTTPException(status_code=404, detail={"error": "Secure share not found."})
|
||||
|
||||
share = SecureShareGetResponse.model_validate(row, from_attributes=True)
|
||||
await repository.table.delete(where={"share_id": share_id})
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -169,7 +169,33 @@ async def test_get_returns_unexpired_share_for_allowed_roles(role: LitellmUserRo
|
|||
|
||||
assert result.share_id == "share-1"
|
||||
assert result.ciphertext == _b64(b"c")
|
||||
table.delete.assert_not_called()
|
||||
table.delete.assert_awaited_once_with(where={"share_id": "share-1"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_is_one_time_second_read_returns_404():
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
table = _fake_table()
|
||||
table.find_unique.side_effect = [
|
||||
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",
|
||||
),
|
||||
None,
|
||||
]
|
||||
|
||||
with _patched_prisma(table):
|
||||
first = await get_secure_share(share_id="share-1", user_api_key_dict=_admin())
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_secure_share(share_id="share-1", user_api_key_dict=_admin())
|
||||
|
||||
assert first.share_id == "share-1"
|
||||
table.delete.assert_awaited_once_with(where={"share_id": "share-1"})
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -1,163 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import LoadingScreen from "@/components/common_components/LoadingScreen";
|
||||
import { Suspense } from "react";
|
||||
import SecureShareView from "../_components/SecureShareView";
|
||||
import SecureShareView from "@/components/secure_share/SecureShareView";
|
||||
|
||||
function SecureShareViewPageContent() {
|
||||
const { isLoading, isAuthorized, accessToken } = useAuthorized();
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import {
|
|||
FolderOutlined,
|
||||
KeyOutlined,
|
||||
LineChartOutlined,
|
||||
LockOutlined,
|
||||
PlayCircleOutlined,
|
||||
RobotOutlined,
|
||||
SafetyOutlined,
|
||||
|
|
@ -273,13 +272,6 @@ const menuGroups: MenuGroup[] = [
|
|||
icon: <CreditCardOutlined />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "secure-share",
|
||||
page: "secure-share",
|
||||
label: "Secure Share",
|
||||
icon: <LockOutlined />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ import {
|
|||
userFilterUICall,
|
||||
} from "../networking";
|
||||
import CreatedKeyDisplay from "../shared/CreatedKeyDisplay";
|
||||
import SecureShareLinkButton from "../secure_share/SecureShareLinkButton";
|
||||
import NumericalInput from "../shared/numerical_input";
|
||||
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
|
||||
import { simplifyKeyGenerateError } from "./utils";
|
||||
|
|
@ -1738,7 +1739,10 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
<Title>Save your Key</Title>
|
||||
<Col numColSpan={1}>
|
||||
{apiKey != null ? (
|
||||
<CreatedKeyDisplay apiKey={apiKey} />
|
||||
<>
|
||||
<CreatedKeyDisplay apiKey={apiKey} />
|
||||
{keyOwner === "another_user" && <SecureShareLinkButton secret={apiKey} accessToken={accessToken} />}
|
||||
</>
|
||||
) : (
|
||||
<Text>Key being created, this might take 30s</Text>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
import { CopyOutlined, LockOutlined } from "@ant-design/icons";
|
||||
import { Button, Form, Input, Modal, Select, Typography } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { createSecureShareCall, SecureShareCreatePayload } from "@/components/networking";
|
||||
import NotificationManager from "@/components/molecules/notifications_manager";
|
||||
import { migratedHref } from "@/utils/migratedPages";
|
||||
import { encryptSecret } from "./crypto";
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
const EXPIRY_OPTIONS = [
|
||||
{ value: "1h", label: "1 hour" },
|
||||
{ value: "6h", label: "6 hours" },
|
||||
{ value: "1d", label: "1 day" },
|
||||
{ value: "7d", label: "7 days" },
|
||||
];
|
||||
|
||||
interface SecureShareLinkButtonProps {
|
||||
secret: string;
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
||||
interface ShareFormValues {
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
expiry: string;
|
||||
}
|
||||
|
||||
const buildShareLink = (shareId: string): string =>
|
||||
`${window.location.origin}${migratedHref("secure-share/view")}?id=${encodeURIComponent(shareId)}`;
|
||||
|
||||
const SecureShareLinkButton: React.FC<SecureShareLinkButtonProps> = ({ secret, accessToken }) => {
|
||||
const [form] = Form.useForm<ShareFormValues>();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [link, setLink] = useState<string | null>(null);
|
||||
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setLink(null);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const handleGenerate = async (values: ShareFormValues) => {
|
||||
if (!accessToken) {
|
||||
NotificationManager.error("You must be logged in to create a share link.");
|
||||
return;
|
||||
}
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const encrypted = await encryptSecret(secret, values.password);
|
||||
const payload: SecureShareCreatePayload = { ...encrypted, expiry: values.expiry };
|
||||
const response = await createSecureShareCall(accessToken, payload);
|
||||
setLink(buildShareLink(response.share_id));
|
||||
} catch (error) {
|
||||
NotificationManager.fromBackend(error);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyLink = async () => {
|
||||
if (link === null) return;
|
||||
await navigator.clipboard.writeText(link);
|
||||
NotificationManager.success("Share link copied to clipboard.");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button icon={<LockOutlined />} onClick={() => setIsModalOpen(true)} style={{ marginTop: 12, marginLeft: 8 }}>
|
||||
Generate one-time share link
|
||||
</Button>
|
||||
<Modal
|
||||
title="Share this key over a one-time encrypted link"
|
||||
open={isModalOpen}
|
||||
onCancel={closeModal}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
>
|
||||
{link === null ? (
|
||||
<>
|
||||
<Paragraph>
|
||||
<Text type="secondary">
|
||||
The key is encrypted in your browser with the password below and never reaches the server in plaintext.
|
||||
Share the link and password separately; the link opens once, then it is gone.
|
||||
</Text>
|
||||
</Paragraph>
|
||||
<Form form={form} layout="vertical" onFinish={handleGenerate} initialValues={{ expiry: "1d" }}>
|
||||
<Form.Item
|
||||
label="Password"
|
||||
name="password"
|
||||
rules={[{ required: true, min: MIN_PASSWORD_LENGTH, message: "Use at least 8 characters." }]}
|
||||
>
|
||||
<Input.Password placeholder="Password the recipient will enter" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Confirm password"
|
||||
name="confirmPassword"
|
||||
dependencies={["password"]}
|
||||
rules={[
|
||||
{ required: true, message: "Confirm the password." },
|
||||
({ getFieldValue }) => ({
|
||||
validator: (_, value) =>
|
||||
!value || getFieldValue("password") === value
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error("Passwords do not match.")),
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="Re-enter the password" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Link expires after" name="expiry" rules={[{ required: true }]}>
|
||||
<Select options={EXPIRY_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={isGenerating}>
|
||||
Generate link
|
||||
</Button>
|
||||
</Form>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Paragraph>
|
||||
<Text type="secondary">
|
||||
Send this link to the recipient and give them the password out-of-band. It reveals the key once, then
|
||||
expires.
|
||||
</Text>
|
||||
</Paragraph>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input readOnly value={link} />
|
||||
<Button icon={<CopyOutlined />} onClick={copyLink}>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecureShareLinkButton;
|
||||
|
|
@ -49,7 +49,6 @@ 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