mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
feat: add local client API key import export
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
+86
@@ -238,6 +238,92 @@ Success response:
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /admin/api-keys/export`
|
||||
|
||||
Exports Rust-local client API key metadata for backup or migration. The export never includes plaintext keys, key hashes, or the raw auth pepper. Because `cpr_` keys are stored only as peppered HMAC hashes, imports rotate keys instead of preserving the original plaintext.
|
||||
|
||||
Optional query:
|
||||
|
||||
```http
|
||||
GET /admin/api-keys/export?ids=key_01,key_02
|
||||
```
|
||||
|
||||
Success response:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "OK",
|
||||
"data": {
|
||||
"sourceFormat": "rustLocalClientApiKeys",
|
||||
"rotationRequired": true,
|
||||
"apiKeys": [
|
||||
{
|
||||
"id": "key_01",
|
||||
"name": "cursor",
|
||||
"label": "automation",
|
||||
"prefix": "cpr_xxxxxxxx",
|
||||
"enabled": true,
|
||||
"createdAt": "2026-06-11T12:00:00Z",
|
||||
"lastUsedAt": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"requestId": "req_01"
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /admin/api-keys/import`
|
||||
|
||||
Imports Rust-local client API key metadata exported by this service. Import creates new local `cpr_` keys using the target instance's pepper and returns the new plaintext values only in this response. Clients must replace the old keys with these rotated plaintext values.
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"sourceFormat": "rustLocalClientApiKeys",
|
||||
"rotationRequired": true,
|
||||
"apiKeys": [
|
||||
{
|
||||
"id": "key_01",
|
||||
"name": "cursor",
|
||||
"label": "automation",
|
||||
"prefix": "cpr_xxxxxxxx",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Success response:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "OK",
|
||||
"data": {
|
||||
"imported": 1,
|
||||
"skipped": 0,
|
||||
"rotated": true,
|
||||
"keys": [
|
||||
{
|
||||
"sourceId": "key_01",
|
||||
"sourcePrefix": "cpr_xxxxxxxx",
|
||||
"id": "key_02",
|
||||
"name": "cursor",
|
||||
"label": "automation",
|
||||
"prefix": "cpr_yyyyyyyy",
|
||||
"enabled": true,
|
||||
"createdAt": "2026-06-11T12:05:00Z",
|
||||
"lastUsedAt": null,
|
||||
"plaintext": "cpr_new_full_key_returned_once"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requestId": "req_01"
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /admin/accounts`
|
||||
|
||||
Returns stored Codex accounts with cursor pagination. Access tokens and refresh tokens are never decrypted or returned.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -27,7 +27,7 @@ This audit is the correction point against the TypeScript reference at `/home/zy
|
||||
| Manual account creation / `16938e6` + current correction | `POST /auth/accounts` accepts only `token` and/or `refreshToken`, runs `AccountImportService.importOne`, exchanges refresh-token-only imports, derives account identity/profile from JWT claims, updates an existing `chatgpt_account_id` + `chatgpt_user_id` entry, and never returns tokens. | `POST /admin/accounts` accepts only `token` and/or `refreshToken`, rejects missing/invalid/expired JWTs and tokens without `https://api.openai.com/auth.chatgpt_account_id`, derives email/accountId/userId/planType/expiresAt from JWT claims, ignores caller metadata/status/label, exchanges refresh-token-only imports with `AppState`'s OpenAI refresher, preserves or rotates refresh tokens, encrypts secrets, and synchronizes the runtime account pool. It does not yet cache quota from a verification probe. | Corrected scoped parity. | Keep. Finish quota fetch/health/login variants separately without adding proxy pools or non-OpenAI provider support. |
|
||||
| Local client API key status/delete / `8af350d` | `/auth/api-keys` manages third-party provider key pools such as OpenAI, Anthropic, Gemini, OpenRouter/custom, plus model bindings and capabilities. | `/admin/api-keys/*` manages local `cpr_` client auth keys used only for this Rust service's `/v1/*` authorization. | Rust local extension, not TS parity. | Keep only as local admin utility if accepted. Do not count as provider API-key parity and do not add non-OpenAI provider pools. |
|
||||
| Local client API key label/batch-delete / `960bbb2` | TS label/batch-delete applies to third-party provider key pool entries. | Rust label/batch-delete applies to local HMAC-hashed `cpr_` client keys. | Rust local extension, possible drift if strict parity is required. | Reclassify in docs as local admin utility. Remove only if strict original parity excludes local client-key management beyond create/list/status/delete. |
|
||||
| API key export/import variants | TS can export/import third-party provider keys for reimport. | Rust local `cpr_` key plaintext is shown only at creation and then HMAC-hashed; provider key pools are not implemented. | Intentional scope cut. | Do not port TS provider key import/export. Document that local client keys cannot be safely exported after creation. |
|
||||
| API key export/import variants | TS can export/import third-party provider keys for reimport. | Rust local `cpr_` key plaintext is shown only at creation and then HMAC-hashed; provider key pools are not implemented. Local `/admin/api-keys/export` exports only metadata, and `/admin/api-keys/import` rotates metadata into new target-local `cpr_` keys with plaintext returned once. | Rust local extension, intentional provider scope cut. | Keep local metadata export/import as client auth utility only. Do not port TS provider key import/export, provider/model binding, proxy pools, or non-OpenAI provider behavior. |
|
||||
| Model refresh and plan allowlist sync / `f75db60`, `4b19846` | TS has provider/key model catalog behavior mixed with account/provider management. | Rust probes Codex backend models via imported accounts and syncs plan allowlists into the account pool. | Partial parity, scoped Rust adaptation. | Keep; avoid third-party provider catalog behavior. Finish full Codex model metadata parity later if needed. |
|
||||
| OAuth/device/CLI account login/import variants | Original scope includes ChatGPT/Codex OAuth login, device login, CLI token import, manual validated token import, logout/status, quota, and health checks. | Rust now supports validated manual `token`/`refreshToken` import, deterministic admin CLI import from Codex `auth.json`, admin quota fetch, health check, per-account refresh, and reset-usage for imported accounts. Rust still lacks OAuth PKCE login-start/code-relay/callback, device login/poll, and logout/auth status equivalents. | Partial OpenAI/Codex parity. | Continue with login/status variants without adding proxy pools or non-OpenAI providers. |
|
||||
| Proxy/provider metadata and routes | TS project includes per-account proxy assignment, proxy health, and non-OpenAI provider routes. | Rust imports ignore proxy fields and has no outbound proxy/provider pools. | Intentional scope cut. | Keep omitted. Do not add proxy routing, proxy UI/API, Anthropic/Gemini/Ollama/custom provider support, or Electron/frontend flows. |
|
||||
@@ -157,6 +157,9 @@ Immediate priority after this audit:
|
||||
- [x] Write failing tests for local client API key label and batch-delete mutations.
|
||||
- [x] Implement local client API key label and batch-delete mutations without third-party provider key pools.
|
||||
- [x] Run: `cargo test --test admin_api_keys_route_test`
|
||||
- [x] Write failing tests for local client API key metadata export and rotation import.
|
||||
- [x] Implement local client API key metadata export/import without plaintext export, hash export, pepper export, or third-party provider key pools.
|
||||
- [x] Run: `cargo test --test admin_api_keys_route_test admin_api_keys_export_should_return_metadata_without_secret_material`; `cargo test --test admin_api_keys_route_test admin_api_keys_import_should_rotate_exported_metadata_and_return_new_plaintext_once`
|
||||
- [x] Write failing tests for account Cookie get/set/delete routes.
|
||||
- [x] Implement encrypted account Cookie get/set/delete routes.
|
||||
- [x] Run: `cargo test --test admin_accounts_route_test`
|
||||
|
||||
+7
-5
@@ -10,11 +10,11 @@ use crate::{
|
||||
admin::{
|
||||
account_quota, accounts, api_keys, batch_delete_accounts, batch_delete_api_keys,
|
||||
batch_update_account_status, create_account, create_api_key, delete_account,
|
||||
delete_account_cookies, delete_api_key, export_accounts, get_account_cookies,
|
||||
health_check_accounts, import_accounts, import_cli_auth, login, logs, refresh_account,
|
||||
refresh_models, reset_account_usage, set_account_cookies, settings,
|
||||
update_account_label, update_account_status, update_api_key_label,
|
||||
update_api_key_status, usage_stats, usage_stats_summary,
|
||||
delete_account_cookies, delete_api_key, export_accounts, export_api_keys,
|
||||
get_account_cookies, health_check_accounts, import_accounts, import_api_keys,
|
||||
import_cli_auth, login, logs, refresh_account, refresh_models, reset_account_usage,
|
||||
set_account_cookies, settings, update_account_label, update_account_status,
|
||||
update_api_key_label, update_api_key_status, usage_stats, usage_stats_summary,
|
||||
},
|
||||
health::health,
|
||||
middleware::attach_request_id,
|
||||
@@ -77,6 +77,8 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/admin/accounts/import", post(import_accounts))
|
||||
.route("/admin/accounts/import-cli", post(import_cli_auth))
|
||||
.route("/admin/api-keys", get(api_keys).post(create_api_key))
|
||||
.route("/admin/api-keys/export", get(export_api_keys))
|
||||
.route("/admin/api-keys/import", post(import_api_keys))
|
||||
.route("/admin/api-keys/batch-delete", post(batch_delete_api_keys))
|
||||
.route("/admin/api-keys/{key_id}", delete(delete_api_key))
|
||||
.route(
|
||||
|
||||
@@ -45,25 +45,38 @@ impl ClientApiKeyRepository {
|
||||
&self,
|
||||
name: &str,
|
||||
generated: &GeneratedClientApiKey,
|
||||
) -> ClientApiKeyRepositoryResult<StoredClientApiKey> {
|
||||
self.insert_generated_with_metadata(name, None, true, generated)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn insert_generated_with_metadata(
|
||||
&self,
|
||||
name: &str,
|
||||
label: Option<&str>,
|
||||
enabled: bool,
|
||||
generated: &GeneratedClientApiKey,
|
||||
) -> ClientApiKeyRepositoryResult<StoredClientApiKey> {
|
||||
let id = format!("key_{}", Uuid::new_v4().simple());
|
||||
let now = Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"insert into client_api_keys (id, name, prefix, key_hash, enabled, created_at, last_used_at) values (?, ?, ?, ?, 1, ?, null)",
|
||||
"insert into client_api_keys (id, name, label, prefix, key_hash, enabled, created_at, last_used_at) values (?, ?, ?, ?, ?, ?, ?, null)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(name)
|
||||
.bind(label)
|
||||
.bind(&generated.prefix)
|
||||
.bind(&generated.key_hash)
|
||||
.bind(if enabled { 1_i64 } else { 0_i64 })
|
||||
.bind(&now)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(StoredClientApiKey {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
label: None,
|
||||
label: label.map(ToString::to_string),
|
||||
prefix: generated.prefix.clone(),
|
||||
enabled: true,
|
||||
enabled,
|
||||
created_at: now,
|
||||
last_used_at: None,
|
||||
})
|
||||
@@ -123,6 +136,15 @@ impl ClientApiKeyRepository {
|
||||
Ok(row.map(|row| key_from_row(&row)))
|
||||
}
|
||||
|
||||
pub async fn list_all(&self) -> ClientApiKeyRepositoryResult<Vec<StoredClientApiKey>> {
|
||||
let rows = sqlx::query(
|
||||
"select id, name, label, prefix, enabled, created_at, last_used_at from client_api_keys order by created_at desc, id desc",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|row| key_from_row(&row)).collect())
|
||||
}
|
||||
|
||||
pub async fn verify_and_touch(
|
||||
&self,
|
||||
plaintext: &str,
|
||||
|
||||
@@ -166,6 +166,12 @@ pub struct ApiKeysQuery {
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApiKeyExportQuery {
|
||||
pub ids: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageStatsQuery {
|
||||
@@ -476,6 +482,59 @@ pub struct DeleteClientApiKeyData {
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ClientApiKeyImportEntry {
|
||||
pub source_id: Option<String>,
|
||||
pub source_prefix: Option<String>,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClientApiKeyExportData {
|
||||
pub source_format: &'static str,
|
||||
pub rotation_required: bool,
|
||||
pub api_keys: Vec<ClientApiKeyExportEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClientApiKeyExportEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub prefix: String,
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
pub last_used_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClientApiKeyImportData {
|
||||
pub imported: u32,
|
||||
pub skipped: u32,
|
||||
pub rotated: bool,
|
||||
pub keys: Vec<ImportedClientApiKeyData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportedClientApiKeyData {
|
||||
pub source_id: Option<String>,
|
||||
pub source_prefix: Option<String>,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub prefix: String,
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
pub last_used_at: Option<String>,
|
||||
pub plaintext: String,
|
||||
}
|
||||
|
||||
impl From<StoredClientApiKey> for ClientApiKeyData {
|
||||
fn from(key: StoredClientApiKey) -> Self {
|
||||
Self {
|
||||
@@ -490,6 +549,20 @@ impl From<StoredClientApiKey> for ClientApiKeyData {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StoredClientApiKey> for ClientApiKeyExportEntry {
|
||||
fn from(key: StoredClientApiKey) -> Self {
|
||||
Self {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
label: key.label,
|
||||
prefix: key.prefix,
|
||||
enabled: key.enabled,
|
||||
created_at: key.created_at,
|
||||
last_used_at: key.last_used_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CreatedClientApiKeyData {
|
||||
fn new(key: StoredClientApiKey, plaintext: String) -> Self {
|
||||
Self {
|
||||
@@ -505,6 +578,28 @@ impl CreatedClientApiKeyData {
|
||||
}
|
||||
}
|
||||
|
||||
impl ImportedClientApiKeyData {
|
||||
fn new(
|
||||
key: StoredClientApiKey,
|
||||
plaintext: String,
|
||||
source_id: Option<String>,
|
||||
source_prefix: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
source_id,
|
||||
source_prefix,
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
label: key.label,
|
||||
prefix: key.prefix,
|
||||
enabled: key.enabled,
|
||||
created_at: key.created_at,
|
||||
last_used_at: key.last_used_at,
|
||||
plaintext,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminAccountData {
|
||||
@@ -2468,6 +2563,192 @@ pub async fn api_keys(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn export_api_keys(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<ApiKeyExportQuery>,
|
||||
) -> Response {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
let Some(pool) = state.db() else {
|
||||
return AdminResponse::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AdminEnvelope::new(50001, "Database is not initialized", (), request_id),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
if let Err(response) = require_admin_session(pool, &headers, &request_id).await {
|
||||
return response;
|
||||
}
|
||||
let Some(repo) = state.client_api_key_repository() else {
|
||||
return AdminResponse::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AdminEnvelope::new(
|
||||
50001,
|
||||
"API key repository is not initialized",
|
||||
(),
|
||||
request_id,
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
let ids = account_export_ids(query.ids.as_deref());
|
||||
let keys = if ids.is_empty() {
|
||||
match repo.list_all().await {
|
||||
Ok(keys) => keys,
|
||||
Err(_) => {
|
||||
return AdminResponse::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AdminEnvelope::new(50001, "Failed to export API keys", (), request_id),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let mut keys = Vec::with_capacity(ids.len());
|
||||
for id in ids {
|
||||
match repo.get(&id).await {
|
||||
Ok(Some(key)) => keys.push(key),
|
||||
Ok(None) => {}
|
||||
Err(_) => {
|
||||
return AdminResponse::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AdminEnvelope::new(50001, "Failed to export API keys", (), request_id),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
keys
|
||||
};
|
||||
|
||||
// 安全边界:本地 cpr_ key 只导出可展示元数据,绝不导出 plaintext、key_hash 或 pepper。
|
||||
let data = ClientApiKeyExportData {
|
||||
source_format: "rustLocalClientApiKeys",
|
||||
rotation_required: true,
|
||||
api_keys: keys
|
||||
.into_iter()
|
||||
.map(ClientApiKeyExportEntry::from)
|
||||
.collect(),
|
||||
};
|
||||
AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data, request_id)).into_response()
|
||||
}
|
||||
|
||||
pub async fn import_api_keys(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<Value>,
|
||||
) -> Response {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
let Some(pool) = state.db() else {
|
||||
return AdminResponse::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AdminEnvelope::new(50001, "Database is not initialized", (), request_id),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
if let Err(response) = require_admin_session(pool, &headers, &request_id).await {
|
||||
return response;
|
||||
}
|
||||
let Some(repo) = state.client_api_key_repository() else {
|
||||
return AdminResponse::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AdminEnvelope::new(
|
||||
50001,
|
||||
"API key repository is not initialized",
|
||||
(),
|
||||
request_id,
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
let Some(hasher) = state.api_key_hasher() else {
|
||||
return AdminResponse::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AdminEnvelope::new(50001, "API key hasher is not initialized", (), request_id),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
let entries = parse_client_api_key_import_payload(&payload);
|
||||
if entries.is_empty() {
|
||||
return AdminResponse::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
AdminEnvelope::new(40001, "No importable API keys found", (), request_id),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let mut imported = 0u32;
|
||||
let mut skipped = 0u32;
|
||||
let mut keys = Vec::with_capacity(entries.len());
|
||||
for entry in entries {
|
||||
let name = entry.name.trim();
|
||||
if name.is_empty() {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if entry
|
||||
.label
|
||||
.as_ref()
|
||||
.is_some_and(|label| label.chars().count() > 64)
|
||||
{
|
||||
return AdminResponse::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
AdminEnvelope::new(
|
||||
40001,
|
||||
"API key label must be 64 characters or fewer",
|
||||
(),
|
||||
request_id,
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let generated = hasher.generate_client_api_key(name);
|
||||
let plaintext = generated.plaintext.clone();
|
||||
let source_id = entry.source_id;
|
||||
let source_prefix = entry.source_prefix;
|
||||
match repo
|
||||
.insert_generated_with_metadata(name, entry.label.as_deref(), entry.enabled, &generated)
|
||||
.await
|
||||
{
|
||||
Ok(key) => {
|
||||
imported += 1;
|
||||
keys.push(ImportedClientApiKeyData::new(
|
||||
key,
|
||||
plaintext,
|
||||
source_id,
|
||||
source_prefix,
|
||||
));
|
||||
}
|
||||
Err(_) => {
|
||||
return AdminResponse::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AdminEnvelope::new(50001, "Failed to import API key", (), request_id),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(
|
||||
ClientApiKeyImportData {
|
||||
imported,
|
||||
skipped,
|
||||
rotated: true,
|
||||
keys,
|
||||
},
|
||||
request_id,
|
||||
),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn create_api_key(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
@@ -3728,6 +4009,59 @@ fn account_export_ids(value: Option<&str>) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_client_api_key_import_payload(payload: &Value) -> Vec<ClientApiKeyImportEntry> {
|
||||
let payload = payload
|
||||
.get("data")
|
||||
.filter(|data| data.get("apiKeys").is_some() || data.get("keys").is_some())
|
||||
.unwrap_or(payload);
|
||||
|
||||
if let Some(keys) = payload.get("apiKeys").and_then(Value::as_array) {
|
||||
return keys
|
||||
.iter()
|
||||
.filter_map(client_api_key_import_entry_from_value)
|
||||
.collect();
|
||||
}
|
||||
if let Some(keys) = payload.get("keys").and_then(Value::as_array) {
|
||||
return keys
|
||||
.iter()
|
||||
.filter_map(client_api_key_import_entry_from_value)
|
||||
.collect();
|
||||
}
|
||||
if let Some(keys) = payload.as_array() {
|
||||
return keys
|
||||
.iter()
|
||||
.filter_map(client_api_key_import_entry_from_value)
|
||||
.collect();
|
||||
}
|
||||
|
||||
client_api_key_import_entry_from_value(payload)
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn client_api_key_import_entry_from_value(value: &Value) -> Option<ClientApiKeyImportEntry> {
|
||||
value.as_object()?;
|
||||
let name = first_string(value, &[&["name"]])?;
|
||||
// 安全边界:导入即使收到 plaintext/keyHash,也只按元数据轮换生成新的本地 cpr_ key。
|
||||
Some(ClientApiKeyImportEntry {
|
||||
source_id: first_string(value, &[&["id"], &["sourceId"]]),
|
||||
source_prefix: first_string(value, &[&["prefix"], &["sourcePrefix"]]),
|
||||
name,
|
||||
label: first_string(value, &[&["label"]]),
|
||||
enabled: client_api_key_import_enabled(value),
|
||||
})
|
||||
}
|
||||
|
||||
fn client_api_key_import_enabled(value: &Value) -> bool {
|
||||
if let Some(enabled) = value.get("enabled").and_then(Value::as_bool) {
|
||||
return enabled;
|
||||
}
|
||||
!first_string(value, &[&["status"]])
|
||||
.unwrap_or_else(|| "active".to_string())
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("disabled")
|
||||
}
|
||||
|
||||
fn parse_account_export_format(value: Option<&str>) -> Result<AccountExportFormat, &'static str> {
|
||||
match value.unwrap_or("native") {
|
||||
"" | "native" | "full" => Ok(AccountExportFormat::Native),
|
||||
|
||||
@@ -477,6 +477,158 @@ async fn create_admin_api_key(
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_api_keys_export_should_return_metadata_without_secret_material() {
|
||||
let (app, _dir) = admin_api_keys_test_app("admin-api-key-export.sqlite").await;
|
||||
let (key_id, plaintext) = create_admin_api_key(&app, "session_1", "export-key").await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(format!("/admin/api-keys/export?ids={key_id}"))
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", "req_api_key_export")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response_json(response).await;
|
||||
assert_eq!(body["requestId"], "req_api_key_export");
|
||||
assert_eq!(body["data"]["sourceFormat"], "rustLocalClientApiKeys");
|
||||
assert_eq!(body["data"]["rotationRequired"], true);
|
||||
assert_eq!(body["data"]["apiKeys"][0]["id"], key_id);
|
||||
assert_eq!(body["data"]["apiKeys"][0]["name"], "export-key");
|
||||
assert!(body["data"]["apiKeys"][0]["prefix"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("cpr_"));
|
||||
assert!(body["data"]["apiKeys"][0].get("plaintext").is_none());
|
||||
assert!(body["data"]["apiKeys"][0].get("keyHash").is_none());
|
||||
assert!(body["data"].get("pepper").is_none());
|
||||
assert!(!body.to_string().contains(&plaintext));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_api_keys_import_should_rotate_exported_metadata_and_return_new_plaintext_once() {
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let source_db = source_dir.path().join("admin-api-key-export-source.sqlite");
|
||||
let source_url = format!("sqlite://{}", source_db.display());
|
||||
let source_pool = connect_sqlite(&source_url).await.unwrap();
|
||||
seed_admin_session(&source_pool, "session_1").await;
|
||||
let source_app = build_router(AppState::with_pool_secret_and_api_key_hasher(
|
||||
test_config(source_url),
|
||||
source_pool,
|
||||
SecretBox::new([51u8; 32]),
|
||||
ApiKeyHasher::new([52u8; 32]),
|
||||
));
|
||||
let (source_key_id, source_plaintext) =
|
||||
create_admin_api_key(&source_app, "session_1", "rotated-key").await;
|
||||
|
||||
let export_response = source_app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/admin/api-keys/export")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(export_response.status(), StatusCode::OK);
|
||||
let export_body = response_json(export_response).await;
|
||||
|
||||
let target_dir = tempfile::tempdir().unwrap();
|
||||
let target_db = target_dir.path().join("admin-api-key-import-target.sqlite");
|
||||
let target_url = format!("sqlite://{}", target_db.display());
|
||||
let target_pool = connect_sqlite(&target_url).await.unwrap();
|
||||
seed_admin_session(&target_pool, "session_1").await;
|
||||
let target_app = build_router(AppState::with_pool_secret_and_api_key_hasher(
|
||||
test_config(target_url),
|
||||
target_pool,
|
||||
SecretBox::new([61u8; 32]),
|
||||
ApiKeyHasher::new([62u8; 32]),
|
||||
));
|
||||
|
||||
let import_response = target_app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/admin/api-keys/import")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", "req_api_key_import")
|
||||
.body(Body::from(export_body["data"].to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(import_response.status(), StatusCode::OK);
|
||||
let import_body = response_json(import_response).await;
|
||||
assert_eq!(import_body["requestId"], "req_api_key_import");
|
||||
assert_eq!(import_body["data"]["imported"], 1);
|
||||
assert_eq!(import_body["data"]["skipped"], 0);
|
||||
assert_eq!(import_body["data"]["rotated"], true);
|
||||
assert_eq!(import_body["data"]["keys"][0]["sourceId"], source_key_id);
|
||||
assert_eq!(import_body["data"]["keys"][0]["name"], "rotated-key");
|
||||
assert!(import_body["data"]["keys"][0].get("keyHash").is_none());
|
||||
let rotated_plaintext = import_body["data"]["keys"][0]["plaintext"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(rotated_plaintext.starts_with("cpr_"));
|
||||
assert_ne!(rotated_plaintext, source_plaintext);
|
||||
|
||||
let list_response = target_app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/admin/api-keys?limit=10")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(list_response.status(), StatusCode::OK);
|
||||
let list_body = response_json(list_response).await;
|
||||
assert!(list_body["data"][0].get("plaintext").is_none());
|
||||
|
||||
let old_rejected = target_app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/v1/models")
|
||||
.header("authorization", format!("Bearer {source_plaintext}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(old_rejected.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let new_accepted = target_app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/v1/models")
|
||||
.header("authorization", format!("Bearer {rotated_plaintext}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(new_accepted.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
async fn seed_admin_session(pool: &sqlx::SqlitePool, session_id: &str) {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
|
||||
Reference in New Issue
Block a user