Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ futures-util = "0.3"
async-trait = "0.1"

# HTTP server
axum = { version = "0.7", features = ["macros", "ws", "tracing"] }
axum = { version = "0.7", features = ["macros", "ws", "tracing", "multipart"] }
axum-server = { version = "0.7", features = ["tls-rustls"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["trace", "cors", "limit", "compression-gzip"] }
Expand All @@ -47,7 +47,7 @@ mime = "0.3"
mime_guess = "2.0"

# HTTP client
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "gzip"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "gzip", "multipart"] }
eventsource-stream = "0.2"

# TLS
Expand Down
5 changes: 5 additions & 0 deletions crates/aisix-admin/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ async-trait.workspace = true
dashmap.workspace = true
etcd-client.workspace = true
aisix-obs = { path = "../aisix-obs" }
chrono.workspace = true

[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt"] }
testcontainers.workspace = true
aisix-gateway = { path = "../aisix-gateway" }
aisix-provider-openai = { path = "../aisix-provider-openai" }
aisix-core = { path = "../aisix-core" }
wiremock.workspace = true
32 changes: 32 additions & 0 deletions crates/aisix-admin/src/apikeys_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
//! 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.
//!
//! Also provides key rotation: `POST /admin/v1/apikeys/:id/rotate`
//! replaces the `key` field with a freshly-generated `sk-*` value and
//! bumps the revision, invalidating the old credential.

use aisix_core::models::validate_apikey;
use aisix_core::resource::ResourceEntry;
Expand Down Expand Up @@ -88,6 +92,34 @@ pub async fn delete_apikey(
Ok(Json(serde_json::json!({"deleted": true, "id": id})))
}

/// `POST /admin/v1/apikeys/:id/rotate`
///
/// Replaces the `key` field with a new `sk-<uuid>` value, bumps the
/// revision, and returns the updated entry. The old key stops working as
/// soon as the etcd watch propagates the new snapshot (≤ 500 ms).
pub async fn rotate_apikey(
_auth: AdminAuth,
Path(id): Path<String>,
State(state): State<AdminState>,
) -> Result<Json<ResourceEntry<ApiKey>>, AdminError> {
let existing = state
.store
.get_apikey(&id)
.await?
.ok_or(AdminError::NotFound)?;

// Generate a new key: `sk-` prefix + first segment of a UUID v4 gives
// a 12-hex-char suffix that's unguessable yet short.
let new_key = format!("sk-{}", Uuid::new_v4().as_simple());

let mut updated = existing.value.clone();
updated.key = new_key;

let entry = ResourceEntry::new(&id, updated, existing.revision + 1);
state.store.put_apikey(entry.clone()).await?;
Ok(Json(entry))
}

fn decode_apikey(raw: &Value) -> Result<ApiKey, AdminError> {
validate_apikey(raw)?;
serde_json::from_value(raw.clone())
Expand Down
103 changes: 103 additions & 0 deletions crates/aisix-admin/src/budgets_handlers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
//! CRUD handlers for `/admin/v1/budgets`.

use aisix_core::models::validate_budget;
use aisix_core::resource::ResourceEntry;
use aisix_core::Budget;
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_budgets(
_auth: AdminAuth,
State(state): State<AdminState>,
) -> Result<Json<Vec<ResourceEntry<Budget>>>, AdminError> {
let entries = state.store.list_budgets().await?;
Ok(Json(entries))
}

pub async fn get_budget(
_auth: AdminAuth,
Path(id): Path<String>,
State(state): State<AdminState>,
) -> Result<Json<ResourceEntry<Budget>>, AdminError> {
let entry = state
.store
.get_budget(&id)
.await?
.ok_or(AdminError::NotFound)?;
Ok(Json(entry))
}

pub async fn create_budget(
_auth: AdminAuth,
State(state): State<AdminState>,
Json(raw): Json<Value>,
) -> Result<Json<ResourceEntry<Budget>>, AdminError> {
let budget = decode(&raw)?;
let all = state.store.list_budgets().await?;
assert_unique_name(&all, &budget.name, None)?;

let id = Uuid::new_v4().to_string();
let entry = ResourceEntry::new(&id, budget, STARTING_REVISION);
state.store.put_budget(entry.clone()).await?;
Ok(Json(entry))
}

pub async fn update_budget(
_auth: AdminAuth,
Path(id): Path<String>,
State(state): State<AdminState>,
Json(raw): Json<Value>,
) -> Result<Json<ResourceEntry<Budget>>, AdminError> {
let existing = state
.store
.get_budget(&id)
.await?
.ok_or(AdminError::NotFound)?;
let budget = decode(&raw)?;

let all = state.store.list_budgets().await?;
assert_unique_name(&all, &budget.name, Some(&id))?;

let entry = ResourceEntry::new(&id, budget, existing.revision + 1);
state.store.put_budget(entry.clone()).await?;
Ok(Json(entry))
}

pub async fn delete_budget(
_auth: AdminAuth,
Path(id): Path<String>,
State(state): State<AdminState>,
) -> Result<Json<Value>, AdminError> {
let removed = state.store.delete_budget(&id).await?;
if !removed {
return Err(AdminError::NotFound);
}
Ok(Json(serde_json::json!({"deleted": true, "id": id})))
}

fn decode(raw: &Value) -> Result<Budget, AdminError> {
validate_budget(raw)?;
serde_json::from_value(raw.clone())
.map_err(|e| AdminError::BadRequest(format!("malformed Budget payload: {e}")))
}

fn assert_unique_name(
existing: &[ResourceEntry<Budget>],
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(())
}
107 changes: 107 additions & 0 deletions crates/aisix-admin/src/credentials_handlers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//! CRUD handlers for `/admin/v1/credentials`.
//!
//! Same shape as the Models / ApiKeys handlers: validate against the
//! JSON schema, reject duplicate names (409), generate a uuid v4 on
//! POST, bump revision on PUT.

use aisix_core::models::validate_credential;
use aisix_core::resource::ResourceEntry;
use aisix_core::Credential;
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_credentials(
_auth: AdminAuth,
State(state): State<AdminState>,
) -> Result<Json<Vec<ResourceEntry<Credential>>>, AdminError> {
let entries = state.store.list_credentials().await?;
Ok(Json(entries))
}

pub async fn get_credential(
_auth: AdminAuth,
Path(id): Path<String>,
State(state): State<AdminState>,
) -> Result<Json<ResourceEntry<Credential>>, AdminError> {
let entry = state
.store
.get_credential(&id)
.await?
.ok_or(AdminError::NotFound)?;
Ok(Json(entry))
}

pub async fn create_credential(
_auth: AdminAuth,
State(state): State<AdminState>,
Json(raw): Json<Value>,
) -> Result<Json<ResourceEntry<Credential>>, AdminError> {
let credential = decode(&raw)?;
let all = state.store.list_credentials().await?;
assert_unique_name(&all, &credential.name, None)?;

let id = Uuid::new_v4().to_string();
let entry = ResourceEntry::new(&id, credential, STARTING_REVISION);
state.store.put_credential(entry.clone()).await?;
Ok(Json(entry))
}

pub async fn update_credential(
_auth: AdminAuth,
Path(id): Path<String>,
State(state): State<AdminState>,
Json(raw): Json<Value>,
) -> Result<Json<ResourceEntry<Credential>>, AdminError> {
let existing = state
.store
.get_credential(&id)
.await?
.ok_or(AdminError::NotFound)?;
let credential = decode(&raw)?;

let all = state.store.list_credentials().await?;
assert_unique_name(&all, &credential.name, Some(&id))?;

let entry = ResourceEntry::new(&id, credential, existing.revision + 1);
state.store.put_credential(entry.clone()).await?;
Ok(Json(entry))
}

pub async fn delete_credential(
_auth: AdminAuth,
Path(id): Path<String>,
State(state): State<AdminState>,
) -> Result<Json<Value>, AdminError> {
let removed = state.store.delete_credential(&id).await?;
if !removed {
return Err(AdminError::NotFound);
}
Ok(Json(serde_json::json!({"deleted": true, "id": id})))
}

fn decode(raw: &Value) -> Result<Credential, AdminError> {
validate_credential(raw)?;
serde_json::from_value(raw.clone())
.map_err(|e| AdminError::BadRequest(format!("malformed Credential payload: {e}")))
}

fn assert_unique_name(
existing: &[ResourceEntry<Credential>],
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(())
}
Loading
Loading