//! 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, } #[tracing::instrument(skip_all)] pub async fn list_tenant_dbs( Extension(agent): AgentExt, ) -> Result, 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, ) -> Result, 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("", "")); } }