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
2 changes: 2 additions & 0 deletions Cargo.lock

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

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ etcd-client = { version = "0.14", features = ["tls"] }
# Config
config = { version = "0.14", default-features = false, features = ["yaml", "toml", "json"] }
clap = { version = "4.5", features = ["derive", "env"] }
# Standalone resources-file source (aisix-core::filesource). Same YAML
# parser the `config` dependency already pulls in, so this adds no new
# parser to the dependency tree.
yaml-rust2 = "0.8"

# Time / IDs
chrono = { version = "0.4", features = ["serde"] }
Expand Down
10 changes: 10 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ etcd:
# # Defaults to the hostname portion of endpoints[0].
# # domain_name: "etcd.aisix.cloud"

# Standalone file-based resource source — the alternative to etcd for a
# single-container gateway. When set, every resource (provider keys,
# models, API keys, guardrails, MCP servers, A2A agents, cache policies,
# observability exporters, rate-limit policies) is loaded from this one
# YAML file at boot and re-read on SIGHUP; the `etcd` section above must
# then be removed (the two sources are mutually exclusive), and the
# admin listener serves the resource endpoints read-only. Validate a
# file without booting via `aisix validate --resources <file>`.
# resources_file: "/etc/aisix/resources.yaml"

proxy:
addr: "0.0.0.0:3000"
request_body_limit_bytes: 10485760 # 10 MiB
Expand Down
54 changes: 44 additions & 10 deletions crates/aisix-admin/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,27 @@ where
type Rejection = AdminError;

async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
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 {
if !is_admin_authorized(&parts.headers, &admin_state.admin_keys) {
return Err(AdminError::Unauthorized);
}
Ok(AdminAuth)
}
}

