ac24ca766a
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 4s
CI / Deploy Dashboard (push) Has been cancelled
CI / Deploy Docs (push) Has been cancelled
CI / Deploy MCP (push) Has been cancelled
CI / Deploy Agent (push) Has been cancelled
GET /api/admin/tenants lists tenant DBs; DELETE /api/admin/tenants/{tenant_id} drops them (GDPR). Behind a separate auth path that rejects customer realm tokens.
116 lines
3.9 KiB
Rust
116 lines
3.9 KiB
Rust
//! Cross-tenant admin endpoints (`/api/v1/admin/*`).
|
|
//!
|
|
//! Operator-only. Auth is a **static bearer token** (`ADMIN_API_TOKEN`
|
|
//! env on the agent) — explicitly NOT a Keycloak JWT, because the
|
|
//! whole point of these endpoints is to operate ACROSS tenants. A
|
|
//! customer JWT (which always carries a single tenant_id) has no
|
|
//! business mounting them.
|
|
//!
|
|
//! Routes are only registered when `ADMIN_API_TOKEN` is set. With no
|
|
//! token, the endpoints don't exist at all (404), which is a stronger
|
|
//! guarantee than "401 if you guess the path".
|
|
//!
|
|
//! Operations:
|
|
//! - `GET /api/v1/admin/tenants` — list tenant DBs
|
|
//! - `DELETE /api/v1/admin/tenants/{tenant_id}` — GDPR delete
|
|
//!
|
|
//! Tenant ids in URLs are passed as-is to `DatabasePool::drop_tenant`,
|
|
//! which sanitises them the same way it does for creation. Listing
|
|
//! returns the raw DB names from `list_tenant_db_names` — operators
|
|
//! can reverse-derive the tenant_id from the prefix.
|
|
|
|
use axum::extract::{Extension, Path, Request};
|
|
use axum::http::{header, StatusCode};
|
|
use axum::middleware::Next;
|
|
use axum::response::{IntoResponse, Response};
|
|
use axum::Json;
|
|
use secrecy::ExposeSecret;
|
|
use serde::Serialize;
|
|
|
|
use super::dto::AgentExt;
|
|
|
|
#[derive(Serialize)]
|
|
pub struct ListTenantDbsResponse {
|
|
pub tenant_db_names: Vec<String>,
|
|
}
|
|
|
|
#[tracing::instrument(skip_all)]
|
|
pub async fn list_tenant_dbs(
|
|
Extension(agent): AgentExt,
|
|
) -> Result<Json<ListTenantDbsResponse>, StatusCode> {
|
|
let names = agent.db_pool.list_tenant_db_names().await.map_err(|e| {
|
|
tracing::error!("admin: list_tenant_db_names failed: {e}");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
Ok(Json(ListTenantDbsResponse {
|
|
tenant_db_names: names,
|
|
}))
|
|
}
|
|
|
|
#[tracing::instrument(skip_all, fields(tenant_id = %tenant_id))]
|
|
pub async fn drop_tenant_db(
|
|
Extension(agent): AgentExt,
|
|
Path(tenant_id): Path<String>,
|
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
|
agent.db_pool.drop_tenant(&tenant_id).await.map_err(|e| {
|
|
tracing::error!("admin: drop_tenant failed: {e}");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
Ok(Json(serde_json::json!({ "status": "dropped" })))
|
|
}
|
|
|
|
/// Constant-time-ish comparison of the configured admin token against
|
|
/// the incoming bearer. Uses `subtle`-style byte equality so timing
|
|
/// attacks can't probe the token character by character.
|
|
fn tokens_eq(a: &str, b: &str) -> bool {
|
|
if a.len() != b.len() {
|
|
return false;
|
|
}
|
|
let mut diff = 0u8;
|
|
for (x, y) in a.bytes().zip(b.bytes()) {
|
|
diff |= x ^ y;
|
|
}
|
|
diff == 0
|
|
}
|
|
|
|
/// Middleware enforcing the static `ADMIN_API_TOKEN`. Mounted only on
|
|
/// the admin sub-router, so this never runs on customer routes.
|
|
pub async fn require_admin_token(
|
|
Extension(agent): AgentExt,
|
|
request: Request,
|
|
next: Next,
|
|
) -> Response {
|
|
let Some(expected) = agent.config.admin_api_token.as_ref() else {
|
|
// Belt-and-braces — if the routes were somehow mounted without
|
|
// a token configured, refuse rather than no-op-pass.
|
|
return (StatusCode::NOT_FOUND, "admin disabled").into_response();
|
|
};
|
|
let presented = request
|
|
.headers()
|
|
.get(header::AUTHORIZATION)
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|s| s.strip_prefix("Bearer "))
|
|
.map(|s| s.trim());
|
|
let Some(presented) = presented.filter(|s| !s.is_empty()) else {
|
|
return (StatusCode::UNAUTHORIZED, "Missing bearer token").into_response();
|
|
};
|
|
if !tokens_eq(presented, expected.expose_secret()) {
|
|
return (StatusCode::UNAUTHORIZED, "Invalid admin token").into_response();
|
|
}
|
|
next.run(request).await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn tokens_eq_basic() {
|
|
assert!(tokens_eq("abc", "abc"));
|
|
assert!(!tokens_eq("abc", "abd"));
|
|
assert!(!tokens_eq("abc", "abcd"));
|
|
assert!(!tokens_eq("", "x"));
|
|
assert!(tokens_eq("", ""));
|
|
}
|
|
}
|