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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Surfaces and capabilities currently in main:

- **Managed-mode bootstrap** — cert-bundle path (no `/dp/register` round-trip): cp-api signs the mTLS leaf at mint time and ships PEMs as env vars at `docker run`. Snapshot persisted to `config_cache.json` so the proxy survives CP outages and restarts (offline resilience per PRD-09 §9.7.2).

- **Per-key budgets** — `ApiKey.max_budget_usd` inline cap; managed mode delegates evaluation to cp-api `/dp/budget_check` with a 5 s LRU on the DP side.
- **Per-key budgets** — enforced through the managed cp-api `/dp/budget_check` path with a 5 s LRU on the DP side. Standalone does not provide local budget resources or authoring.

## Workspace

Expand All @@ -77,15 +77,15 @@ crates/

The same binary runs both modes; the table is about **which surface owns the feature**, not about whether it works at all in one mode.

> A few resources (Budget, Team, Member/Role, Audit Log, Billing) belong to the SaaS control plane and are intentionally **absent** from the standalone DP — standalone uses inline per-key alternatives (`ApiKey.max_budget_usd`, `ApiKey.rate_limit`).
> A few resources (Budget, Team, Member/Role, Audit Log, Billing) belong to the SaaS control plane and are intentionally **absent** from the standalone DP. Standalone keeps per-key rate limiting; budget policy remains CP-owned.

| Capability | Standalone (DP only) | Managed (DP + AISIX-Cloud CP) |
|---|---|---|
| Configuration entry point | `/admin/v1/*` on `:3001`, static `admin_keys` bearer | Dashboard / cp-api `/api/*`, Better Auth session or PAT |
| Multi-tenant model | None — single instance, single namespace | Org → Team → Member → Environment hierarchy |
| ProviderKey storage | Plaintext `secret` in etcd (mTLS-only channel) | Master-key envelope-encrypted at rest, decrypted on projection |
| API key handling | Hash on create, plaintext shown once | Hash + masked / one-time reveal in dashboard, rotation flow |
| Budget enforcement | Per-ApiKey inline cap (`max_budget_usd`) | Per-ApiKey + per-ProviderKey + per-Environment + per-Org budgets, hard-stop / warn-only modes, alerts, audit |
| Budget enforcement | No standalone budget resource or hard-stop engine | Per-ApiKey + per-ProviderKey + per-Environment + per-Org budgets, hard-stop / warn-only modes, alerts, audit |
| Audit log | None | Full org-scoped audit with diff viewer, RBAC-gated views |
| RBAC / Roles | None — admin key is binary access | Org-scoped roles (owner / admin / developer / viewer), invitations |
| Auth for proxy clients | Inbound `ApiKey` only | Inbound `ApiKey` only (proxy contract is identical) |
Expand Down
76 changes: 65 additions & 11 deletions crates/aisix-admin/src/apikeys_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use aisix_core::resource::ResourceEntry;
use aisix_core::ApiKey;
use axum::extract::{Path, State};
use axum::Json;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;

Expand All @@ -23,48 +24,97 @@ use crate::state::AdminState;

const STARTING_REVISION: i64 = 1;

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct StandaloneApiKeyBody {
key_hash: String,
allowed_models: Vec<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
rate_limit: Option<aisix_core::models::RateLimit>,
}

#[derive(Debug, Clone, Serialize)]
pub struct PublicApiKey {
pub key_hash: String,
pub allowed_models: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rate_limit: Option<aisix_core::models::RateLimit>,
}

impl From<ApiKey> for PublicApiKey {
fn from(value: ApiKey) -> Self {
Self {
key_hash: value.key_hash,
allowed_models: value.allowed_models,
rate_limit: value.rate_limit,
}
}
}

#[derive(Debug, Clone, Serialize)]
pub struct PublicApiKeyEntry {
pub id: String,
pub value: PublicApiKey,
pub revision: i64,
}

impl From<ResourceEntry<ApiKey>> for PublicApiKeyEntry {
fn from(value: ResourceEntry<ApiKey>) -> Self {
Self {
id: value.id,
value: PublicApiKey::from(value.value),
revision: value.revision,
}
}
}

fn public_entry(entry: ResourceEntry<ApiKey>) -> PublicApiKeyEntry {
entry.into()
}

pub async fn list_apikeys(
_auth: AdminAuth,
State(state): State<AdminState>,
) -> Result<Json<Vec<ResourceEntry<ApiKey>>>, AdminError> {
) -> Result<Json<Vec<PublicApiKeyEntry>>, AdminError> {
let entries = state.store.list_apikeys().await?;
Ok(Json(entries))
Ok(Json(entries.into_iter().map(public_entry).collect()))
}

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

