mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
feat: add admin api key batch operations
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
alter table client_api_keys add column label text;
|
||||
+10
-5
@@ -8,11 +8,11 @@ use tower_http::trace::TraceLayer;
|
||||
use crate::{
|
||||
http::{
|
||||
admin::{
|
||||
accounts, api_keys, batch_delete_accounts, batch_update_account_status, create_api_key,
|
||||
delete_account, delete_account_cookies, delete_api_key, get_account_cookies,
|
||||
import_accounts, login, logs, refresh_models, set_account_cookies, settings,
|
||||
update_account_label, update_account_status, update_api_key_status, usage_stats,
|
||||
usage_stats_summary,
|
||||
accounts, api_keys, batch_delete_accounts, batch_delete_api_keys,
|
||||
batch_update_account_status, create_api_key, delete_account, delete_account_cookies,
|
||||
delete_api_key, get_account_cookies, import_accounts, login, logs, refresh_models,
|
||||
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,
|
||||
@@ -63,7 +63,12 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)
|
||||
.route("/admin/accounts/import", post(import_accounts))
|
||||
.route("/admin/api-keys", get(api_keys).post(create_api_key))
|
||||
.route("/admin/api-keys/batch-delete", post(batch_delete_api_keys))
|
||||
.route("/admin/api-keys/{key_id}", delete(delete_api_key))
|
||||
.route(
|
||||
"/admin/api-keys/{key_id}/label",
|
||||
patch(update_api_key_label),
|
||||
)
|
||||
.route(
|
||||
"/admin/api-keys/{key_id}/status",
|
||||
patch(update_api_key_status),
|
||||
|
||||
@@ -24,6 +24,7 @@ pub type ClientApiKeyRepositoryResult<T> = Result<T, ClientApiKeyRepositoryError
|
||||
pub struct StoredClientApiKey {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub prefix: String,
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
@@ -60,6 +61,7 @@ impl ClientApiKeyRepository {
|
||||
Ok(StoredClientApiKey {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
label: None,
|
||||
prefix: generated.prefix.clone(),
|
||||
enabled: true,
|
||||
created_at: now,
|
||||
@@ -77,7 +79,7 @@ impl ClientApiKeyRepository {
|
||||
let (created_at, id) =
|
||||
decode_cursor(&cursor).ok_or(ClientApiKeyRepositoryError::InvalidCursor)?;
|
||||
sqlx::query(
|
||||
"select id, name, prefix, enabled, created_at, last_used_at from client_api_keys where created_at < ? or (created_at = ? and id < ?) order by created_at desc, id desc limit ?",
|
||||
"select id, name, label, prefix, enabled, created_at, last_used_at from client_api_keys where created_at < ? or (created_at = ? and id < ?) order by created_at desc, id desc limit ?",
|
||||
)
|
||||
.bind(&created_at)
|
||||
.bind(created_at)
|
||||
@@ -87,7 +89,7 @@ impl ClientApiKeyRepository {
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query(
|
||||
"select id, name, prefix, enabled, created_at, last_used_at from client_api_keys order by created_at desc, id desc limit ?",
|
||||
"select id, name, label, prefix, enabled, created_at, last_used_at from client_api_keys order by created_at desc, id desc limit ?",
|
||||
)
|
||||
.bind(fetch_limit)
|
||||
.fetch_all(&self.pool)
|
||||
@@ -111,6 +113,16 @@ impl ClientApiKeyRepository {
|
||||
Ok(Page { items, next_cursor })
|
||||
}
|
||||
|
||||
pub async fn get(&self, id: &str) -> ClientApiKeyRepositoryResult<Option<StoredClientApiKey>> {
|
||||
let row = sqlx::query(
|
||||
"select id, name, label, prefix, enabled, created_at, last_used_at from client_api_keys where id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(|row| key_from_row(&row)))
|
||||
}
|
||||
|
||||
pub async fn verify_and_touch(
|
||||
&self,
|
||||
plaintext: &str,
|
||||
@@ -147,6 +159,22 @@ impl ClientApiKeyRepository {
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn set_label(
|
||||
&self,
|
||||
id: &str,
|
||||
label: Option<String>,
|
||||
) -> ClientApiKeyRepositoryResult<Option<StoredClientApiKey>> {
|
||||
let result = sqlx::query("update client_api_keys set label = ? where id = ?")
|
||||
.bind(label)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
self.get(id).await
|
||||
}
|
||||
|
||||
pub async fn delete(&self, id: &str) -> ClientApiKeyRepositoryResult<bool> {
|
||||
let result = sqlx::query("delete from client_api_keys where id = ?")
|
||||
.bind(id)
|
||||
@@ -160,6 +188,7 @@ fn key_from_row(row: &sqlx::sqlite::SqliteRow) -> StoredClientApiKey {
|
||||
StoredClientApiKey {
|
||||
id: row.get("id"),
|
||||
name: row.get("name"),
|
||||
label: row.get("label"),
|
||||
prefix: row.get("prefix"),
|
||||
enabled: row.get::<i64, _>("enabled") != 0,
|
||||
created_at: row.get("created_at"),
|
||||
|
||||
@@ -325,6 +325,7 @@ pub struct CreateApiKeyRequest {
|
||||
pub struct ClientApiKeyData {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub prefix: String,
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
@@ -336,6 +337,7 @@ pub struct ClientApiKeyData {
|
||||
pub struct CreatedClientApiKeyData {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub prefix: String,
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
@@ -349,6 +351,12 @@ pub struct UpdateClientApiKeyStatusRequest {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateClientApiKeyLabelRequest {
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateClientApiKeyStatusData {
|
||||
@@ -356,6 +364,19 @@ pub struct UpdateClientApiKeyStatusData {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BatchDeleteClientApiKeysRequest {
|
||||
pub ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BatchDeleteClientApiKeysData {
|
||||
pub deleted: u32,
|
||||
pub not_found: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeleteClientApiKeyData {
|
||||
@@ -367,6 +388,7 @@ impl From<StoredClientApiKey> for ClientApiKeyData {
|
||||
Self {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
label: key.label,
|
||||
prefix: key.prefix,
|
||||
enabled: key.enabled,
|
||||
created_at: key.created_at,
|
||||
@@ -380,6 +402,7 @@ impl CreatedClientApiKeyData {
|
||||
Self {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
label: key.label,
|
||||
prefix: key.prefix,
|
||||
enabled: key.enabled,
|
||||
created_at: key.created_at,
|
||||
@@ -1820,6 +1843,136 @@ pub async fn create_api_key(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn batch_delete_api_keys(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<BatchDeleteClientApiKeysRequest>,
|
||||
) -> Response {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
if payload.ids.is_empty() {
|
||||
return AdminResponse::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
AdminEnvelope::new(40001, "API key ids are required", (), request_id),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
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 mut deleted = 0u32;
|
||||
let mut not_found = Vec::new();
|
||||
for key_id in payload.ids {
|
||||
match repo.delete(&key_id).await {
|
||||
Ok(true) => deleted += 1,
|
||||
Ok(false) => not_found.push(key_id),
|
||||
Err(_) => {
|
||||
return AdminResponse::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AdminEnvelope::new(50001, "Failed to delete API key", (), request_id),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(
|
||||
BatchDeleteClientApiKeysData { deleted, not_found },
|
||||
request_id,
|
||||
),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn update_api_key_label(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Path(key_id): Path<String>,
|
||||
Json(payload): Json<UpdateClientApiKeyLabelRequest>,
|
||||
) -> Response {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
if payload
|
||||
.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 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();
|
||||
};
|
||||
|
||||
// label 是后台显示备注,不能影响 cpr_ key 的 hash、prefix 或启用状态。
|
||||
match repo.set_label(&key_id, payload.label).await {
|
||||
Ok(Some(key)) => AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(ClientApiKeyData::from(key), request_id),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(None) => AdminResponse::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
AdminEnvelope::new(40401, "API key not found", (), request_id),
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => AdminResponse::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AdminEnvelope::new(50001, "Failed to update API key label", (), request_id),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_api_key_status(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
|
||||
@@ -305,6 +305,137 @@ async fn admin_api_key_delete_should_remove_client_key_authorization() {
|
||||
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_api_key_label_should_update_and_clear_local_client_key_name() {
|
||||
let (app, _dir) = admin_api_keys_test_app("admin-api-key-label.sqlite").await;
|
||||
let (key_id, _plaintext) = create_admin_api_key(&app, "session_1", "label-key").await;
|
||||
|
||||
let renamed = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PATCH")
|
||||
.uri(format!("/admin/api-keys/{key_id}/label"))
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.body(Body::from(r#"{"label":"automation"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(renamed.status(), StatusCode::OK);
|
||||
let body = response_json(renamed).await;
|
||||
assert_eq!(body["data"]["name"], "label-key");
|
||||
assert_eq!(body["data"]["label"], "automation");
|
||||
|
||||
let cleared = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PATCH")
|
||||
.uri(format!("/admin/api-keys/{key_id}/label"))
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.body(Body::from(r#"{"label":null}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(cleared.status(), StatusCode::OK);
|
||||
let body = response_json(cleared).await;
|
||||
assert_eq!(body["data"]["name"], "label-key");
|
||||
assert!(body["data"]["label"].is_null());
|
||||
|
||||
let too_long = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PATCH")
|
||||
.uri(format!("/admin/api-keys/{key_id}/label"))
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.body(Body::from(json!({ "label": "x".repeat(65) }).to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(too_long.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let missing = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PATCH")
|
||||
.uri("/admin/api-keys/missing/label")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.body(Body::from(r#"{"label":"automation"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_api_keys_batch_delete_should_remove_found_keys_and_report_missing_ids() {
|
||||
let (app, _dir) = admin_api_keys_test_app("admin-api-key-batch-delete.sqlite").await;
|
||||
let (key_a, plaintext_a) = create_admin_api_key(&app, "session_1", "batch-a").await;
|
||||
let (key_b, plaintext_b) = create_admin_api_key(&app, "session_1", "batch-b").await;
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/admin/api-keys/batch-delete")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"ids": [key_a, "ghost", key_b]
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response_json(response).await;
|
||||
assert_eq!(body["data"]["deleted"], 2);
|
||||
assert_eq!(body["data"]["notFound"], json!(["ghost"]));
|
||||
|
||||
for plaintext in [plaintext_a, plaintext_b] {
|
||||
let rejected = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/v1/models")
|
||||
.header("authorization", format!("Bearer {plaintext}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
let empty = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/admin/api-keys/batch-delete")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.body(Body::from(r#"{"ids":[]}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(empty.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
async fn admin_api_keys_test_app(db_name: &str) -> (axum::Router, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join(db_name);
|
||||
|
||||
Reference in New Issue
Block a user