From 228febde670ee01b1a57ea0180f104833f566af3 Mon Sep 17 00:00:00 2001 From: zyycn Date: Fri, 12 Jun 2026 01:23:50 +0800 Subject: [PATCH] feat: add encrypted admin account import Co-authored-by: Codex --- docs/api.md | 42 +++ docs/implementation-status.md | 4 +- .../plans/2026-06-11-codex-proxy-rs.md | 3 +- src/accounts/repository.rs | 84 ++++++ src/app.rs | 4 +- src/crypto.rs | 33 +++ src/http/admin.rs | 260 +++++++++++++++++ src/main.rs | 4 +- src/state.rs | 27 ++ tests/admin_accounts_route_test.rs | 261 ++++++++++++++++++ tests/crypto_test.rs | 15 + 11 files changed, 732 insertions(+), 5 deletions(-) create mode 100644 tests/admin_accounts_route_test.rs diff --git a/docs/api.md b/docs/api.md index 21d74d39e..99f26654d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -116,6 +116,48 @@ Success response: } ``` +### `GET /admin/accounts` + +Returns stored Codex accounts with cursor pagination. Access tokens and refresh tokens are never decrypted or returned. + +### `POST /admin/accounts/import` + +Imports accounts into encrypted SQLite storage. The request body uses the Rust import format, which accepts the exported account object fields needed by this service and ignores unrelated fields. + +Request: + +```json +{ + "accounts": [ + { + "id": "acct_01", + "email": "user@example.com", + "accountId": "chatgpt-account", + "userId": "chatgpt-user", + "label": "primary", + "planType": "plus", + "token": "access-token", + "refreshToken": "refresh-token", + "status": "active" + } + ] +} +``` + +Success response: + +```json +{ + "code": 200, + "message": "OK", + "data": { + "imported": 1, + "skipped": 0 + }, + "requestId": "req_01" +} +``` + Success body: ```json diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 15c788388..16166f00b 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -9,14 +9,14 @@ Update this file before each feature commit. | Configuration | Completed | `9ed8579` + `e0680bf` + `cfec8ba` | `cargo test default_config_keeps_only_codex_backend`; `cargo test config_loader_merges_default_local_and_cprs_env_overrides`; full `fmt/test/clippy` before commit | Defines Codex-only config, default/local/env loading, default YAML, `Arc` state shell, `gpt-5.5` default model, and in-scope model/auth/quota/usage/logging settings fields without proxy/Ollama/provider/OpenAI-key knobs. | | SQLite storage | Completed | `ffec8b5` + `b0c10f2` | `cargo test sqlite_schema_creates_accounts_and_event_tables`; `cargo test connect_sqlite_creates_parent_directory_for_file_database`; full `fmt/test/clippy` before commit | Creates SQLite WAL connector, parent directory bootstrap, sqlx-managed schema for accounts/API keys/sessions/cookies/fingerprints/events, and event/account indexes. | | Admin auth and client API keys | Completed | `24d5799` + `4e0d1e5` | `cargo test client_api_key_has_proxy_prefix_and_verifies_against_hash`; `cargo test admin_password_hash_is_not_a_client_api_key`; `cargo test --test admin_login_route_test`; full `fmt/test/clippy` before commit | Admin passwords use Argon2id, client API keys use `cpr_` prefix plus HMAC-SHA256 with server-side pepper; `/admin/login` issues HttpOnly sessions and rejects client API keys as admin credentials. | -| Secret encryption | Completed | `105aa58` | `cargo test secret_box_encrypts_and_decrypts_without_storing_plaintext`; full `fmt/test/clippy` before commit | AES-256-GCM `SecretBox` stores upstream secrets as `v1::` without plaintext. | +| Secret encryption | Completed | `105aa58` + `pending` | `cargo test secret_box_encrypts_and_decrypts_without_storing_plaintext`; `cargo test secret_box_load_or_create_should_reuse_existing_master_key_file`; full `fmt/test/clippy` before commit | AES-256-GCM `SecretBox` stores upstream secrets as `v1::` without plaintext and can load/create the runtime master key file used by repositories. | | Logging and pagination | Completed | `eb0341e` + `fc16c48` + `b0c10f2` | `cargo test event_logs_are_cursor_paginated`; `cargo test admin_logs_are_cursor_paginated_and_include_request_id`; `cargo test rotating_writer_splits_files_when_size_limit_is_reached`; full `fmt/test/clippy` before commit | Adds cursor pagination, `Page` camelCase serialization, admin `/admin/logs` query API, event log repository, runtime tracing initialization, HTTP trace layer, and date/size-based file rotation. | | TLS headers and fingerprint | Completed | `1bdd08c` | `cargo test codex_headers_include_desktop_identity_and_turn_state`; `cargo tree \| rg 'reqwest\|rustls'`; full `fmt/test/clippy` before commit | Adds Codex Desktop fingerprint model, exact identity/request headers, and pinned reqwest/rustls client builder. | | Cookie persistence | Completed | `4710926` + `5d3cc6e` | `cargo test cookie_jar_captures_and_replays_account_scoped_cookies`; `cargo test cookie_repository_encrypts_and_replays_account_scoped_cookies`; full `fmt/test/clippy` before commit | Adds account-scoped in-memory Cookie jar and encrypted SQLite Cookie repository; Cookies are replayed only for the same account/domain. | | Account pool and refresh | Partial | `0f97c5c` + `2f25db8` + `4dc0c9a` | `cargo test account_pool_skips_expired_disabled_banned_and_quota_exhausted_accounts`; `cargo test --test account_pool_scheduling_test`; `cargo test --test account_refresh_test`; full `fmt/test/clippy` before commit | Implements base account status filtering, refresh scheduler semantics, max concurrent slots, stale slot cleanup, least-used/round-robin/sticky strategies, tier priority, quota-cache filtering, Cloudflare cooldown skip, request staggering metadata, preferred account selection, exclude IDs, and capacity summary. Still missing settings-backed scheduling config, SQLite repository integration for pool snapshots/release usage, upstream retry/fallback wiring, and admin account CRUD. | | Account repository | Completed | `14fe706` | `cargo test --test account_repository_test`; `cargo fmt --check`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; `cargo test` | Adds encrypted SQLite account token storage, decrypting reads, cursor pagination, status/label updates, release-time usage counter persistence, and refresh-token-preserving token updates. | | Translation | Completed | `eff182e` | `cargo test chat_completion_translates_to_codex_response_request`; full `fmt/test/clippy` before commit | Adds OpenAI Chat request types, Codex Responses request type, translation, and OpenAI-compatible error body helper. | -| HTTP routes | Partial | `ab0ad8f` + `3830b21` + `6179d44` + `cfec8ba` + `6341336` | `cargo test v1_requires_client_api_key_not_admin_cookie`; `cargo test models_route_returns_openai_compatible_codex_model_list`; `cargo test --test admin_settings_route_test`; `cargo test --test model_catalog_test`; full `fmt/test/clippy` before commit | Adds Axum router, health route, `/v1/models`, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, `/debug/models`, `/v1` unauthorized OpenAI error body, global request-id response header, binary listener, `gpt-5.5` as the default/test model baseline, and read-only `/admin/settings` with admin-session auth and lower camelCase envelope. Still missing admin account/auth/API-key management routes, settings mutation/persistence, diagnostics, usage stats, backend model refresh, model cache persistence, and additional Responses routes. | +| HTTP routes | Partial | `ab0ad8f` + `3830b21` + `6179d44` + `cfec8ba` + `6341336` + `pending` | `cargo test v1_requires_client_api_key_not_admin_cookie`; `cargo test models_route_returns_openai_compatible_codex_model_list`; `cargo test --test admin_settings_route_test`; `cargo test --test model_catalog_test`; `cargo test --test admin_accounts_route_test`; full `fmt/test/clippy` before commit | Adds Axum router, health route, `/v1/models`, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, `/debug/models`, `/admin/accounts`, `/admin/accounts/import`, `/v1` unauthorized OpenAI error body, global request-id response header, binary listener, `gpt-5.5` as the default/test model baseline, and read-only `/admin/settings` with admin-session auth and lower camelCase envelope. `/admin/accounts` reads metadata only and does not decrypt tokens for list views. Still missing OAuth/device/CLI account login flows, admin account mutation/health/quota/Cookie routes, admin API-key management routes, settings mutation/persistence, diagnostics, usage stats, backend model refresh, model cache persistence, and additional Responses routes. | | Admin/frontend response contract | Completed | `1c34ea7` + `b0c10f2` | `cargo test admin_envelope_serializes_lower_camel_case_request_id`; `cargo test admin_logs_reject_missing_admin_session_cookie`; `cargo test v1_response_has_request_id_header_without_admin_body_field`; full `fmt/test/clippy` before commit | Adds reusable admin envelopes with body codes, lower camelCase JSON, paginated list shape, requestId bodies, admin session cookie enforcement, and keeps `/v1/*` OpenAI-compatible. | | HTTP auth boundary | Completed | `cec4607` | `cargo test --test http_auth_test`; `cargo test --test routes_responses_test`; `cargo test --test admin_logs_route_test`; `cargo fmt --check`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; `cargo test` | Adds a real `src/http/auth.rs` boundary for client API key and admin session cookie parsing. `/v1/*` accepts only `Bearer cpr_...`, admin session parsing ignores client API keys, and route handlers no longer own ad hoc credential parsing. | | Model catalog | Partial | `6341336` | `cargo test --test model_catalog_test`; `cargo test model_catalog_route_returns_codex_metadata_without_alias_entries`; full `fmt/test/clippy` before commit | Adds a native static Codex model catalog with `gpt-5.5` default, configured aliases, reasoning/service-tier suffix parsing, OpenAI-compatible `/v1/models`, extended catalog/detail/info routes, and debug summary. Still missing backend Codex model fetching per plan, cache persistence, `/admin/refresh-models`, plan allowlist integration from fetched models, and full model metadata parity. | diff --git a/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md b/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md index 6b794fbf2..59b09150b 100644 --- a/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md +++ b/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md @@ -2962,6 +2962,7 @@ git commit -m "docs: document codex-proxy-rs scope" - [x] Set `gpt-5.5` as the default/test model baseline across code, tests, and plan examples. `/v1/models` now asserts the exact default model instead of only checking for a `gpt` prefix. - [x] Add read-only `/admin/settings` for the retained Rust settings surface. The response is admin-session gated, lower camelCase, and excludes proxy URL, Ollama, third-party provider, OpenAI official API key, proxy API key, and Electron/self-update fields. Persistent settings mutation remains a separate task. - [x] Add a native static model catalog foundation. It exposes `gpt-5.5`, configured aliases, reasoning/service tier suffix parsing, `/v1/models`, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, and `/debug/models`. Backend model fetching/cache and `/admin/refresh-models` remain separate tasks. +- [x] Add encrypted admin account import/list foundations. `/admin/accounts/import` stores access/refresh tokens through `SecretBox` and `/admin/accounts` returns paginated sanitized account metadata without tokens or token decryption. The runtime now loads or creates the master key file before constructing repository-backed routes. ## 2026-06-11 Original Project Gap Audit @@ -2971,7 +2972,7 @@ The Rust project has no legacy compatibility requirement, but the in-scope behav - [ ] **HTTP auth boundary:** restore a real `src/http/auth.rs` module instead of scattered header checks. It must validate `Bearer cpr_...` client API keys for `/v1/*`, validate admin session cookies for `/admin/*`, reject cross-use in both directions, and return the documented OpenAI/admin response families. - [ ] **Account SQLite repository:** implement `src/accounts/repository.rs` for encrypted access/refresh tokens, account metadata, quota JSON, usage counters, labels, status transitions, pagination, and batch-safe mutations. Do not add TypeScript JSON migration or legacy import shims. -- [ ] **Admin account routes:** rebuild `/auth/status`, `/auth/accounts`, import/export, batch delete/status, health-check, per-account refresh, label, delete, reset usage, quota, Cookie CRUD, and quota warnings as `/admin/*` endpoints with admin envelopes and cursor pagination where a list is returned. +- [ ] **Admin account routes:** rebuild `/auth/status`, `/auth/accounts`, import/export, batch delete/status, health-check, per-account refresh, label, delete, reset usage, quota, Cookie CRUD, and quota warnings as `/admin/*` endpoints with admin envelopes and cursor pagination where a list is returned. Encrypted import and paginated sanitized list are complete; export, mutations, health, quota, Cookie CRUD, and warnings remain. - [ ] **Login routes:** implement OAuth PKCE login-start/code-relay/callback, device-login/device-poll, CLI auth import, manual access-token import, and logout as account-login flows. Keep this separate from admin password login and client API key creation. - [ ] **Client API key management:** implement admin CRUD for local client API keys: list/create/delete/batch-delete/label/status/export/import. Remove provider/model binding semantics from the TypeScript version; Rust client API keys authorize only local `/v1/*` access. - [ ] **Settings:** implement admin settings for server/runtime fields that remain in scope: default model, reasoning effort, service tier, model aliases, refresh enabled/margin/concurrency, max concurrent per account, request interval, rotation strategy, tier priority, quota refresh/thresholds/skip exhausted, logs state/capacity/body capture, and usage history retention. Read-only `/admin/settings` is complete; persistent mutation and config-store writeback remain. Remove proxy URL, Ollama, third-party provider, OpenAI official key, and Electron/self-update settings. diff --git a/src/accounts/repository.rs b/src/accounts/repository.rs index 7483eaaa9..97ce534a0 100644 --- a/src/accounts/repository.rs +++ b/src/accounts/repository.rs @@ -62,6 +62,20 @@ pub struct StoredAccount { pub updated_at: DateTime, } +#[derive(Debug, Clone)] +pub struct StoredAccountMetadata { + pub id: String, + pub email: Option, + pub account_id: Option, + pub user_id: Option, + pub label: Option, + pub plan_type: Option, + pub access_token_expires_at: Option>, + pub status: AccountStatus, + pub added_at: DateTime, + pub updated_at: DateTime, +} + #[derive(Debug, Clone, Copy)] pub struct UsageDelta { pub input_tokens: i64, @@ -128,6 +142,14 @@ impl AccountRepository { row.map(|row| self.account_from_row(&row)).transpose() } + pub async fn exists(&self, id: &str) -> AccountRepositoryResult { + let row = sqlx::query("select 1 from accounts where id = ?") + .bind(id) + .fetch_optional(&self.pool) + .await?; + Ok(row.is_some()) + } + pub async fn list( &self, cursor: Option, @@ -171,6 +193,49 @@ impl AccountRepository { Ok(Page { items, next_cursor }) } + pub async fn list_metadata( + &self, + cursor: Option, + limit: u32, + ) -> AccountRepositoryResult> { + let fetch_limit = i64::from(limit) + 1; + let rows = if let Some(cursor) = cursor { + let (created_at, id) = + decode_cursor(&cursor).ok_or(AccountRepositoryError::InvalidCursor)?; + sqlx::query( + "select id, email, account_id, user_id, label, plan_type, access_token_expires_at, status, added_at, updated_at from accounts where added_at < ? or (added_at = ? and id < ?) order by added_at desc, id desc limit ?", + ) + .bind(&created_at) + .bind(created_at) + .bind(id) + .bind(fetch_limit) + .fetch_all(&self.pool) + .await? + } else { + sqlx::query( + "select id, email, account_id, user_id, label, plan_type, access_token_expires_at, status, added_at, updated_at from accounts order by added_at desc, id desc limit ?", + ) + .bind(fetch_limit) + .fetch_all(&self.pool) + .await? + }; + + let has_next = rows.len() > limit as usize; + let take_count = rows.len().min(limit as usize); + let mut items = Vec::with_capacity(take_count); + for row in rows.into_iter().take(take_count) { + items.push(metadata_from_row(&row)?); + } + let next_cursor = if has_next { + items + .last() + .map(|account| encode_cursor(&account.added_at.to_rfc3339(), &account.id)) + } else { + None + }; + Ok(Page { items, next_cursor }) + } + pub async fn set_status( &self, id: &str, @@ -338,3 +403,22 @@ fn usage_from_row(row: &sqlx::sqlite::SqliteRow) -> AccountRepositoryResult, _>("last_used_at"))?, }) } + +fn metadata_from_row( + row: &sqlx::sqlite::SqliteRow, +) -> AccountRepositoryResult { + Ok(StoredAccountMetadata { + id: row.get("id"), + email: row.get("email"), + account_id: row.get("account_id"), + user_id: row.get("user_id"), + label: row.get("label"), + plan_type: row.get("plan_type"), + access_token_expires_at: parse_optional_rfc3339( + row.get::, _>("access_token_expires_at"), + )?, + status: status_from_db(&row.get::("status"))?, + added_at: parse_rfc3339(&row.get::("added_at"))?, + updated_at: parse_rfc3339(&row.get::("updated_at"))?, + }) +} diff --git a/src/app.rs b/src/app.rs index ee407c1be..da9233586 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,7 +7,7 @@ use tower_http::trace::TraceLayer; use crate::{ http::{ - admin::{login, logs, settings}, + admin::{accounts, import_accounts, login, logs, settings}, health::health, middleware::attach_request_id, v1::{debug_models, model_catalog, model_detail, model_info, models, responses}, @@ -28,6 +28,8 @@ pub fn build_router(state: AppState) -> Router { .route("/admin/login", post(login)) .route("/admin/logs", get(logs)) .route("/admin/settings", get(settings)) + .route("/admin/accounts", get(accounts)) + .route("/admin/accounts/import", post(import_accounts)) .with_state(state) .layer(TraceLayer::new_for_http()) .layer(from_fn(attach_request_id)) diff --git a/src/crypto.rs b/src/crypto.rs index 551494882..a61ddab34 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -1,3 +1,5 @@ +use std::{fs, path::Path}; + use aes_gcm::{ aead::{rand_core::RngCore, Aead, OsRng}, Aes256Gcm, KeyInit, Nonce, @@ -22,6 +24,10 @@ pub enum CryptoError { UnsupportedVersion, #[error("secret is not valid utf-8: {0}")] Utf8(#[from] std::string::FromUtf8Error), + #[error("secret key file io error: {0}")] + Io(#[from] std::io::Error), + #[error("stored secret key must decode to 32 bytes")] + InvalidStoredKeyLength, } pub type CryptoResult = Result; @@ -36,6 +42,26 @@ impl SecretBox { Self { key } } + pub fn load_or_create(path: impl AsRef) -> CryptoResult { + let path = path.as_ref(); + if path.exists() { + let encoded = fs::read_to_string(path)?; + let key = decode_key(encoded.trim())?; + return Ok(Self::new(key)); + } + + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent)?; + } + let mut key = [0u8; 32]; + OsRng.fill_bytes(&mut key); + fs::write(path, URL_SAFE_NO_PAD.encode(key))?; + Ok(Self::new(key)) + } + pub fn encrypt(&self, plaintext: &SecretString) -> CryptoResult { let cipher = Aes256Gcm::new_from_slice(&self.key).map_err(|_| CryptoError::InvalidKeyLength)?; @@ -74,3 +100,10 @@ impl SecretBox { Ok(SecretString::new(String::from_utf8(plaintext)?.into())) } } + +fn decode_key(encoded: &str) -> CryptoResult<[u8; 32]> { + URL_SAFE_NO_PAD + .decode(encoded)? + .try_into() + .map_err(|_| CryptoError::InvalidStoredKeyLength) +} diff --git a/src/http/admin.rs b/src/http/admin.rs index 229d846bb..72b3677db 100644 --- a/src/http/admin.rs +++ b/src/http/admin.rs @@ -8,11 +8,16 @@ use axum::{ Extension, Json, }; use chrono::{Duration, Utc}; +use secrecy::SecretString; use serde::{Deserialize, Serialize}; use sqlx::Row; use uuid::Uuid; use crate::{ + accounts::{ + model::AccountStatus, + repository::{NewAccount, StoredAccountMetadata}, + }, auth::admin_session::verify_admin_password, config::{AppConfig, QuotaWarningThresholds}, http::{auth::admin_session_id, middleware::RequestId}, @@ -118,6 +123,13 @@ pub struct LogsQuery { pub limit: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountsQuery { + pub cursor: Option, + pub limit: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LoginRequest { @@ -153,6 +165,67 @@ pub struct AdminSettingsData { pub usage_history_retention_days: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountImportPayload { + pub accounts: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountImportEntry { + pub id: Option, + pub email: Option, + pub account_id: Option, + pub user_id: Option, + pub label: Option, + pub plan_type: Option, + pub token: String, + pub refresh_token: Option, + pub status: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountImportData { + pub imported: u32, + pub skipped: u32, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminAccountData { + pub id: String, + pub email: Option, + pub account_id: Option, + pub user_id: Option, + pub label: Option, + pub plan_type: Option, + pub status: String, + pub access_token_expires_at: Option, + pub added_at: String, + pub updated_at: String, +} + +impl From for AdminAccountData { + fn from(account: StoredAccountMetadata) -> Self { + Self { + id: account.id, + email: account.email, + account_id: account.account_id, + user_id: account.user_id, + label: account.label, + plan_type: account.plan_type, + status: account_status_value(account.status).to_string(), + access_token_expires_at: account + .access_token_expires_at + .map(|value| value.to_rfc3339()), + added_at: account.added_at.to_rfc3339(), + updated_at: account.updated_at.to_rfc3339(), + } + } +} + impl AdminSettingsData { fn from_config(config: &AppConfig) -> Self { Self { @@ -345,6 +418,159 @@ pub async fn settings( .into_response() } +pub async fn accounts( + State(state): State, + Extension(request_id): Extension, + headers: HeaderMap, + Query(query): Query, +) -> 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.account_repository() else { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new( + 50001, + "Account repository is not initialized", + (), + request_id, + ), + ) + .into_response(); + }; + + let limit = clamp_limit(query.limit.unwrap_or(50)); + match repo.list_metadata(query.cursor, limit).await { + Ok(page) => { + let Page { items, next_cursor } = page; + let page = Page { + items: items.into_iter().map(AdminAccountData::from).collect(), + next_cursor, + }; + AdminResponse::new( + StatusCode::OK, + AdminPageEnvelope::ok(page, limit, request_id), + ) + .into_response() + } + Err(_) => AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "Failed to list accounts", (), request_id), + ) + .into_response(), + } +} + +pub async fn import_accounts( + State(state): State, + Extension(request_id): Extension, + headers: HeaderMap, + Json(payload): Json, +) -> 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.account_repository() else { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new( + 50001, + "Account repository is not initialized", + (), + request_id, + ), + ) + .into_response(); + }; + if payload.accounts.is_empty() { + return AdminResponse::new( + StatusCode::BAD_REQUEST, + AdminEnvelope::new(40001, "No importable accounts found", (), request_id), + ) + .into_response(); + } + + let mut imported = 0u32; + let mut skipped = 0u32; + for entry in payload.accounts { + let token = entry.token.trim(); + if token.is_empty() { + skipped += 1; + continue; + } + let id = normalized_account_id(entry.id); + match repo.exists(&id).await { + Ok(true) => { + skipped += 1; + continue; + } + Ok(false) => {} + Err(_) => { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "Failed to inspect account", (), request_id), + ) + .into_response(); + } + } + + let status = match parse_import_status(entry.status.as_deref()) { + Ok(status) => status, + Err(message) => { + return AdminResponse::new( + StatusCode::BAD_REQUEST, + AdminEnvelope::new(40001, message, (), request_id), + ) + .into_response(); + } + }; + let account = NewAccount { + id, + email: empty_to_none(entry.email), + account_id: empty_to_none(entry.account_id), + user_id: empty_to_none(entry.user_id), + label: empty_to_none(entry.label), + plan_type: empty_to_none(entry.plan_type), + access_token: SecretString::new(token.to_string().into()), + refresh_token: empty_to_none(entry.refresh_token) + .map(|token| SecretString::new(token.into())), + access_token_expires_at: None, + status, + }; + if repo.insert(account).await.is_err() { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "Failed to import account", (), request_id), + ) + .into_response(); + } + imported += 1; + } + + AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(AccountImportData { imported, skipped }, request_id), + ) + .into_response() +} + #[derive(Debug)] struct AdminUserRow { id: String, @@ -429,3 +655,37 @@ async fn require_admin_session( .into_response()), } } + +fn normalized_account_id(id: Option) -> String { + id.and_then(|id| empty_to_none(Some(id))) + .unwrap_or_else(|| format!("acct_{}", Uuid::new_v4().simple())) +} + +fn empty_to_none(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn parse_import_status(status: Option<&str>) -> Result { + match status.unwrap_or("active") { + "active" => Ok(AccountStatus::Active), + "expired" => Ok(AccountStatus::Expired), + "quota_exhausted" => Ok(AccountStatus::QuotaExhausted), + "refreshing" => Ok(AccountStatus::Refreshing), + "disabled" => Ok(AccountStatus::Disabled), + "banned" => Ok(AccountStatus::Banned), + other => Err(format!("Unsupported account status: {other}")), + } +} + +fn account_status_value(status: AccountStatus) -> &'static str { + match status { + AccountStatus::Active => "active", + AccountStatus::Expired => "expired", + AccountStatus::QuotaExhausted => "quota_exhausted", + AccountStatus::Refreshing => "refreshing", + AccountStatus::Disabled => "disabled", + AccountStatus::Banned => "banned", + } +} diff --git a/src/main.rs b/src/main.rs index 2c1a053a9..90eb58e0f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ use codex_proxy_rs::{ app::build_router, config::AppConfig, + crypto::SecretBox, logs::rotation::{init_tracing, RotationConfig}, state::AppState, storage::db::connect_sqlite, @@ -17,8 +18,9 @@ async fn main() -> anyhow::Result<()> { ))?; let host = config.server.host.clone(); let port = config.server.port; + let secret_box = SecretBox::load_or_create(&config.security.master_key_file)?; let pool = connect_sqlite(&config.database.url).await?; - let app = build_router(AppState::with_pool(config, pool)); + let app = build_router(AppState::with_pool_and_secret_box(config, pool, secret_box)); let listener = tokio::net::TcpListener::bind((host.as_str(), port)).await?; tracing::info!(host, port, "codex-proxy-rs listening"); axum::serve(listener, app).await?; diff --git a/src/state.rs b/src/state.rs index 55da7fd47..10eb2fa58 100644 --- a/src/state.rs +++ b/src/state.rs @@ -2,7 +2,9 @@ use std::sync::Arc; use sqlx::SqlitePool; +use crate::accounts::repository::AccountRepository; use crate::config::AppConfig; +use crate::crypto::SecretBox; use crate::logs::repository::EventLogRepository; #[derive(Clone)] @@ -14,6 +16,7 @@ pub struct AppServices { pub config: AppConfig, pub db: Option, pub event_logs: Option, + pub secret_box: Option, } impl AppState { @@ -23,6 +26,7 @@ impl AppState { config, db: None, event_logs: None, + secret_box: None, }), } } @@ -33,6 +37,22 @@ impl AppState { config, db: Some(pool.clone()), event_logs: Some(EventLogRepository::new(pool)), + secret_box: None, + }), + } + } + + pub fn with_pool_and_secret_box( + config: AppConfig, + pool: SqlitePool, + secret_box: SecretBox, + ) -> Self { + Self { + services: Arc::new(AppServices { + config, + db: Some(pool.clone()), + event_logs: Some(EventLogRepository::new(pool)), + secret_box: Some(secret_box), }), } } @@ -48,4 +68,11 @@ impl AppState { pub fn event_logs(&self) -> Option<&EventLogRepository> { self.services.event_logs.as_ref() } + + pub fn account_repository(&self) -> Option { + Some(AccountRepository::new( + self.db()?.clone(), + self.services.secret_box.clone()?, + )) + } } diff --git a/tests/admin_accounts_route_test.rs b/tests/admin_accounts_route_test.rs new file mode 100644 index 000000000..74fa84020 --- /dev/null +++ b/tests/admin_accounts_route_test.rs @@ -0,0 +1,261 @@ +use std::collections::BTreeMap; + +use axum::{ + body::{to_bytes, Body}, + http::{Request, StatusCode}, +}; +use chrono::Utc; +use serde_json::{json, Value}; +use tower::ServiceExt; + +use codex_proxy_rs::{ + app::build_router, + config::{ + AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig, + QuotaConfig, QuotaWarningThresholds, SecurityConfig, ServerConfig, TlsConfig, + UsageStatsConfig, + }, + crypto::SecretBox, + state::AppState, + storage::db::connect_sqlite, +}; + +fn test_config(database_url: String) -> AppConfig { + AppConfig { + server: ServerConfig { + host: "127.0.0.1".to_string(), + port: 0, + }, + api: ApiConfig { + base_url: "https://chatgpt.com/backend-api".to_string(), + }, + model: ModelConfig { + default_model: "gpt-5.5".to_string(), + default_reasoning_effort: None, + service_tier: None, + aliases: BTreeMap::new(), + }, + auth: AuthConfig { + refresh_margin_seconds: 300, + refresh_enabled: true, + refresh_concurrency: 2, + max_concurrent_per_account: 3, + request_interval_ms: 50, + rotation_strategy: "least_used".to_string(), + tier_priority: Vec::new(), + }, + quota: QuotaConfig { + refresh_interval_minutes: 5, + warning_thresholds: QuotaWarningThresholds { + primary: vec![80, 90], + secondary: vec![80, 90], + }, + skip_exhausted: true, + }, + usage_stats: UsageStatsConfig { + history_retention_days: None, + }, + database: DatabaseConfig { url: database_url }, + security: SecurityConfig { + master_key_file: "data/master.key".to_string(), + api_key_pepper_file: "data/api-key-pepper.key".to_string(), + }, + tls: TlsConfig { + force_http11: false, + }, + admin: AdminConfig { + session_ttl_minutes: 1440, + }, + logging: LoggingConfig { + directory: "logs".to_string(), + max_file_bytes: 10_485_760, + retention_days: 14, + enabled: false, + capacity: 2_000, + capture_body: false, + }, + } +} + +#[tokio::test] +async fn admin_accounts_import_should_require_admin_session_cookie() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-accounts.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + let app = build_router(AppState::with_pool_and_secret_box( + test_config(url), + pool, + SecretBox::new([11u8; 32]), + )); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/accounts/import") + .header("content-type", "application/json") + .header("x-request-id", "req_accounts") + .body(Body::from(r#"{"accounts":[]}"#)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let body = response_json(response).await; + assert_eq!(body["code"], 40101); + assert_eq!(body["requestId"], "req_accounts"); +} + +#[tokio::test] +async fn admin_accounts_import_should_store_tokens_encrypted_and_list_sanitized_accounts() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-accounts.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let app = build_router(AppState::with_pool_and_secret_box( + test_config(url), + pool.clone(), + SecretBox::new([12u8; 32]), + )); + let import_body = json!({ + "accounts": [{ + "id": "acct_imported", + "email": "user@example.com", + "accountId": "chatgpt-account", + "userId": "chatgpt-user", + "label": "primary", + "planType": "plus", + "token": "access-secret", + "refreshToken": "refresh-secret", + "status": "active" + }] + }); + + let import_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/accounts/import") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_accounts") + .body(Body::from(import_body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(import_response.status(), StatusCode::OK); + let body = response_json(import_response).await; + assert_eq!(body["data"]["imported"], 1); + assert_eq!(body["data"]["skipped"], 0); + let stored: (String, String) = sqlx::query_as( + "select access_token_cipher, refresh_token_cipher from accounts where id = ?", + ) + .bind("acct_imported") + .fetch_one(&pool) + .await + .unwrap(); + assert!(stored.0.starts_with("v1:")); + assert!(!stored.0.contains("access-secret")); + assert!(stored.1.starts_with("v1:")); + assert!(!stored.1.contains("refresh-secret")); + + let list_response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/admin/accounts?limit=10") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_accounts_list") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(list_response.status(), StatusCode::OK); + let body = response_json(list_response).await; + assert_eq!(body["data"][0]["id"], "acct_imported"); + assert_eq!(body["data"][0]["email"], "user@example.com"); + assert!(body["data"][0].get("token").is_none()); + assert!(body["data"][0].get("refreshToken").is_none()); + assert_eq!(body["page"]["limit"], 10); +} + +#[tokio::test] +async fn admin_accounts_list_should_not_decrypt_account_tokens() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-accounts.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let now = Utc::now().to_rfc3339(); + sqlx::query( + "insert into accounts (id, email, access_token_cipher, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?)", + ) + .bind("acct_corrupt") + .bind("user@example.com") + .bind("not-a-secret-box-cipher") + .bind("active") + .bind(&now) + .bind(&now) + .execute(&pool) + .await + .unwrap(); + let app = build_router(AppState::with_pool_and_secret_box( + test_config(url), + pool, + SecretBox::new([13u8; 32]), + )); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/admin/accounts?limit=10") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_accounts_list") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["data"][0]["id"], "acct_corrupt"); +} + +async fn seed_admin_session(pool: &sqlx::SqlitePool, session_id: &str) { + let now = Utc::now().to_rfc3339(); + sqlx::query( + "insert into admin_users (id, password_hash, created_at, updated_at) values (?, ?, ?, ?)", + ) + .bind("admin_1") + .bind("hash") + .bind(&now) + .bind(&now) + .execute(pool) + .await + .unwrap(); + sqlx::query( + "insert into admin_sessions (id, user_id, expires_at, created_at) values (?, ?, ?, ?)", + ) + .bind(session_id) + .bind("admin_1") + .bind("2999-01-01T00:00:00Z") + .bind(now) + .execute(pool) + .await + .unwrap(); +} + +async fn response_json(response: axum::response::Response) -> Value { + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + serde_json::from_slice(&bytes).unwrap() +} diff --git a/tests/crypto_test.rs b/tests/crypto_test.rs index 01a05f1d0..00451bb26 100644 --- a/tests/crypto_test.rs +++ b/tests/crypto_test.rs @@ -14,3 +14,18 @@ fn secret_box_encrypts_and_decrypts_without_storing_plaintext() { "rt_example_refresh_token" ); } + +#[test] +fn secret_box_load_or_create_should_reuse_existing_master_key_file() { + let dir = tempfile::tempdir().unwrap(); + let key_path = dir.path().join("master.key"); + let first_box = SecretBox::load_or_create(&key_path).unwrap(); + let ciphertext = first_box + .encrypt(&SecretString::new("persisted-secret".to_string().into())) + .unwrap(); + + let second_box = SecretBox::load_or_create(&key_path).unwrap(); + let decrypted = second_box.decrypt(&ciphertext).unwrap(); + + assert_eq!(decrypted.expose_secret(), "persisted-secret"); +}