fix(admin): remove model reauthentication
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { queryAdminRows } from "@/lib/admin/database";
|
||||
import { adminErrorResponse, invalidQueryResponse, requireHighRiskAdminMutation } from "@/lib/admin/http";
|
||||
import { adminErrorResponse, invalidQueryResponse, requireAdminMutation } from "@/lib/admin/http";
|
||||
import { requestAllowedModelProvider } from "@/lib/epay/gateway-policy";
|
||||
import { decryptModelProviderApiKey, modelProviderModelsUrl, modelProviderRequestHeaders, type ModelProviderType } from "@/lib/model-provider-policy";
|
||||
|
||||
@@ -8,4 +8,4 @@ export const runtime="nodejs";
|
||||
const schema=z.object({providerId:z.string().uuid()}).strict();
|
||||
type Row={provider_type:ModelProviderType;base_url:string|null;encrypted_api_key:string|null;enabled:boolean};
|
||||
function normalize(payload:unknown){const data=payload&&typeof payload==="object"&&"data" in payload?(payload as {data?:unknown}).data:null;if(!Array.isArray(data))return[];const seen=new Set<string>();const out:{id:string;label:string}[]=[];for(const item of data){if(out.length>=200)break;if(!item||typeof item!=="object")continue;const raw="id" in item?(item as {id?:unknown}).id:undefined;if(typeof raw!=="string")continue;const id=raw.trim();if(!id||id.length>160||seen.has(id))continue;seen.add(id);const display="display_name" in item?(item as {display_name?:unknown}).display_name:undefined;out.push({id,label:typeof display==="string"&&display.trim()?display.trim().slice(0,160):id});}return out;}
|
||||
export async function POST(request:Request){try{await requireHighRiskAdminMutation(request,"models.test");const parsed=schema.safeParse(await request.json().catch(()=>null));if(!parsed.success)return invalidQueryResponse(parsed.error.flatten());const rows=await queryAdminRows<Row>("select provider_type,base_url,encrypted_api_key,enabled from public.model_providers where id=$1",[parsed.data.providerId]);const p=rows[0];if(!p||!p.enabled)return Response.json({error:"模型供应商不可用"},{status:404});let key="";try{key=decryptModelProviderApiKey({encryptedApiKey:p.encrypted_api_key});}catch{return Response.json({error:"模型供应商配置不可用"},{status:409});}const upstream=await requestAllowedModelProvider(modelProviderModelsUrl({providerType:p.provider_type,baseUrl:p.base_url}),modelProviderRequestHeaders(p.provider_type,key),process.env,{timeoutMs:8000,maxResponseBytes:256*1024});if(upstream.status<200||upstream.status>299)return Response.json({error:"模型发现失败"},{status:502});let payload:unknown;try{payload=JSON.parse(upstream.body.toString("utf8"));}catch{return Response.json({error:"模型发现失败"},{status:502});}return Response.json({data:normalize(payload)});}catch(e){return adminErrorResponse(e);}}
|
||||
export async function POST(request:Request){try{await requireAdminMutation(request,"models.test");const parsed=schema.safeParse(await request.json().catch(()=>null));if(!parsed.success)return invalidQueryResponse(parsed.error.flatten());const rows=await queryAdminRows<Row>("select provider_type,base_url,encrypted_api_key,enabled from public.model_providers where id=$1",[parsed.data.providerId]);const p=rows[0];if(!p||!p.enabled)return Response.json({error:"模型供应商不可用"},{status:404});let key="";try{key=decryptModelProviderApiKey({encryptedApiKey:p.encrypted_api_key});}catch{return Response.json({error:"模型供应商配置不可用"},{status:409});}const upstream=await requestAllowedModelProvider(modelProviderModelsUrl({providerType:p.provider_type,baseUrl:p.base_url}),modelProviderRequestHeaders(p.provider_type,key),process.env,{timeoutMs:8000,maxResponseBytes:256*1024});if(upstream.status<200||upstream.status>299)return Response.json({error:"模型发现失败"},{status:502});let payload:unknown;try{payload=JSON.parse(upstream.body.toString("utf8"));}catch{return Response.json({error:"模型发现失败"},{status:502});}return Response.json({data:normalize(payload)});}catch(e){return adminErrorResponse(e);}}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
parseListQuery,
|
||||
requestId,
|
||||
requireAdminMutation,
|
||||
requireHighRiskAdminMutation,
|
||||
} from "@/lib/admin/http";
|
||||
import { assertAllowedModelProviderUrl, probeAllowedModelProvider } from "@/lib/epay/gateway-policy";
|
||||
import { handleAdminModelMutation, type AdminModelMutation } from "@/lib/admin/model-mutation-handler";
|
||||
@@ -177,10 +176,7 @@ export async function POST(request: Request) {
|
||||
: body.data.action === "test"
|
||||
? "models.test"
|
||||
: "models.write";
|
||||
const highRisk = body.data.action === "saveProvider" || body.data.action === "publish" || body.data.action === "rollback";
|
||||
const session = highRisk
|
||||
? await requireHighRiskAdminMutation(request, permission)
|
||||
: await requireAdminMutation(request, permission);
|
||||
const session = await requireAdminMutation(request, permission);
|
||||
const rid = requestId(request);
|
||||
return await handleAdminModelMutation(
|
||||
body.data as AdminModelMutation,
|
||||
|
||||
@@ -118,7 +118,6 @@ export default function ModelManagement() {
|
||||
const [editingModel, setEditingModel] = useState<ModelVersion | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [discovering, setDiscovering] = useState(false);
|
||||
const [discoverProviderId, setDiscoverProviderId] = useState<string | null>(null);
|
||||
const [discoveredModels, setDiscoveredModels] = useState<DiscoveredModel[]>([]);
|
||||
const [actingId, setActingId] = useState<string | null>(null);
|
||||
const [versionAction, setVersionAction] = useState<VersionAction | null>(null);
|
||||
@@ -240,17 +239,18 @@ export default function ModelManagement() {
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverModels() {
|
||||
if (!discoverProviderId) return;
|
||||
async function discoverModels(providerId?: string) {
|
||||
if (!providerId) return;
|
||||
setDiscovering(true);
|
||||
try {
|
||||
const payload = await adminRequestJson<DiscoveredModelsPayload>("/api/admin/models/discover", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ providerId: discoverProviderId }),
|
||||
body: JSON.stringify({ providerId }),
|
||||
});
|
||||
setDiscoveredModels(payload.data);
|
||||
if (!payload.data.length) message.info("未发现可用模型,可继续手工输入");
|
||||
setDiscoverProviderId(null);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "获取模型列表失败");
|
||||
} finally {
|
||||
setDiscovering(false);
|
||||
}
|
||||
@@ -426,7 +426,7 @@ export default function ModelManagement() {
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={8}><Form.Item name="modelId" label="模型 ID" rules={[{ required: true }, { pattern: /^[a-z0-9][a-z0-9._-]{0,63}$/ }]}><Input disabled={Boolean(editingModel)} /></Form.Item></Col>
|
||||
<Col xs={24} md={8}><Form.Item name="label" label="显示名称" rules={[{ required: true }]}><Input /></Form.Item></Col>
|
||||
<Col xs={24} md={8}><Form.Item label="供应商" required><Space.Compact block><Form.Item name="providerId" noStyle rules={[{ required: true, message: "请选择供应商" }]}><Select onChange={() => setDiscoveredModels([])} options={providers.map((item) => ({ value: item.id, label: `${item.name} (${item.code})` }))} /></Form.Item><Button loading={discovering} disabled={!selectedProviderId} onClick={() => setDiscoverProviderId(selectedProviderId)}>获取模型列表</Button></Space.Compact></Form.Item></Col>
|
||||
<Col xs={24} md={8}><Form.Item label="供应商" required><Space.Compact block><Form.Item name="providerId" noStyle rules={[{ required: true, message: "请选择供应商" }]}><Select onChange={() => setDiscoveredModels([])} options={providers.map((item) => ({ value: item.id, label: `${item.name} (${item.code})` }))} /></Form.Item><Button loading={discovering} disabled={!selectedProviderId} onClick={() => void discoverModels(selectedProviderId)}>获取模型列表</Button></Space.Compact></Form.Item></Col>
|
||||
</Row>
|
||||
<Form.Item label="发现的模型" extra="选择后会填入供应商模型名;模型 ID 和显示名称仅在为空时自动补全。">
|
||||
<Select
|
||||
@@ -457,21 +457,11 @@ export default function ModelManagement() {
|
||||
<Form.Item name="reason" label="修改原因" rules={[{ required: true }, { max: 500 }]}><Input.TextArea rows={2} /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
<ReasonActionModal
|
||||
open={Boolean(discoverProviderId)}
|
||||
title="获取供应商模型列表"
|
||||
okText="验证并获取"
|
||||
confirmLoading={discovering}
|
||||
reauthPermission="models.test"
|
||||
onCancel={() => setDiscoverProviderId(null)}
|
||||
onSubmit={discoverModels}
|
||||
/>
|
||||
<ReasonActionModal
|
||||
open={Boolean(pendingProvider)}
|
||||
title="保存模型供应商"
|
||||
okText="验证并保存"
|
||||
okText="保存"
|
||||
confirmLoading={saving}
|
||||
reauthPermission="models.write"
|
||||
onCancel={() => setPendingProvider(null)}
|
||||
onSubmit={saveProvider}
|
||||
/>
|
||||
@@ -481,7 +471,6 @@ export default function ModelManagement() {
|
||||
okText={versionAction?.action === "rollback" ? "确认回滚" : "确认发布"}
|
||||
danger={versionAction?.action === "rollback"}
|
||||
confirmLoading={Boolean(actingId)}
|
||||
reauthPermission={versionAction?.action === "rollback" ? "models.rollback" : "models.publish"}
|
||||
onCancel={() => setVersionAction(null)}
|
||||
onSubmit={submitVersionAction}
|
||||
/>
|
||||
|
||||
@@ -19,10 +19,10 @@ test("provider form keeps codes server-owned and API keys write-only", () => {
|
||||
});
|
||||
|
||||
test("model discovery stays searchable with a manual provider model fallback", () => {
|
||||
assert.match(component, /onClick=\{\(\) => setDiscoverProviderId\(selectedProviderId\)\}>获取模型列表<\/Button>/);
|
||||
assert.match(component, /title="获取供应商模型列表"[\s\S]*reauthPermission="models\.test"[\s\S]*onSubmit=\{discoverModels\}/);
|
||||
assert.match(component, /onClick=\{\(\) => void discoverModels\(selectedProviderId\)\}>获取模型列表<\/Button>/);
|
||||
assert.doesNotMatch(component, /title="获取供应商模型列表"[\s\S]*ReasonActionModal/);
|
||||
assert.match(component, /adminRequestJson<DiscoveredModelsPayload>\("\/api\/admin\/models\/discover"/);
|
||||
assert.match(component, /body: JSON\.stringify\(\{ providerId: discoverProviderId \}\)/);
|
||||
assert.match(component, /body: JSON\.stringify\(\{ providerId \}\)/);
|
||||
assert.match(component, /获取模型列表/);
|
||||
assert.match(component, /<Select[\s\S]*showSearch[\s\S]*optionFilterProp="label"[\s\S]*onSelect=\{selectDiscoveredModel\}/);
|
||||
assert.match(component, /name="providerModel" label="供应商模型名(可手工输入)"/);
|
||||
@@ -31,6 +31,13 @@ test("model discovery stays searchable with a manual provider model fallback", (
|
||||
assert.match(component, /!values\.label/);
|
||||
});
|
||||
|
||||
test("model mutations keep audit reasons without requesting email verification", () => {
|
||||
assert.doesNotMatch(component, /reauthPermission=[^\n]*models\./);
|
||||
assert.doesNotMatch(component, /验证并(?:保存|获取)/);
|
||||
assert.match(component, /title="保存模型供应商"[\s\S]*okText="保存"[\s\S]*onSubmit=\{saveProvider\}/);
|
||||
assert.match(component, /title=\{versionAction\?\.action[\s\S]*onSubmit=\{submitVersionAction\}/);
|
||||
});
|
||||
|
||||
test("provider rows expose a direct add-model entry with the provider preselected", () => {
|
||||
assert.match(component, /function openModel\(model\?: ModelVersion, providerId\?: string\)/);
|
||||
assert.match(component, /providerId: providerId \?\? providers\[0\]\?\.id/);
|
||||
|
||||
@@ -41,24 +41,6 @@ const highRiskMappings = [
|
||||
api: "src/app/api/admin/codes/[id]/route.ts",
|
||||
permission: "billing.adjustments.write",
|
||||
},
|
||||
{
|
||||
name: "供应商保存",
|
||||
ui: "src/components/admin/model-management.tsx",
|
||||
api: "src/app/api/admin/models/route.ts",
|
||||
permission: "models.write",
|
||||
},
|
||||
{
|
||||
name: "模型发布",
|
||||
ui: "src/components/admin/model-management.tsx",
|
||||
api: "src/app/api/admin/models/route.ts",
|
||||
permission: "models.publish",
|
||||
},
|
||||
{
|
||||
name: "模型回滚",
|
||||
ui: "src/components/admin/model-management.tsx",
|
||||
api: "src/app/api/admin/models/route.ts",
|
||||
permission: "models.rollback",
|
||||
},
|
||||
{
|
||||
name: "功能开关发布",
|
||||
ui: "src/components/admin/feature-flags-management.tsx",
|
||||
@@ -92,6 +74,22 @@ test("high-risk UI reauth permissions match their API guards", async (t) => {
|
||||
}
|
||||
});
|
||||
|
||||
test("model management uses ordinary admin mutation guards without reauth", () => {
|
||||
const ui = source("src/components/admin/model-management.tsx");
|
||||
const route = source("src/app/api/admin/models/route.ts");
|
||||
const discoverRoute = source("src/app/api/admin/models/discover/route.ts");
|
||||
|
||||
for (const permission of ["models.write", "models.test", "models.publish", "models.rollback"]) {
|
||||
assert.ok(ui.includes(`permissions.includes("${permission}")`));
|
||||
}
|
||||
assert.match(route, /requireAdminMutation\(request, permission\)/);
|
||||
assert.doesNotMatch(route, /requireHighRiskAdminMutation/);
|
||||
assert.match(discoverRoute, /requireAdminMutation\(request,"models\.test"\)/);
|
||||
assert.doesNotMatch(discoverRoute, /requireHighRiskAdminMutation/);
|
||||
assert.doesNotMatch(ui, /reauthPermission=[^\n]*models\./);
|
||||
assert.doesNotMatch(ui, /验证并(?:保存|获取)/);
|
||||
});
|
||||
|
||||
test("admin request failures are real Error instances and reason modal keeps failures visible", () => {
|
||||
const providers = source("src/lib/admin/providers.ts");
|
||||
const modal = source("src/components/admin/reason-action-modal.tsx");
|
||||
|
||||
@@ -12,6 +12,7 @@ const epaySettingsRoute = source("src/app/api/admin/epay-settings/route.ts");
|
||||
const subscriptionsRoute = source("src/app/api/admin/subscriptions/route.ts");
|
||||
const featureFlagsRoute = source("src/app/api/admin/feature-flags/route.ts");
|
||||
const modelsRoute = source("src/app/api/admin/models/route.ts");
|
||||
const modelDiscoveryRoute = source("src/app/api/admin/models/discover/route.ts");
|
||||
const modelMutationHandler = source("src/lib/admin/model-mutation-handler.ts");
|
||||
const ordersRoute = source("src/app/api/admin/orders/route.ts");
|
||||
const codesRoute = source("src/app/api/admin/codes/route.ts");
|
||||
@@ -48,9 +49,11 @@ test("billing and operations high-risk writes use the shared mutation guard", ()
|
||||
assert.match(featureFlagsRoute, /admin_publish_feature_flag[\s\S]*b\.data\.reason,rid/);
|
||||
});
|
||||
|
||||
test("model provider changes and release mutations already use the shared high-risk guard", () => {
|
||||
assert.match(modelsRoute, /action === "saveProvider" \|\| body\.data\.action === "publish" \|\| body\.data\.action === "rollback"/);
|
||||
assert.match(modelsRoute, /await requireHighRiskAdminMutation\(request, permission\)/);
|
||||
test("model provider changes and release mutations use the ordinary admin guard", () => {
|
||||
assert.match(modelsRoute, /await requireAdminMutation\(request, permission\)/);
|
||||
assert.doesNotMatch(modelsRoute, /requireHighRiskAdminMutation/);
|
||||
assert.match(modelDiscoveryRoute, /requireAdminMutation\(request,"models\.test"\)/);
|
||||
assert.doesNotMatch(modelDiscoveryRoute, /requireHighRiskAdminMutation/);
|
||||
assert.match(modelsRoute, /handleAdminModelMutation\(/);
|
||||
assert.match(modelMutationHandler, /admin_save_model_provider/);
|
||||
assert.match(modelMutationHandler, /admin_publish_model/);
|
||||
|
||||
Reference in New Issue
Block a user