pub async fn create_apikey(
_auth: AdminAuth,
State(state): State<AdminState>,
Json(raw): Json<Value>,
) -> Result<Json<ResourceEntry<ApiKey>>, AdminError> {
) -> Result<Json<PublicApiKeyEntry>, AdminError> {
let apikey = decode_apikey(&raw)?;
let all = state.store.list_apikeys().await?;
assert_unique_key(&all, &apikey.key_hash, 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))
Ok(Json(public_entry(entry)))
}

pub async fn update_apikey(
_auth: AdminAuth,
Path(id): Path<String>,
State(state): State<AdminState>,
Json(raw): Json<Value>,
) -> Result<Json<ResourceEntry<ApiKey>>, AdminError> {
) -> Result<Json<PublicApiKeyEntry>, AdminError> {
let existing = state
.store
.get_apikey(&id)
Expand All @@ -77,7 +127,7 @@ pub async fn update_apikey(

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

pub async fn delete_apikey(
Expand Down Expand Up @@ -124,14 +174,18 @@ pub async fn rotate_apikey(
let entry = ResourceEntry::new(&id, updated, existing.revision + 1);
state.store.put_apikey(entry.clone()).await?;
Ok(Json(serde_json::json!({
"entry": entry,
"entry": public_entry(entry),
"plaintext": new_plaintext,
})))
}

fn decode_apikey(raw: &Value) -> Result<ApiKey, AdminError> {
validate_apikey(raw)?;
serde_json::from_value(raw.clone())
let body: StandaloneApiKeyBody = serde_json::from_value(raw.clone())
.map_err(|e| AdminError::BadRequest(format!("malformed ApiKey payload: {e}")))?;
let value = serde_json::to_value(&body)
.map_err(|e| AdminError::BadRequest(format!("malformed ApiKey payload: {e}")))?;
validate_apikey(&value)?;
serde_json::from_value(value)
.map_err(|e| AdminError::BadRequest(format!("malformed ApiKey payload: {e}")))
}

Expand Down
37 changes: 36 additions & 1 deletion crates/aisix-admin/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -705,7 +705,8 @@ mod tests {
// 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);
let listed = body_json(resp).await;
assert_eq!(listed.as_array().unwrap().len(), 1);

// Delete.
let app = build_router(state);
Expand All @@ -717,6 +718,40 @@ mod tests {
assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn create_apikey_rejects_unknown_field() {
let app = build_router(build_state());
let resp = run(
app,
auth_req(
"POST",
"/admin/v1/apikeys",
Some(json!({
"key_hash": aisix_core::ApiKey::hash_bearer("sk-budget"),
"allowed_models": ["*"],
"max_budget_usd": 500.0
})),
),
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let v = body_json(resp).await;
assert!(v["error_msg"].as_str().unwrap().contains("unknown field"));
}

#[tokio::test]
async fn openapi_apikey_schema_excludes_max_budget_usd() {
let resp = openapi::openapi_json().await;
let bytes = to_bytes(resp.into_body(), 65536).await.unwrap();
let parsed: serde_json::Value =
serde_json::from_slice(&bytes).expect("OPENAPI_JSON must parse");
let props = &parsed["components"]["schemas"]["ApiKey"]["properties"];
assert!(props["key_hash"].is_object());
assert!(props["allowed_models"].is_object());
assert!(props["rate_limit"].is_object());
assert!(props.get("max_budget_usd").is_none());
}

// ──────────────────── Guardrails CRUD ────────────────────

fn guardrail_payload(name: &str) -> Value {
Expand Down
3 changes: 1 addition & 2 deletions crates/aisix-admin/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,7 @@ const OPENAPI_JSON: &str = r##"{
"properties": {
"key_hash": {"type": "string", "description": "SHA-256 hex of the plaintext bearer. Lowercase.", "example": "91ed2dbc407561556f3e7be98ba0bd2a57986d6a868c482d867d19c6d40d201c"},
"allowed_models": {"type": "array", "items": {"type": "string"}, "description": "Allowed Model display_names. `[\"*\"]` for all; `[]` denies everything."},
"rate_limit": {"$ref": "#/components/schemas/RateLimit"},
"max_budget_usd": {"type": "number", "minimum": 0, "description": "Per-month USD spend cap. Absent = unlimited."}
"rate_limit": {"$ref": "#/components/schemas/RateLimit"}
}
},
"ApiKeyEntry": {
Expand Down
7 changes: 0 additions & 7 deletions crates/aisix-core/src/models/apikey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,6 @@ pub struct ApiKey {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rate_limit: Option<RateLimit>,

/// Maximum USD spend per calendar month. When the accumulated spend
/// for this key reaches or exceeds this cap the proxy returns 429.
/// Absent = no budget enforcement.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_budget_usd: Option<f64>,

/// etcd-key uuid; filled by the loader, never in the JSON payload.
#[serde(skip)]
pub(crate) runtime_id: String,
Expand Down Expand Up @@ -150,7 +144,6 @@ mod tests {
key_hash: "abc".into(),
allowed_models: vec![],
rate_limit: None,
max_budget_usd: None,
runtime_id: String::new(),
};
assert!(!k.can_access("my-gpt4"));
Expand Down
3 changes: 1 addition & 2 deletions crates/aisix-core/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@
//!
//! Team is intentionally absent: it's a SaaS-tier concept owned by
//! the AISIX-Cloud control plane, not by the standalone gateway.
//! Standalone deployments do per-key budgeting via
//! `ApiKey::max_budget_usd` and per-key rate-limiting via
//! Standalone deployments do per-key rate-limiting via
//! `ApiKey::rate_limit`.

pub mod apikey;
Expand Down
17 changes: 3 additions & 14 deletions crates/aisix-core/src/models/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,7 @@ fn apikey_schema() -> Value {
"type": "array",
"items": { "type": "string" }
},
"rate_limit": { "$ref": "#/$defs/rate_limit" },
"max_budget_usd": { "type": "number", "minimum": 0 }
"rate_limit": { "$ref": "#/$defs/rate_limit" }
},
"$defs": {
"rate_limit": {
Expand Down Expand Up @@ -505,21 +504,11 @@ mod tests {
}

#[test]
fn apikey_with_max_budget_usd_passes() {
let v = json!({
"key_hash":"9df37f5e7cbc3c391d872742b5f286c242e733a09add9eeaa4d26a599bd90b20",
"allowed_models":["a","b"],
"max_budget_usd": 500.0
});
validate_apikey(&v).unwrap();
}

#[test]
fn apikey_negative_max_budget_usd_rejected() {
fn apikey_unknown_field_rejected() {
let v = json!({
"key_hash":"9df37f5e7cbc3c391d872742b5f286c242e733a09add9eeaa4d26a599bd90b20",
"allowed_models":["a"],
"max_budget_usd": -1.0
"max_budget_usd": 500.0
});
assert!(validate_apikey(&v).is_err());
}
Expand Down
21 changes: 7 additions & 14 deletions docs/api-admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,13 +133,11 @@ curl -X POST http://localhost:3001/admin/v1/apikeys \
-d '{
"key_hash": "'"$KEY_HASH"'",
"allowed_models": ["my-gpt4"],
"rate_limit": {"rpm": 60, "concurrency": 10},
"max_budget_usd": 500.0
"rate_limit": {"rpm": 60, "concurrency": 10}
}'
```

> `max_budget_usd` is enforced only in SaaS / managed mode. See
> §4.4 for the standalone caveat and the SaaS propagation model.
> `max_budget_usd` is not part of the AISIX AI Gateway ApiKey schema.

The `/rotate` endpoint replaces the stored hash and returns the new
plaintext directly, so the operator does not need to compute the
Expand Down Expand Up @@ -178,12 +176,9 @@ curl -X POST http://localhost:3001/admin/v1/provider_keys \

### 4.4 Budgets

Per-ApiKey USD spend caps are expressed via `max_budget_usd` on the
ApiKey resource — there is no separate `/admin/v1/budgets`
collection. **Enforcement is a SaaS-tier feature**: the data-plane
proxy never reads `max_budget_usd` from the etcd snapshot. The
field is populated by the SaaS control plane for parity with its
own `api_keys` table and is informational on the DP side.
Per-key USD budget enforcement is a managed control-plane feature.
There is no standalone `/admin/v1/budgets` collection, and the
gateway ApiKey schema does not include `max_budget_usd`.

#### SaaS / Managed mode

Expand Down Expand Up @@ -218,10 +213,8 @@ same cp-api stay consistent without DP-side coordination.

#### Standalone mode

Budget enforcement is **not implemented**. The admin API still
accepts `max_budget_usd` on POST/PUT (the field passes through
schema validation and persists to etcd) so the wire shape stays
compatible with managed mode, but no part of the proxy reads it.
Budget enforcement is **not implemented**.

Operators who need per-key spend caps must run in managed mode.

Team-level budgets are SaaS-tier (cp-api owns cross-key
Expand Down
Loading
Loading