diff --git a/Cargo.lock b/Cargo.lock index 68308a68..c5b50484 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,7 +44,9 @@ dependencies = [ "aisix-core", "aisix-etcd", "aisix-proxy", + "async-trait", "axum", + "dashmap", "http", "mime_guess", "rust-embed", diff --git a/crates/aisix-admin/Cargo.toml b/crates/aisix-admin/Cargo.toml index 5e1b137e..ed3f15bd 100644 --- a/crates/aisix-admin/Cargo.toml +++ b/crates/aisix-admin/Cargo.toml @@ -27,6 +27,8 @@ utoipa-axum.workspace = true utoipa-scalar.workspace = true rust-embed.workspace = true mime_guess.workspace = true +async-trait.workspace = true +dashmap.workspace = true [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates/aisix-admin/src/apikeys_handlers.rs b/crates/aisix-admin/src/apikeys_handlers.rs new file mode 100644 index 00000000..3cebd974 --- /dev/null +++ b/crates/aisix-admin/src/apikeys_handlers.rs @@ -0,0 +1,108 @@ +//! CRUD handlers for `/admin/v1/apikeys`. +//! +//! Same shape as [`crate::models_handlers`], operating on `ApiKey` +//! resources. Duplicate-name detection uses `ApiKey::key` (which is the +//! ApiKey's unique human-readable name from [`aisix_core::Resource`]), +//! matching the proxy auth lookup by `by_name` index. + +use aisix_core::models::validate_apikey; +use aisix_core::resource::ResourceEntry; +use aisix_core::ApiKey; +use axum::extract::{Path, State}; +use axum::Json; +use serde_json::Value; +use uuid::Uuid; + +use crate::auth::AdminAuth; +use crate::error::AdminError; +use crate::state::AdminState; + +const STARTING_REVISION: i64 = 1; + +pub async fn list_apikeys( + _auth: AdminAuth, + State(state): State, +) -> Result>>, AdminError> { + let entries = state.store.list_apikeys().await?; + Ok(Json(entries)) +} + +pub async fn get_apikey( + _auth: AdminAuth, + Path(id): Path, + State(state): State, +) -> Result>, AdminError> { + let entry = state + .store + .get_apikey(&id) + .await? + .ok_or(AdminError::NotFound)?; + Ok(Json(entry)) +} + +pub async fn create_apikey( + _auth: AdminAuth, + State(state): State, + Json(raw): Json, +) -> Result>, AdminError> { + let apikey = decode_apikey(&raw)?; + let all = state.store.list_apikeys().await?; + assert_unique_key(&all, &apikey.key, None)?; + + let id = Uuid::new_v4().to_string(); + let entry = ResourceEntry::new(&id, apikey, STARTING_REVISION); + state.store.put_apikey(entry.clone()).await?; + Ok(Json(entry)) +} + +pub async fn update_apikey( + _auth: AdminAuth, + Path(id): Path, + State(state): State, + Json(raw): Json, +) -> Result>, AdminError> { + let existing = state + .store + .get_apikey(&id) + .await? + .ok_or(AdminError::NotFound)?; + let apikey = decode_apikey(&raw)?; + + let all = state.store.list_apikeys().await?; + assert_unique_key(&all, &apikey.key, Some(&id))?; + + let entry = ResourceEntry::new(&id, apikey, existing.revision + 1); + state.store.put_apikey(entry.clone()).await?; + Ok(Json(entry)) +} + +pub async fn delete_apikey( + _auth: AdminAuth, + Path(id): Path, + State(state): State, +) -> Result, AdminError> { + let removed = state.store.delete_apikey(&id).await?; + if !removed { + return Err(AdminError::NotFound); + } + Ok(Json(serde_json::json!({"deleted": true, "id": id}))) +} + +fn decode_apikey(raw: &Value) -> Result { + validate_apikey(raw)?; + serde_json::from_value(raw.clone()) + .map_err(|e| AdminError::BadRequest(format!("malformed ApiKey payload: {e}"))) +} + +fn assert_unique_key( + existing: &[ResourceEntry], + key: &str, + self_id: Option<&str>, +) -> Result<(), AdminError> { + for e in existing { + if e.value.key == key && self_id.is_none_or(|sid| sid != e.id) { + return Err(AdminError::Conflict(key.to_string())); + } + } + Ok(()) +} diff --git a/crates/aisix-admin/src/auth.rs b/crates/aisix-admin/src/auth.rs new file mode 100644 index 00000000..d7d10fe0 --- /dev/null +++ b/crates/aisix-admin/src/auth.rs @@ -0,0 +1,110 @@ +//! Admin-key bearer auth. Distinct from the proxy's API-key auth: +//! +//! - Admin keys come from `config.admin.admin_keys` (static, bootstrap +//! config), not the `ApiKey` table in etcd. +//! - Presentation matches OpenAI convention for symmetry — +//! `Authorization: Bearer ` with an `x-api-key` fallback. +//! +//! This extractor short-circuits with an `AdminError::Unauthorized` +//! envelope before any handler runs. + +use axum::extract::{FromRef, FromRequestParts}; +use axum::http::request::Parts; + +use crate::error::AdminError; +use crate::state::AdminState; + +/// Marker yielded by the extractor once an admin key has been verified. +/// Handlers don't need the key itself — just proof that the caller +/// supplied a valid one — so the type is empty by design. +#[derive(Debug, Clone, Copy)] +pub struct AdminAuth; + +#[axum::async_trait] +impl FromRequestParts for AdminAuth +where + S: Send + Sync, + AdminState: FromRef, +{ + type Rejection = AdminError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let token = extract_bearer(parts)?; + let admin_state = AdminState::from_ref(state); + let is_authorized = admin_state.admin_keys.iter().any(|k| k == &token); + if !is_authorized { + return Err(AdminError::Unauthorized); + } + Ok(AdminAuth) + } +} + +fn extract_bearer(parts: &Parts) -> Result { + if let Some(auth) = parts.headers.get(axum::http::header::AUTHORIZATION) { + let s = auth.to_str().map_err(|_| AdminError::Unauthorized)?; + if let Some(rest) = s.strip_prefix("Bearer ") { + let rest = rest.trim(); + if rest.is_empty() { + return Err(AdminError::Unauthorized); + } + return Ok(rest.to_string()); + } + return Err(AdminError::Unauthorized); + } + if let Some(raw) = parts.headers.get("x-api-key") { + let s = raw.to_str().map_err(|_| AdminError::Unauthorized)?; + let s = s.trim(); + if s.is_empty() { + return Err(AdminError::Unauthorized); + } + return Ok(s.to_string()); + } + Err(AdminError::Unauthorized) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::{HeaderMap, HeaderValue, Request}; + + fn parts_with(headers: HeaderMap) -> Parts { + let mut req = Request::builder().uri("/").body(()).unwrap(); + *req.headers_mut() = headers; + req.into_parts().0 + } + + #[test] + fn extract_bearer_reads_authorization_header() { + let mut h = HeaderMap::new(); + h.insert( + axum::http::header::AUTHORIZATION, + HeaderValue::from_static("Bearer admin-secret"), + ); + assert_eq!(extract_bearer(&parts_with(h)).unwrap(), "admin-secret"); + } + + #[test] + fn extract_bearer_accepts_x_api_key_fallback() { + let mut h = HeaderMap::new(); + h.insert("x-api-key", HeaderValue::from_static("admin-secret")); + assert_eq!(extract_bearer(&parts_with(h)).unwrap(), "admin-secret"); + } + + #[test] + fn extract_bearer_rejects_missing_and_wrong_scheme() { + assert!(matches!( + extract_bearer(&parts_with(HeaderMap::new())), + Err(AdminError::Unauthorized) + )); + + let mut h = HeaderMap::new(); + h.insert( + axum::http::header::AUTHORIZATION, + HeaderValue::from_static("Basic Zm9v"), + ); + assert!(matches!( + extract_bearer(&parts_with(h)), + Err(AdminError::Unauthorized) + )); + } +} diff --git a/crates/aisix-admin/src/error.rs b/crates/aisix-admin/src/error.rs new file mode 100644 index 00000000..501bcde2 --- /dev/null +++ b/crates/aisix-admin/src/error.rs @@ -0,0 +1,105 @@ +//! Admin error envelope — spec §3 uses the simpler `{"error_msg": "..."}` +//! shape (distinct from the OpenAI-style proxy envelope). +//! +//! The `AdminError` enum is the internal taxonomy. `IntoResponse` lets +//! handlers `?`-propagate without touching JSON shape boilerplate. + +use aisix_core::models::SchemaError; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::Serialize; + +use crate::store::StoreError; + +#[derive(Debug, Serialize)] +pub struct ErrorBody { + pub error_msg: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum AdminError { + #[error("missing or malformed admin authorization")] + Unauthorized, + #[error("{0}")] + BadRequest(String), + #[error("resource not found")] + NotFound, + #[error("name {0:?} already in use by another resource")] + Conflict(String), + #[error("schema validation failed at {path}: {message}")] + Schema { path: String, message: String }, + #[error("store error: {0}")] + Store(String), +} + +impl AdminError { + pub fn status(&self) -> StatusCode { + match self { + AdminError::Unauthorized => StatusCode::UNAUTHORIZED, + AdminError::BadRequest(_) | AdminError::Schema { .. } => StatusCode::BAD_REQUEST, + AdminError::NotFound => StatusCode::NOT_FOUND, + AdminError::Conflict(_) => StatusCode::CONFLICT, + AdminError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +impl From for AdminError { + fn from(e: SchemaError) -> Self { + AdminError::Schema { + path: e.path, + message: e.message, + } + } +} + +impl From for AdminError { + fn from(e: StoreError) -> Self { + AdminError::Store(e.to_string()) + } +} + +impl IntoResponse for AdminError { + fn into_response(self) -> Response { + let status = self.status(); + let body = ErrorBody { + error_msg: self.to_string(), + }; + (status, Json(body)).into_response() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn status_codes_match_spec_admin_envelope_rules() { + assert_eq!(AdminError::Unauthorized.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + AdminError::BadRequest("x".into()).status(), + StatusCode::BAD_REQUEST + ); + assert_eq!(AdminError::NotFound.status(), StatusCode::NOT_FOUND); + assert_eq!( + AdminError::Conflict("n".into()).status(), + StatusCode::CONFLICT + ); + assert_eq!( + AdminError::Store("boom".into()).status(), + StatusCode::INTERNAL_SERVER_ERROR, + ); + } + + #[test] + fn error_body_uses_error_msg_field_not_openai_shape() { + let body = ErrorBody { + error_msg: "missing field".into(), + }; + let json = serde_json::to_value(&body).unwrap(); + assert_eq!(json["error_msg"], "missing field"); + // No top-level `error` object (that's the proxy envelope). + assert!(json.get("error").is_none()); + } +} diff --git a/crates/aisix-admin/src/lib.rs b/crates/aisix-admin/src/lib.rs index cca0541e..b90f5410 100644 --- a/crates/aisix-admin/src/lib.rs +++ b/crates/aisix-admin/src/lib.rs @@ -1,40 +1,62 @@ //! aisix-admin — Admin API + Playground + embedded UI (:3001). //! -//! PR #5 provides only the startup-sequence building block: [`build_router`] -//! mounts `/health` so the server binary can bind and serve its admin -//! listener. The full CRUD surface for Models / ApiKeys, the OpenAPI -//! scalar, and the embedded UI are implemented in follow-up PRs. +//! Mounts the admin surface behind admin-key bearer auth: +//! - `GET /health` +//! - `GET|POST /admin/v1/models` +//! - `GET|PUT|DELETE /admin/v1/models/:id` +//! - `GET|POST /admin/v1/apikeys` +//! - `GET|PUT|DELETE /admin/v1/apikeys/:id` +//! +//! Writes validate against the JSON Schemas from `aisix-core` and reject +//! duplicate names (409). The storage layer is pluggable via the +//! [`ConfigStore`] trait; production wires an etcd-backed impl in a +//! follow-up PR, tests use [`InMemoryStore`]. +//! +//! Errors follow the simple admin envelope: `{"error_msg": "..."}`, +//! distinct from the proxy's OpenAI-style envelope. #![forbid(unsafe_code)] #![deny(rust_2018_idioms)] -use aisix_core::snapshot::SnapshotHandle; -use aisix_core::{AdminConfig, AisixSnapshot}; -use axum::{http::StatusCode, routing::get, Json, Router}; -use serde_json::json; -use std::sync::Arc; - -/// Runtime state shared across admin handlers. -#[derive(Clone)] -pub struct AdminState { - pub snapshot: SnapshotHandle, - /// Admin keys are held as an Arc<[String]> so cloning the state is cheap. - pub admin_keys: Arc<[String]>, -} +mod apikeys_handlers; +mod auth; +mod error; +mod models_handlers; +mod state; +pub mod store; -impl AdminState { - pub fn new(snapshot: SnapshotHandle, cfg: &AdminConfig) -> Self { - Self { - snapshot, - admin_keys: Arc::from(cfg.admin_keys.clone()), - } - } -} +pub use auth::AdminAuth; +pub use error::{AdminError, ErrorBody}; +pub use state::AdminState; +pub use store::{ConfigStore, InMemoryStore, StoreError}; + +use axum::routing::get; +use axum::{http::StatusCode, Json, Router}; +use serde_json::json; -/// Build the admin router. For PR #5 this is just `/health`. pub fn build_router(state: AdminState) -> Router { Router::new() .route("/health", get(health)) + .route( + "/admin/v1/models", + get(models_handlers::list_models).post(models_handlers::create_model), + ) + .route( + "/admin/v1/models/:id", + get(models_handlers::get_model) + .put(models_handlers::update_model) + .delete(models_handlers::delete_model), + ) + .route( + "/admin/v1/apikeys", + get(apikeys_handlers::list_apikeys).post(apikeys_handlers::create_apikey), + ) + .route( + "/admin/v1/apikeys/:id", + get(apikeys_handlers::get_apikey) + .put(apikeys_handlers::update_apikey) + .delete(apikeys_handlers::delete_apikey), + ) .with_state(state) } @@ -55,46 +77,371 @@ async fn health( #[cfg(test)] mod tests { use super::*; - use axum::body::to_bytes; - use axum::http::Request; + use aisix_core::snapshot::SnapshotHandle; + use aisix_core::{AdminConfig, AisixSnapshot}; + use axum::body::{to_bytes, Body}; + use axum::http::{Request, StatusCode}; + use serde_json::{json, Value}; + use std::sync::Arc; use tower::ServiceExt; fn cfg() -> AdminConfig { AdminConfig { addr: "127.0.0.1:0".into(), - admin_keys: vec!["k1".into()], + admin_keys: vec!["admin-secret".into()], tls: None, } } - #[tokio::test] - async fn health_returns_ok_and_counts() { + fn build_state() -> AdminState { let handle = SnapshotHandle::new(AisixSnapshot::new()); - let state = AdminState::new(handle, &cfg()); - let app = build_router(state); + let store = InMemoryStore::new() as Arc; + AdminState::new(handle, store, &cfg()) + } - let resp = app - .oneshot( - Request::builder() - .uri("/health") - .body(axum::body::Body::empty()) - .unwrap(), - ) - .await + fn model_payload(name: &str) -> Value { + json!({ + "name": name, + "model": "openai/gpt-4o", + "provider_config": {"api_key": "sk-x"} + }) + } + + fn apikey_payload(key: &str, allowed: &[&str]) -> Value { + json!({"key": key, "allowed_models": allowed}) + } + + fn auth_req(method: &str, uri: &str, body: Option) -> Request { + let body = match body { + Some(v) => Body::from(v.to_string()), + None => Body::empty(), + }; + Request::builder() + .method(method) + .uri(uri) + .header("authorization", "Bearer admin-secret") + .header("content-type", "application/json") + .body(body) + .unwrap() + } + + async fn run(app: Router, req: Request) -> axum::http::Response { + app.oneshot(req).await.unwrap() + } + + async fn body_json(resp: axum::http::Response) -> Value { + let bytes = to_bytes(resp.into_body(), 65536).await.unwrap(); + serde_json::from_slice(&bytes).unwrap() + } + + #[tokio::test] + async fn health_reports_snapshot_counts() { + let app = build_router(build_state()); + let req = Request::builder() + .uri("/health") + .body(Body::empty()) .unwrap(); + let resp = run(app, req).await; assert_eq!(resp.status(), StatusCode::OK); - - let bytes = to_bytes(resp.into_body(), 1024).await.unwrap(); - let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let v = body_json(resp).await; assert_eq!(v["status"], "ok"); } - #[test] - fn admin_state_clones_admin_keys_into_arc() { - let handle = SnapshotHandle::new(AisixSnapshot::new()); - let state = AdminState::new(handle, &cfg()); - let b = state.clone(); - assert_eq!(b.admin_keys.len(), 1); - assert_eq!(&*b.admin_keys[0], "k1"); + #[tokio::test] + async fn create_model_returns_entry_with_generated_id() { + let app = build_router(build_state()); + let resp = run( + app, + auth_req("POST", "/admin/v1/models", Some(model_payload("my-gpt4"))), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let v = body_json(resp).await; + assert!(!v["id"].as_str().unwrap().is_empty()); + assert_eq!(v["revision"], 1); + assert_eq!(v["value"]["name"], "my-gpt4"); + } + + #[tokio::test] + async fn create_model_without_auth_is_401() { + let app = build_router(build_state()); + let req = Request::builder() + .method("POST") + .uri("/admin/v1/models") + .header("content-type", "application/json") + .body(Body::from(model_payload("m").to_string())) + .unwrap(); + let resp = run(app, req).await; + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let v = body_json(resp).await; + // Spec §3 admin envelope — {"error_msg": "..."}. + assert!(v["error_msg"].is_string()); + assert!(v.get("error").is_none()); + } + + #[tokio::test] + async fn create_model_with_wrong_admin_key_is_401() { + let app = build_router(build_state()); + let req = Request::builder() + .method("POST") + .uri("/admin/v1/models") + .header("authorization", "Bearer wrong") + .header("content-type", "application/json") + .body(Body::from(model_payload("m").to_string())) + .unwrap(); + let resp = run(app, req).await; + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn create_model_with_invalid_provider_prefix_is_400_schema_error() { + let app = build_router(build_state()); + let body = json!({ + "name": "x", + "model": "mistral/large", + "provider_config": {"api_key": "sk-x"} + }); + let resp = run(app, auth_req("POST", "/admin/v1/models", Some(body))).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let v = body_json(resp).await; + assert!(v["error_msg"] + .as_str() + .unwrap() + .contains("schema validation")); + } + + #[tokio::test] + async fn duplicate_model_name_on_create_is_409() { + let state = build_state(); + let app = build_router(state.clone()); + let _ = run( + app, + auth_req("POST", "/admin/v1/models", Some(model_payload("dup"))), + ) + .await; + let app = build_router(state); + let resp = run( + app, + auth_req("POST", "/admin/v1/models", Some(model_payload("dup"))), + ) + .await; + assert_eq!(resp.status(), StatusCode::CONFLICT); + } + + #[tokio::test] + async fn list_models_returns_created_entries() { + let state = build_state(); + let app = build_router(state.clone()); + let _ = run( + app, + auth_req("POST", "/admin/v1/models", Some(model_payload("foo"))), + ) + .await; + let app = build_router(state.clone()); + let _ = run( + app, + auth_req("POST", "/admin/v1/models", Some(model_payload("bar"))), + ) + .await; + let app = build_router(state); + let resp = run(app, auth_req("GET", "/admin/v1/models", None)).await; + assert_eq!(resp.status(), StatusCode::OK); + let v = body_json(resp).await; + assert_eq!(v.as_array().unwrap().len(), 2); + } + + #[tokio::test] + async fn get_model_round_trip() { + let state = build_state(); + let app = build_router(state.clone()); + let resp = run( + app, + auth_req("POST", "/admin/v1/models", Some(model_payload("foo"))), + ) + .await; + let created = body_json(resp).await; + let id = created["id"].as_str().unwrap(); + + let app = build_router(state); + let resp = run( + app, + auth_req("GET", &format!("/admin/v1/models/{id}"), None), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let v = body_json(resp).await; + assert_eq!(v["value"]["name"], "foo"); + } + + #[tokio::test] + async fn get_model_missing_is_404() { + let app = build_router(build_state()); + let resp = run(app, auth_req("GET", "/admin/v1/models/nonexistent", None)).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn update_model_bumps_revision_and_persists_changes() { + let state = build_state(); + let app = build_router(state.clone()); + let resp = run( + app, + auth_req("POST", "/admin/v1/models", Some(model_payload("foo"))), + ) + .await; + let id = body_json(resp).await["id"].as_str().unwrap().to_string(); + + // Change provider upstream. + let updated_body = json!({ + "name": "foo", + "model": "anthropic/claude-sonnet-4-5", + "provider_config": {"api_key": "sk-ant"} + }); + let app = build_router(state); + let resp = run( + app, + auth_req("PUT", &format!("/admin/v1/models/{id}"), Some(updated_body)), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let v = body_json(resp).await; + assert_eq!(v["revision"], 2); + assert_eq!(v["value"]["model"], "anthropic/claude-sonnet-4-5"); + } + + #[tokio::test] + async fn update_model_renaming_to_existing_name_is_409() { + let state = build_state(); + let app = build_router(state.clone()); + let _ = run( + app, + auth_req("POST", "/admin/v1/models", Some(model_payload("foo"))), + ) + .await; + let app = build_router(state.clone()); + let resp = run( + app, + auth_req("POST", "/admin/v1/models", Some(model_payload("bar"))), + ) + .await; + let bar_id = body_json(resp).await["id"].as_str().unwrap().to_string(); + + // Try to rename "bar" -> "foo". + let app = build_router(state); + let resp = run( + app, + auth_req( + "PUT", + &format!("/admin/v1/models/{bar_id}"), + Some(model_payload("foo")), + ), + ) + .await; + assert_eq!(resp.status(), StatusCode::CONFLICT); + } + + #[tokio::test] + async fn update_model_keeping_own_name_is_allowed() { + let state = build_state(); + let app = build_router(state.clone()); + let resp = run( + app, + auth_req("POST", "/admin/v1/models", Some(model_payload("foo"))), + ) + .await; + let id = body_json(resp).await["id"].as_str().unwrap().to_string(); + + let app = build_router(state); + let resp = run( + app, + auth_req( + "PUT", + &format!("/admin/v1/models/{id}"), + Some(model_payload("foo")), + ), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn delete_model_is_204_esque_and_subsequent_get_is_404() { + let state = build_state(); + let app = build_router(state.clone()); + let resp = run( + app, + auth_req("POST", "/admin/v1/models", Some(model_payload("foo"))), + ) + .await; + let id = body_json(resp).await["id"].as_str().unwrap().to_string(); + + let app = build_router(state.clone()); + let resp = run( + app, + auth_req("DELETE", &format!("/admin/v1/models/{id}"), None), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + let app = build_router(state); + let resp = run( + app, + auth_req("GET", &format!("/admin/v1/models/{id}"), None), + ) + .await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn delete_missing_model_is_404() { + let app = build_router(build_state()); + let resp = run(app, auth_req("DELETE", "/admin/v1/models/missing-id", None)).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn apikey_crud_follows_the_same_flow() { + let state = build_state(); + + // Create. + let app = build_router(state.clone()); + let resp = run( + app, + auth_req( + "POST", + "/admin/v1/apikeys", + Some(apikey_payload("sk-user-1", &["my-gpt4"])), + ), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let id = body_json(resp).await["id"].as_str().unwrap().to_string(); + + // Duplicate key rejected. + let app = build_router(state.clone()); + let resp = run( + app, + auth_req( + "POST", + "/admin/v1/apikeys", + Some(apikey_payload("sk-user-1", &["*"])), + ), + ) + .await; + assert_eq!(resp.status(), StatusCode::CONFLICT); + + // List sees exactly one. + let app = build_router(state.clone()); + let resp = run(app, auth_req("GET", "/admin/v1/apikeys", None)).await; + assert_eq!(body_json(resp).await.as_array().unwrap().len(), 1); + + // Delete. + let app = build_router(state); + let resp = run( + app, + auth_req("DELETE", &format!("/admin/v1/apikeys/{id}"), None), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); } } diff --git a/crates/aisix-admin/src/models_handlers.rs b/crates/aisix-admin/src/models_handlers.rs new file mode 100644 index 00000000..3ad960a4 --- /dev/null +++ b/crates/aisix-admin/src/models_handlers.rs @@ -0,0 +1,111 @@ +//! CRUD handlers for `/admin/v1/models`. +//! +//! Every mutating endpoint: +//! 1. validates the JSON body against the Model schema (aisix-core), +//! 2. rejects duplicate `name` against other resources in the store, +//! 3. persists via `ConfigStore`, +//! 4. returns the full `ResourceEntry` as JSON. +//! +//! ids are UUID v4s generated on POST; PUT preserves the existing id. + +use aisix_core::models::validate_model; +use aisix_core::resource::ResourceEntry; +use aisix_core::Model; +use axum::extract::{Path, State}; +use axum::Json; +use serde_json::Value; +use uuid::Uuid; + +use crate::auth::AdminAuth; +use crate::error::AdminError; +use crate::state::AdminState; + +const STARTING_REVISION: i64 = 1; + +pub async fn list_models( + _auth: AdminAuth, + State(state): State, +) -> Result>>, AdminError> { + let entries = state.store.list_models().await?; + Ok(Json(entries)) +} + +pub async fn get_model( + _auth: AdminAuth, + Path(id): Path, + State(state): State, +) -> Result>, AdminError> { + let entry = state + .store + .get_model(&id) + .await? + .ok_or(AdminError::NotFound)?; + Ok(Json(entry)) +} + +pub async fn create_model( + _auth: AdminAuth, + State(state): State, + Json(raw): Json, +) -> Result>, AdminError> { + let model = decode_model(&raw)?; + let all = state.store.list_models().await?; + assert_unique_name(&all, &model.name, None)?; + + let id = Uuid::new_v4().to_string(); + let entry = ResourceEntry::new(&id, model, STARTING_REVISION); + state.store.put_model(entry.clone()).await?; + Ok(Json(entry)) +} + +pub async fn update_model( + _auth: AdminAuth, + Path(id): Path, + State(state): State, + Json(raw): Json, +) -> Result>, AdminError> { + let existing = state + .store + .get_model(&id) + .await? + .ok_or(AdminError::NotFound)?; + let model = decode_model(&raw)?; + + let all = state.store.list_models().await?; + assert_unique_name(&all, &model.name, Some(&id))?; + + let entry = ResourceEntry::new(&id, model, existing.revision + 1); + state.store.put_model(entry.clone()).await?; + Ok(Json(entry)) +} + +pub async fn delete_model( + _auth: AdminAuth, + Path(id): Path, + State(state): State, +) -> Result, AdminError> { + let removed = state.store.delete_model(&id).await?; + if !removed { + return Err(AdminError::NotFound); + } + Ok(Json(serde_json::json!({"deleted": true, "id": id}))) +} + +fn decode_model(raw: &Value) -> Result { + validate_model(raw)?; + serde_json::from_value(raw.clone()) + .map_err(|e| AdminError::BadRequest(format!("malformed Model payload: {e}"))) +} + +fn assert_unique_name( + existing: &[ResourceEntry], + name: &str, + self_id: Option<&str>, +) -> Result<(), AdminError> { + for e in existing { + if e.value.name == name && self_id.is_none_or(|sid| sid != e.id) { + return Err(AdminError::Conflict(name.to_string())); + } + } + Ok(()) +} diff --git a/crates/aisix-admin/src/state.rs b/crates/aisix-admin/src/state.rs new file mode 100644 index 00000000..297403ad --- /dev/null +++ b/crates/aisix-admin/src/state.rs @@ -0,0 +1,37 @@ +//! Shared axum state for every admin handler. +//! +//! Holds: +//! - the bootstrap-config-provided `admin_keys` (auth) +//! - the `ConfigStore` trait object (CRUD backend) +//! - a `SnapshotHandle` for the /health endpoint (snapshot counts) +//! +//! The store is held behind an `Arc` so production can +//! wire an etcd-backed impl and tests can use `InMemoryStore` via the +//! same type. + +use aisix_core::snapshot::SnapshotHandle; +use aisix_core::{AdminConfig, AisixSnapshot}; +use std::sync::Arc; + +use crate::store::ConfigStore; + +#[derive(Clone)] +pub struct AdminState { + pub snapshot: SnapshotHandle, + pub admin_keys: Arc<[String]>, + pub store: Arc, +} + +impl AdminState { + pub fn new( + snapshot: SnapshotHandle, + store: Arc, + cfg: &AdminConfig, + ) -> Self { + Self { + snapshot, + admin_keys: Arc::from(cfg.admin_keys.clone()), + store, + } + } +} diff --git a/crates/aisix-admin/src/store.rs b/crates/aisix-admin/src/store.rs new file mode 100644 index 00000000..ae115947 --- /dev/null +++ b/crates/aisix-admin/src/store.rs @@ -0,0 +1,156 @@ +//! [`ConfigStore`] — the storage abstraction every admin handler reads +//! and writes through. +//! +//! Production wires an etcd-backed implementation (follow-up PR); tests +//! use [`InMemoryStore`]. The trait keeps CRUD minimal — orchestration +//! (schema validation, duplicate-name detection, uuid generation) belongs +//! in the handler layer so the store stays dumb and fast. + +use aisix_core::resource::ResourceEntry; +use aisix_core::{ApiKey, Model}; +use dashmap::DashMap; +use std::sync::Arc; + +#[derive(Debug, thiserror::Error)] +pub enum StoreError { + #[error("store backend failure: {0}")] + Backend(String), +} + +// `async_trait` macro is what makes `dyn ConfigStore` trait objects +// compile — bare `async fn` in traits isn't dyn-compatible today. +#[async_trait::async_trait] +pub trait ConfigStore: Send + Sync + 'static { + async fn put_model(&self, entry: ResourceEntry) -> Result<(), StoreError>; + async fn get_model(&self, id: &str) -> Result>, StoreError>; + async fn list_models(&self) -> Result>, StoreError>; + async fn delete_model(&self, id: &str) -> Result; + + async fn put_apikey(&self, entry: ResourceEntry) -> Result<(), StoreError>; + async fn get_apikey(&self, id: &str) -> Result>, StoreError>; + async fn list_apikeys(&self) -> Result>, StoreError>; + async fn delete_apikey(&self, id: &str) -> Result; +} + +/// In-memory store. Thread-safe via DashMap; mainly used by tests, but +/// also a viable fallback for single-process development runs. +#[derive(Debug, Default)] +pub struct InMemoryStore { + models: DashMap>, + apikeys: DashMap>, +} + +impl InMemoryStore { + pub fn new() -> Arc { + Arc::new(Self::default()) + } +} + +#[async_trait::async_trait] +impl ConfigStore for InMemoryStore { + async fn put_model(&self, entry: ResourceEntry) -> Result<(), StoreError> { + self.models.insert(entry.id.clone(), entry); + Ok(()) + } + + async fn get_model(&self, id: &str) -> Result>, StoreError> { + Ok(self.models.get(id).map(|r| r.clone())) + } + + async fn list_models(&self) -> Result>, StoreError> { + Ok(self.models.iter().map(|r| r.clone()).collect()) + } + + async fn delete_model(&self, id: &str) -> Result { + Ok(self.models.remove(id).is_some()) + } + + async fn put_apikey(&self, entry: ResourceEntry) -> Result<(), StoreError> { + self.apikeys.insert(entry.id.clone(), entry); + Ok(()) + } + + async fn get_apikey(&self, id: &str) -> Result>, StoreError> { + Ok(self.apikeys.get(id).map(|r| r.clone())) + } + + async fn list_apikeys(&self) -> Result>, StoreError> { + Ok(self.apikeys.iter().map(|r| r.clone()).collect()) + } + + async fn delete_apikey(&self, id: &str) -> Result { + Ok(self.apikeys.remove(id).is_some()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_model(name: &str) -> Model { + let cfg = format!( + r#"{{ + "name": "{name}", + "model": "openai/gpt-4o", + "provider_config": {{"api_key": "sk-x"}} + }}"# + ); + serde_json::from_str(&cfg).unwrap() + } + + #[tokio::test] + async fn in_memory_put_get_roundtrips() { + let store = InMemoryStore::new(); + let entry = ResourceEntry::new("m-1", sample_model("foo"), 1); + store.put_model(entry.clone()).await.unwrap(); + let got = store.get_model("m-1").await.unwrap().unwrap(); + assert_eq!(got.id, "m-1"); + assert_eq!(got.value.name, "foo"); + } + + #[tokio::test] + async fn in_memory_list_returns_all_entries() { + let store = InMemoryStore::new(); + store + .put_model(ResourceEntry::new("m-1", sample_model("foo"), 1)) + .await + .unwrap(); + store + .put_model(ResourceEntry::new("m-2", sample_model("bar"), 2)) + .await + .unwrap(); + let all = store.list_models().await.unwrap(); + assert_eq!(all.len(), 2); + } + + #[tokio::test] + async fn in_memory_delete_returns_false_when_absent() { + let store = InMemoryStore::new(); + assert!(!store.delete_model("missing").await.unwrap()); + store + .put_model(ResourceEntry::new("m-1", sample_model("foo"), 1)) + .await + .unwrap(); + assert!(store.delete_model("m-1").await.unwrap()); + assert!(store.get_model("m-1").await.unwrap().is_none()); + } + + #[tokio::test] + async fn models_and_apikeys_share_store_without_collision() { + let store = InMemoryStore::new(); + store + .put_model(ResourceEntry::new("shared-id", sample_model("m"), 1)) + .await + .unwrap(); + + let apikey: ApiKey = + serde_json::from_str(r#"{"key":"sk-k","allowed_models":["m"]}"#).unwrap(); + store + .put_apikey(ResourceEntry::new("shared-id", apikey, 1)) + .await + .unwrap(); + + assert!(store.get_model("shared-id").await.unwrap().is_some()); + assert!(store.get_apikey("shared-id").await.unwrap().is_some()); + } +} diff --git a/crates/aisix-server/src/main.rs b/crates/aisix-server/src/main.rs index 2bb49e3c..1150b614 100644 --- a/crates/aisix-server/src/main.rs +++ b/crates/aisix-server/src/main.rs @@ -15,7 +15,7 @@ use std::path::PathBuf; use std::sync::Arc; -use aisix_admin::AdminState; +use aisix_admin::{AdminState, ConfigStore, InMemoryStore}; use aisix_core::models::Provider; use aisix_core::Config; use aisix_etcd::{EtcdConfigProvider, Supervisor}; @@ -73,8 +73,14 @@ async fn run(cfg: Config) -> anyhow::Result<()> { hub.clone(), &cfg.proxy, )); - let admin_router = - aisix_admin::build_router(AdminState::new(snapshot_handle.clone(), &cfg.admin)); + // Admin CRUD uses an in-memory store for now; an etcd-backed store + // lands in a follow-up PR. + let admin_store: Arc = InMemoryStore::new(); + let admin_router = aisix_admin::build_router(AdminState::new( + snapshot_handle.clone(), + admin_store, + &cfg.admin, + )); // Step 9: bind + serve. let proxy_addr: std::net::SocketAddr = cfg.proxy.addr.parse()?;