From a32d56677bd6551f0a2d1ab73a4abe7e60e0f173 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sat, 8 Aug 2026 12:20:26 +0800 Subject: [PATCH] Allow emergency model provider disable --- .../src/lib/admin/model-mutation-handler.ts | 6 +- ...10000_model_provider_emergency_disable.sql | 83 +++++++++++++++++++ .../model-configuration-security.test.ts | 50 +++++++++-- 3 files changed, 130 insertions(+), 9 deletions(-) create mode 100644 frontend/supabase/migrations/20260808010000_model_provider_emergency_disable.sql diff --git a/frontend/src/lib/admin/model-mutation-handler.ts b/frontend/src/lib/admin/model-mutation-handler.ts index 9977fb94..f728b14f 100644 --- a/frontend/src/lib/admin/model-mutation-handler.ts +++ b/frontend/src/lib/admin/model-mutation-handler.ts @@ -8,11 +8,11 @@ type Dependencies={queryRows(sql:string,values?:readonly unknown[]):PromiseResponse.json(body,{status}); const immutable=(e:unknown)=>e instanceof Error&&e.message.includes("model_provider_runtime_immutable"); async function versionProvider(d:Dependencies,where:string,values:readonly unknown[]){const rows=await d.queryRows(`select p.id,p.code,p.provider_type,p.base_url,p.encrypted_api_key,p.enabled,v.id version_id,v.enabled version_enabled from public.model_config_versions v join public.model_providers p on p.id=v.provider_id where ${where}`,values);return rows[0] as ProviderRow|undefined;} -async function probeAndRecord(p:ProviderRow,actor:string,rid:string,d:Dependencies){if(!p.enabled||!p.version_enabled||!p.encrypted_api_key)return{recorded:false,status:0,success:false};let key="";try{key=decryptModelProviderApiKey({encryptedApiKey:p.encrypted_api_key},d.environment);}catch{return{recorded:false,status:0,success:false};}if(p.provider_type==="openai-compatible")await d.assertAllowedUrl(p.base_url??"");let status=0;try{status=await d.probeAllowed(modelProviderModelsUrl({providerType:p.provider_type,baseUrl:p.base_url}),modelProviderRequestHeaders(p.provider_type,key));}catch{}await d.queryRows("select public.admin_record_model_connection_test($1,$2,$3,$4) id",[actor,p.version_id,status,rid]);return{recorded:true,status,success:modelConnectionTestSucceeded(status)};} +async function probeAndRecord(p:ProviderRow,actor:string,rid:string,d:Dependencies,allowDisabled=false){if((!allowDisabled&&(!p.enabled||!p.version_enabled))||!p.encrypted_api_key)return{recorded:false,status:0,success:false};let key="";try{key=decryptModelProviderApiKey({encryptedApiKey:p.encrypted_api_key},d.environment);}catch{return{recorded:false,status:0,success:false};}if(p.provider_type==="openai-compatible")await d.assertAllowedUrl(p.base_url??"");let status=0;try{status=await d.probeAllowed(modelProviderModelsUrl({providerType:p.provider_type,baseUrl:p.base_url}),modelProviderRequestHeaders(p.provider_type,key));}catch{}await d.queryRows("select public.admin_record_model_connection_test($1,$2,$3,$4) id",[actor,p.version_id,status,rid]);return{recorded:true,status,success:modelConnectionTestSucceeded(status)};} export async function handleAdminModelMutation(a:AdminModelMutation,c:Readonly<{actorUserId:string;requestId:string}>,d:Dependencies){ - if(a.action==="saveProvider"){if(a.providerType==="openai-compatible")await d.assertAllowedUrl(a.baseUrl??"");const key=a.apiKey?.trim();let encrypted:string|null=null;if(key)encrypted=encryptModelProviderApiKey(key,d.environment);else if(!a.id&&a.enabled)return json({error:"模型供应商密钥未配置"},409);try{const rows=await d.queryRows("select public.admin_save_model_provider($1,$2,$3,$4,$5,$6,$7,$8,$9) id",[c.actorUserId,a.id??null,a.name,a.providerType,a.providerType==="openai-compatible"?a.baseUrl??null:null,encrypted,a.enabled,a.reason,c.requestId]);return json({data:{id:rows[0]!.id,requestId:c.requestId}});}catch(e){if(immutable(e))return json({error:"已发布或已退役版本使用的供应商连接配置不可修改,请新建供应商和模型版本后重新测试并发布",code:"model_provider_runtime_immutable"},409);if(e instanceof Error&&e.message.includes("model_provider_code_immutable"))return json({error:"供应商代码不可修改",code:"model_provider_code_immutable"},409);throw e;}} + if(a.action==="saveProvider"){if(a.providerType==="openai-compatible")await d.assertAllowedUrl(a.baseUrl??"");const key=a.apiKey?.trim();let encrypted:string|null=null;if(key)encrypted=encryptModelProviderApiKey(key,d.environment);else if(!a.id&&a.enabled)return json({error:"模型供应商密钥未配置"},409);try{const rows=await d.queryRows("select public.admin_save_model_provider($1,$2,$3,$4,$5,$6,$7,$8,$9) id",[c.actorUserId,a.id??null,a.name,a.providerType,a.providerType==="openai-compatible"?a.baseUrl??null:null,encrypted,a.enabled,a.reason,c.requestId]);d.invalidateCatalog();return json({data:{id:rows[0]!.id,requestId:c.requestId}});}catch(e){if(immutable(e))return json({error:"已发布或已退役版本使用的供应商连接配置不可修改,请新建供应商和模型版本后重新测试并发布",code:"model_provider_runtime_immutable"},409);if(e instanceof Error&&e.message.includes("model_provider_code_immutable"))return json({error:"供应商代码不可修改",code:"model_provider_code_immutable"},409);if(e instanceof Error&&e.message.includes("model_provider_reenable_test_required"))return json({error:"重新启用前,请先在模型版本列表中测试此供应商的已发布或已退役版本",code:"model_provider_reenable_test_required"},409);throw e;}} if(a.action==="saveDraft"){const rows=await d.queryRows("select public.admin_save_model_draft($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16::jsonb,$17,$18) id",[c.actorUserId,a.modelId,a.versionId??null,a.providerId,a.label,a.description,a.providerModel,a.modelTier,a.creditCost,a.contextWindow??null,a.inputCostMicrousdPerMillion,a.outputCostMicrousdPerMillion,a.enabled,a.isDefault,a.fallbackModelId??null,JSON.stringify(a.settings),a.reason,c.requestId]);return json({data:{id:rows[0]!.id,requestId:c.requestId}});} - if(a.action==="test"){const p=await versionProvider(d,"v.id=$1 and v.status in ('draft','published','retired')",[a.versionId]);if(!p)return json({error:"模型版本不存在"},404);const r=await probeAndRecord(p,c.actorUserId,c.requestId,d);if(!r.recorded)return json({error:"模型版本或供应商未启用,或数据库密钥不可用"},409);return json({data:{id:p.version_id,reachable:r.success,status:r.status,secretConfigured:true,requestId:c.requestId}},r.success?200:409);} + if(a.action==="test"){const p=await versionProvider(d,"v.id=$1 and v.status in ('draft','published','retired')",[a.versionId]);if(!p)return json({error:"模型版本不存在"},404);const r=await probeAndRecord(p,c.actorUserId,c.requestId,d,true);if(!r.recorded)return json({error:"数据库密钥不可用"},409);return json({data:{id:p.version_id,reachable:r.success,status:r.status,secretConfigured:true,requestId:c.requestId}},r.success?200:409);} if(a.action==="publish"){const rows=await d.queryRows("select public.admin_publish_model($1,$2,$3,$4) id",[c.actorUserId,a.versionId,a.reason,c.requestId]);d.invalidateCatalog();return json({data:{id:rows[0]!.id,requestId:c.requestId}});} const p=await versionProvider(d,"v.config_id=$1 and v.version=$2 and v.status='retired'",[a.configId,a.targetVersion]);if(!p)return json({error:"回滚版本不存在"},404);const r=await probeAndRecord(p,c.actorUserId,c.requestId,d);if(!r.recorded)return json({error:"回滚版本或供应商未启用,或数据库密钥不可用"},409);if(!r.success)return json({error:"回滚版本运行时连接测试失败",data:{status:r.status,requestId:c.requestId}},409);const rows=await d.queryRows("select public.admin_rollback_model($1,$2,$3,$4,$5) id",[c.actorUserId,a.configId,a.targetVersion,a.reason,c.requestId]);d.invalidateCatalog();return json({data:{id:rows[0]!.id,requestId:c.requestId}}); } diff --git a/frontend/supabase/migrations/20260808010000_model_provider_emergency_disable.sql b/frontend/supabase/migrations/20260808010000_model_provider_emergency_disable.sql new file mode 100644 index 00000000..11784bd2 --- /dev/null +++ b/frontend/supabase/migrations/20260808010000_model_provider_emergency_disable.sql @@ -0,0 +1,83 @@ +begin; + +create or replace function public.model_version_config_hash(p_version_id uuid) +returns text language sql stable security definer set search_path='' +as $$ + select encode(public.digest(convert_to(jsonb_build_object( + 'versionId',v.id, + 'configId',v.config_id, + 'version',v.version, + 'providerId',v.provider_id, + 'label',v.label, + 'description',v.description, + 'providerModel',v.provider_model, + 'modelTier',v.model_tier, + 'creditCost',v.credit_cost, + 'contextWindow',v.context_window, + 'inputCost',v.input_cost_microusd_per_million, + 'outputCost',v.output_cost_microusd_per_million, + 'enabled',v.enabled, + 'isDefault',v.is_default, + 'fallbackModelId',v.fallback_model_id, + 'settings',v.settings, + 'providerCode',p.code, + 'providerType',p.provider_type, + 'providerBaseUrl',p.base_url, + 'providerCredentialHash',case when p.encrypted_api_key is null then null else encode(public.digest(convert_to(p.encrypted_api_key,'utf8'),'sha256'),'hex') end + )::text,'utf8'),'sha256'),'hex') + from public.model_config_versions v + join public.model_providers p on p.id=v.provider_id + where v.id=p_version_id +$$; + +create or replace function public.prevent_published_model_provider_runtime_mutation() +returns trigger language plpgsql set search_path='' +as $$ +declare v_has_historical_version boolean; +begin + select exists( + select 1 from public.model_config_versions + where provider_id=old.id and status in ('published','retired') + ) into v_has_historical_version; + + if v_has_historical_version + and (new.code,new.provider_type,new.base_url) + is distinct from + (old.code,old.provider_type,old.base_url) + then + raise exception 'model_provider_runtime_immutable' using + errcode='23514', + hint='Create a new provider and model config version, then test and publish it.'; + end if; + + if v_has_historical_version and old.enabled and not new.enabled then + new.updated_at:=clock_timestamp(); + end if; + + if v_has_historical_version and not old.enabled and new.enabled + and ( + new.encrypted_api_key is distinct from old.encrypted_api_key + or not exists( + select 1 + from public.model_config_versions v + join public.model_connection_test_evidence e + on e.version_id=v.id + and e.provider_id=old.id + and e.config_hash=public.model_version_config_hash(v.id) + and e.http_status between 200 and 299 + and e.expires_at>clock_timestamp() + and e.tested_at>old.updated_at + where v.provider_id=old.id + and v.status in ('published','retired') + ) + ) + then + raise exception 'model_provider_reenable_test_required' using + errcode='23514', + hint='Test a published or retired model version with the saved provider credentials after disabling the provider and before re-enabling it.'; + end if; + + return new; +end $$; + +commit; diff --git a/frontend/tests/model-configuration-security.test.ts b/frontend/tests/model-configuration-security.test.ts index ec8604b2..0b2c769f 100644 --- a/frontend/tests/model-configuration-security.test.ts +++ b/frontend/tests/model-configuration-security.test.ts @@ -12,6 +12,7 @@ import { runMigrations } from "../scripts/db-migrate.mjs"; import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; const migrationPath = fileURLToPath(new URL("../supabase/migrations/20260807160000_model_provider_encrypted_credentials.sql", import.meta.url)); +const emergencyDisableMigrationPath = fileURLToPath(new URL("../supabase/migrations/20260808010000_model_provider_emergency_disable.sql", import.meta.url)); const handlerPath = fileURLToPath(new URL("../src/lib/admin/model-mutation-handler.ts", import.meta.url)); const routePath = fileURLToPath(new URL("../src/app/api/admin/models/route.ts", import.meta.url)); const catalogPath = fileURLToPath(new URL("../src/lib/model-catalog.ts", import.meta.url)); @@ -30,7 +31,7 @@ async function migrateModelConfigurationFixture(connectionString: string) { const target = join(temporaryRoot, relative.replace("/", "-")); mkdirSync(target, { recursive: true }); for (const filename of readdirSync(source)) { - if (!filename.endsWith(".sql") || filename > "20260807160000_model_provider_encrypted_credentials.sql") continue; + if (!filename.endsWith(".sql") || filename > "20260808010000_model_provider_emergency_disable.sql") continue; if ([ "20260806030000_settle_order_usage_authorization.sql", "20260806050000_operations_feature_flags.sql", @@ -133,6 +134,7 @@ test("mutation handler preserves an omitted key on edit and requires a key for e assert.equal(createQueries, 0); const calls: Array<{ sql: string; values?: readonly unknown[] }> = []; + let invalidations = 0; const edited = await handleAdminModelMutation({ action: "saveProvider", id: "40000000-0000-4000-8000-000000000001", @@ -148,10 +150,11 @@ test("mutation handler preserves an omitted key on edit and requires a key for e }, assertAllowedUrl: async () => undefined, probeAllowed: async () => 200, - invalidateCatalog: () => undefined, + invalidateCatalog: () => { invalidations += 1; }, environment, }); assert.equal(edited.status, 200); + assert.equal(invalidations, 1); assert.match(calls[0]!.sql, /admin_save_model_provider\(\$1,\$2,\$3,\$4,\$5,\$6,\$7,\$8,\$9\)/); assert.equal(calls[0]!.values?.[5], null); }); @@ -173,9 +176,9 @@ test("mutation handler decrypts only the database ciphertext and records failed provider_type: "anthropic", base_url: null, encrypted_api_key: encrypted, - enabled: true, + enabled: false, version_id: "30000000-0000-4000-8000-000000000001", - version_enabled: true, + version_enabled: false, }]; } return [{ id: "50000000-0000-4000-8000-000000000001" }]; @@ -257,10 +260,30 @@ test("database drops secret refs, generates immutable codes, and invalidates evi assert.equal(sql(`select public.model_connection_test_is_fresh('${versionOne}',null)`), "f"); sql(`select public.admin_record_model_connection_test('${actorId}','${versionOne}',200,'publish-v1')`); sql(`select public.admin_publish_model('${actorId}','${versionOne}','publish v1','publish-v1')`); - expectSqlError( + for (const mutation of [ `update public.model_providers set code='mutated' where id='${provider}'`, - /model_provider_runtime_immutable/, + `update public.model_providers set provider_type='anthropic' where id='${provider}'`, + `update public.model_providers set base_url='https://changed.example/v1' where id='${provider}'`, + ]) { + expectSqlError(mutation, /model_provider_runtime_immutable/); + } + + sql(`select public.admin_save_model_provider('${actorId}','${provider}','OpenAI Renamed','openai',null,null,false,'emergency disable','provider-disable')`); + assert.equal(sql(`select enabled::text from public.model_providers where id='${provider}'`), "f"); + assert.equal(sql(`select public.model_connection_test_is_fresh('${versionOne}',null)`), "t"); + assert.equal(sql(`select exists(select 1 from public.model_connection_test_evidence e join public.model_providers p on p.id=e.provider_id where e.provider_id='${provider}' and e.version_id='${versionOne}' and e.request_id='publish-v1' and e.tested_at<=p.updated_at)::text`), "true"); + expectSqlError( + `select public.admin_save_model_provider('${actorId}','${provider}','OpenAI Renamed','openai',null,'untested-ciphertext',true,'unsafe key and enable','provider-reenable-new-key')`, + /model_provider_reenable_test_required/, ); + expectSqlError( + `select public.admin_save_model_provider('${actorId}','${provider}','OpenAI Renamed','openai',null,null,true,'unsafe re-enable with pre-disable evidence','provider-reenable-pre-disable-test')`, + /model_provider_reenable_test_required/, + ); + sql(`select public.admin_record_model_connection_test('${actorId}','${versionOne}',200,'provider-reenable-test')`); + assert.equal(sql(`select exists(select 1 from public.model_connection_test_evidence e join public.model_providers p on p.id=e.provider_id where e.provider_id='${provider}' and e.version_id='${versionOne}' and e.request_id='provider-reenable-test' and e.tested_at>p.updated_at)::text`), "true"); + sql(`select public.admin_save_model_provider('${actorId}','${provider}','OpenAI Renamed','openai',null,null,true,'tested re-enable','provider-reenable-tested')`); + assert.equal(sql(`select enabled::text from public.model_providers where id='${provider}'`), "true"); const providerTwo = conflictingProvider; const versionTwo = sql(`select public.admin_save_model_draft('${actorId}','default-model',null,'${providerTwo}','Default v2','', 'model-v2','standard',1,64000,0,0,true,true,null,'{}','create v2','draft-v2')`); @@ -306,3 +329,18 @@ test("forward migration removes secret-ref dependencies and hashes credential st assert.match(migration, /models\.write/); assert.match(migration, /audit\.admin_audit_logs/); }); + + +test("emergency-disable migration keeps runtime identity immutable and gates re-enable on fresh evidence", () => { + const migration = readFileSync(emergencyDisableMigrationPath, "utf8"); + assert.match(migration, /\(new\.code,new\.provider_type,new\.base_url\)/); + assert.doesNotMatch(migration, /\(new\.code,new\.provider_type,new\.base_url,new\.enabled\)/); + assert.match(migration, /old\.enabled and not new\.enabled/); + assert.match(migration, /new\.updated_at:=clock_timestamp\(\)/); + assert.match(migration, /not old\.enabled and new\.enabled/); + assert.match(migration, /e\.tested_at>old\.updated_at/); + assert.match(migration, /e\.http_status between 200 and 299/); + assert.match(migration, /e\.config_hash=public\.model_version_config_hash\(v\.id\)/); + assert.match(migration, /new\.encrypted_api_key is distinct from old\.encrypted_api_key/); + assert.match(migration, /model_provider_reenable_test_required/); +});