fn extract_bearer(parts: &Parts) -> Result<String, AdminError> {
if let Some(auth) = parts.headers.get(axum::http::header::AUTHORIZATION) {
/// Header-level admin-key check shared by the extractor above and by
/// router-layer middleware (which runs *before* per-handler extractors
/// and therefore cannot use `AdminAuth` directly). True iff the request
/// carries a valid admin key.
pub(crate) fn is_admin_authorized(headers: &axum::http::HeaderMap, admin_keys: &[String]) -> bool {
match extract_bearer(headers) {
Ok(token) => admin_keys.iter().any(|k| k == &token),
Err(_) => false,
}
}

fn extract_bearer(headers: &axum::http::HeaderMap) -> Result<String, AdminError> {
if let Some(auth) = 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();
Expand All @@ -51,7 +60,7 @@ fn extract_bearer(parts: &Parts) -> Result<String, AdminError> {
}
return Err(AdminError::Unauthorized);
}
if let Some(raw) = parts.headers.get("x-api-key") {
if let Some(raw) = headers.get("x-api-key") {
let s = raw.to_str().map_err(|_| AdminError::Unauthorized)?;
let s = s.trim();
if s.is_empty() {
Expand Down Expand Up @@ -80,20 +89,26 @@ mod tests {
axum::http::header::AUTHORIZATION,
HeaderValue::from_static("Bearer admin-secret"),
);
assert_eq!(extract_bearer(&parts_with(h)).unwrap(), "admin-secret");
assert_eq!(
extract_bearer(&parts_with(h).headers).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");
assert_eq!(
extract_bearer(&parts_with(h).headers).unwrap(),
"admin-secret"
);
}

#[test]
fn extract_bearer_rejects_missing_and_wrong_scheme() {
assert!(matches!(
extract_bearer(&parts_with(HeaderMap::new())),
extract_bearer(&parts_with(HeaderMap::new()).headers),
Err(AdminError::Unauthorized)
));

Expand All @@ -103,8 +118,27 @@ mod tests {
HeaderValue::from_static("Basic Zm9v"),
);
assert!(matches!(
extract_bearer(&parts_with(h)),
extract_bearer(&parts_with(h).headers),
Err(AdminError::Unauthorized)
));
}

#[test]
fn is_admin_authorized_checks_key_membership() {
let keys = vec!["admin-secret".to_string()];
let mut h = HeaderMap::new();
h.insert(
axum::http::header::AUTHORIZATION,
HeaderValue::from_static("Bearer admin-secret"),
);
assert!(is_admin_authorized(&h, &keys));

let mut wrong = HeaderMap::new();
wrong.insert(
axum::http::header::AUTHORIZATION,
HeaderValue::from_static("Bearer nope"),
);
assert!(!is_admin_authorized(&wrong, &keys));
assert!(!is_admin_authorized(&HeaderMap::new(), &keys));
}
}
13 changes: 11 additions & 2 deletions crates/aisix-admin/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ pub enum AdminError {
NotFound,
#[error("name {0:?} already in use by another resource")]
Conflict(String),
/// Resource writes are refused because this gateway loads its
/// resources from a declarative file. 409, like [`Self::Conflict`]:
/// the request is well-formed but conflicts with how the resource
/// set is managed.
#[error("{0}")]
FileManaged(String),
#[error("schema validation failed at {path}: {message}")]
Schema { path: String, message: String },
#[error("store error: {0}")]
Expand All @@ -39,7 +45,7 @@ impl AdminError {
AdminError::Unauthorized => StatusCode::UNAUTHORIZED,
AdminError::BadRequest(_) | AdminError::Schema { .. } => StatusCode::BAD_REQUEST,
AdminError::NotFound => StatusCode::NOT_FOUND,
AdminError::Conflict(_) => StatusCode::CONFLICT,
AdminError::Conflict(_) | AdminError::FileManaged(_) => StatusCode::CONFLICT,
AdminError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
Expand All @@ -56,7 +62,10 @@ impl From<SchemaError> for AdminError {

impl From<StoreError> for AdminError {
fn from(e: StoreError) -> Self {
AdminError::Store(e.to_string())
match e {
StoreError::ReadOnly(msg) => AdminError::FileManaged(msg),
other => AdminError::Store(other.to_string()),
}
}
}

Expand Down
171 changes: 171 additions & 0 deletions crates/aisix-admin/src/file_store.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
//! [`FileManagedStore`] — the [`ConfigStore`] the admin listener uses
//! when the gateway loads its resources from a file
//! (`resources_file` in config.yaml) instead of etcd.
//!
//! Reads are served from the live snapshot (the same one the proxy
//! reads), so `GET` lists / gets reflect the loaded file — including
//! SIGHUP reloads — without a second storage backend. Every write
//! returns [`StoreError::ReadOnly`], which the HTTP layer maps to a
//! 409 telling the operator to edit the file and reload. A router-level
//! guard in `build_router` rejects write requests before handler logic
//! runs; these store errors are the defense-in-depth backstop for any
//! non-HTTP caller.

use aisix_core::resource::Resource;
use aisix_core::resource::ResourceEntry;
use aisix_core::snapshot::{ResourceTable, SnapshotHandle};
use aisix_core::{
A2aAgent, AisixSnapshot, ApiKey, CachePolicy, Guardrail, McpServer, Model,
ObservabilityExporter, ProviderKey,
};

use crate::store::{ConfigStore, StoreError};

/// Read-only [`ConfigStore`] over the file-loaded snapshot.
pub struct FileManagedStore {
snapshot: SnapshotHandle<AisixSnapshot>,
resources_path: String,
}

impl FileManagedStore {
pub fn new(snapshot: SnapshotHandle<AisixSnapshot>, resources_path: impl Into<String>) -> Self {
Self {
snapshot,
resources_path: resources_path.into(),
}
}

/// The message every write path returns. Includes the file path so
/// the operator knows exactly what to edit.
pub fn read_only_message(resources_path: &str) -> String {
format!(
"resources are file-managed: this gateway loads its resources from \
{resources_path}; edit that file and send SIGHUP to reload instead of \
using the resource write API"
)
}

fn read_only(&self) -> StoreError {
StoreError::ReadOnly(Self::read_only_message(&self.resources_path))
}

fn get_from<T: Resource + Clone>(
&self,
table: fn(&AisixSnapshot) -> &ResourceTable<T>,
id: &str,
) -> Option<ResourceEntry<T>> {
table(&self.snapshot.load())
.get_by_id(id)
.map(|e| (*e).clone())
}

fn list_from<T: Resource + Clone>(
&self,
table: fn(&AisixSnapshot) -> &ResourceTable<T>,
) -> Vec<ResourceEntry<T>> {
table(&self.snapshot.load())
.entries()
.into_iter()
.map(|e| (*e).clone())
.collect()
}
}

macro_rules! impl_file_managed_store {
($( { $ty:ty, $table:ident, $put:ident, $get:ident, $list:ident, $delete:ident } )+) => {
#[async_trait::async_trait]
impl ConfigStore for FileManagedStore {
$(
async fn $put(&self, _entry: ResourceEntry<$ty>) -> Result<(), StoreError> {
Err(self.read_only())
}

async fn $get(&self, id: &str) -> Result<Option<ResourceEntry<$ty>>, StoreError> {
Ok(self.get_from(|s| &s.$table, id))
}

async fn $list(&self) -> Result<Vec<ResourceEntry<$ty>>, StoreError> {
Ok(self.list_from(|s| &s.$table))
}

async fn $delete(&self, _id: &str) -> Result<bool, StoreError> {
Err(self.read_only())
}
)+
}
};
}

impl_file_managed_store! {
{ Model, models, put_model, get_model, list_models, delete_model }
{ ApiKey, apikeys, put_apikey, get_apikey, list_apikeys, delete_apikey }
{ ProviderKey, provider_keys, put_provider_key, get_provider_key, list_provider_keys, delete_provider_key }
{ Guardrail, guardrails, put_guardrail, get_guardrail, list_guardrails, delete_guardrail }
{ CachePolicy, cache_policies, put_cache_policy, get_cache_policy, list_cache_policies, delete_cache_policy }
{ ObservabilityExporter, observability_exporters, put_observability_exporter, get_observability_exporter, list_observability_exporters, delete_observability_exporter }
{ McpServer, mcp_servers, put_mcp_server, get_mcp_server, list_mcp_servers, delete_mcp_server }
{ A2aAgent, a2a_agents, put_a2a_agent, get_a2a_agent, list_a2a_agents, delete_a2a_agent }
}

#[cfg(test)]
mod tests {
use super::*;

fn snapshot_with_model() -> SnapshotHandle<AisixSnapshot> {
let snap = AisixSnapshot::new();
let model: Model = serde_json::from_str(
r#"{
"display_name": "file-model",
"provider": "openai",
"model_name": "gpt-4o",
"provider_key_id": "11111111-1111-1111-1111-111111111111"
}"#,
)
.unwrap();
snap.models.insert(ResourceEntry::new("m-1", model, 1));
SnapshotHandle::new(snap)
}

#[tokio::test]
async fn reads_serve_the_live_snapshot() {
let handle = snapshot_with_model();
let store = FileManagedStore::new(handle.clone(), "/etc/aisix/resources.yaml");

let listed = store.list_models().await.unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].value.display_name, "file-model");

let got = store.get_model("m-1").await.unwrap().unwrap();
assert_eq!(got.id, "m-1");
assert!(store.get_model("missing").await.unwrap().is_none());

// A snapshot swap (SIGHUP reload) is immediately visible.
handle.store(AisixSnapshot::new());
assert!(store.list_models().await.unwrap().is_empty());
}

#[tokio::test]
async fn writes_and_deletes_are_read_only_errors_naming_the_file() {
let store = FileManagedStore::new(snapshot_with_model(), "/etc/aisix/resources.yaml");
let entry = store.get_model("m-1").await.unwrap().unwrap();

let err = store.put_model(entry).await.unwrap_err();
match &err {
StoreError::ReadOnly(msg) => {
assert!(msg.contains("file-managed"), "{msg}");
assert!(msg.contains("/etc/aisix/resources.yaml"), "{msg}");
assert!(msg.contains("SIGHUP"), "{msg}");
}
other => panic!("expected ReadOnly, got {other:?}"),
}
assert!(matches!(
store.delete_model("m-1").await.unwrap_err(),
StoreError::ReadOnly(_)
));
// Spot-check a second kind so the macro expansion is covered.
assert!(matches!(
store.delete_guardrail("g-1").await.unwrap_err(),
StoreError::ReadOnly(_)
));
}
}
Loading
Loading