use crate::Governor;
use crate::config::FlowControlConfig;
use async_trait::async_trait;
use std::sync::Arc;
#[cfg(feature = "circuit-breaker")]
use super::handlers::CircuitBreakerStatus;
#[cfg(feature = "ban-manager")]
use super::handlers::{BanResponse, BanTargetQuery, CreateBanRequest, UnbanRequest};
use super::handlers::{BatchCheckBody, SystemStatus, TokenPrefetchBody};
#[cfg(feature = "quota-control")]
use super::handlers::{UpdateQuotaRequest, UpdateQuotaResponse};
#[cfg(feature = "ban-manager")]
use crate::error::LimiteronError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdminServiceError {
NotConfigured(&'static str),
Invalid(String),
NotFound(String),
Forbidden(String),
Internal(String),
}
impl std::fmt::Display for AdminServiceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotConfigured(what) => write!(f, "{what} not configured"),
Self::Invalid(m) => write!(f, "{m}"),
Self::NotFound(m) => write!(f, "{m}"),
Self::Forbidden(m) => write!(f, "{m}"),
Self::Internal(m) => write!(f, "{m}"),
}
}
}
impl std::error::Error for AdminServiceError {}
#[async_trait]
pub trait AdminService: Send + Sync {
async fn status(&self) -> SystemStatus;
async fn introspect(&self) -> serde_json::Value;
async fn apply_config(
&self,
config: FlowControlConfig,
) -> Result<serde_json::Value, AdminServiceError>;
async fn check_batch(
&self,
body: BatchCheckBody,
) -> Result<(usize, Vec<serde_json::Value>), AdminServiceError>;
async fn prefetch_tokens(
&self,
body: TokenPrefetchBody,
) -> Result<(usize, Vec<serde_json::Value>), AdminServiceError>;
#[cfg(feature = "ban-manager")]
async fn create_ban(
&self,
operator: String,
req: CreateBanRequest,
) -> Result<BanResponse, AdminServiceError>;
#[cfg(feature = "ban-manager")]
async fn delete_ban(
&self,
operator: String,
target: String,
query: BanTargetQuery,
req: UnbanRequest,
) -> Result<String, AdminServiceError>;
#[cfg(feature = "quota-control")]
async fn update_quota(
&self,
user_id: &str,
req: UpdateQuotaRequest,
) -> Result<UpdateQuotaResponse, AdminServiceError>;
#[cfg(feature = "circuit-breaker")]
async fn circuit_breaker_status(&self) -> Result<CircuitBreakerStatus, AdminServiceError>;
}
pub async fn prefetch_tokens_detached(
body: TokenPrefetchBody,
) -> Result<(usize, Vec<serde_json::Value>), AdminServiceError> {
use super::handlers::{BATCH_MAX_ITEMS, token_prefetcher};
if body.items.is_empty() {
return Err(AdminServiceError::Invalid(
"token prefetch requires at least one item".to_string(),
));
}
if body.items.len() > BATCH_MAX_ITEMS {
return Err(AdminServiceError::Invalid(format!(
"token prefetch limited to {BATCH_MAX_ITEMS} items per call"
)));
}
let pairs: Vec<(String, u64)> = body.items.into_iter().map(|i| (i.key, i.tokens)).collect();
let results = token_prefetcher().prefetch_batch(&pairs).await;
let granted_count = results.iter().filter(|r| r.granted).count();
let values = serde_json::to_value(&results)
.unwrap_or_else(|_| serde_json::json!([]))
.as_array()
.cloned()
.unwrap_or_default();
Ok((granted_count, values))
}
pub struct GovernorAdminService {
governor: Arc<Governor>,
#[cfg(feature = "ban-manager")]
ban_manager: Option<Arc<crate::BanManager>>,
#[cfg(feature = "quota-control")]
quota_controller: Option<Arc<crate::QuotaController>>,
#[cfg(feature = "circuit-breaker")]
circuit_breaker: Option<Arc<crate::CircuitBreaker>>,
}
impl GovernorAdminService {
pub fn from_state(state: &super::server::LimiteronState) -> Self {
Self {
governor: state.governor.clone(),
#[cfg(feature = "ban-manager")]
ban_manager: state.ban_manager.clone(),
#[cfg(feature = "quota-control")]
quota_controller: state.quota_controller.clone(),
#[cfg(feature = "circuit-breaker")]
circuit_breaker: state.circuit_breaker.clone(),
}
}
}
#[async_trait]
impl AdminService for GovernorAdminService {
async fn status(&self) -> SystemStatus {
let stats = self.governor.stats().await;
let blocked = stats
.rejected_requests
.saturating_add(stats.banned_requests);
let total = stats.total_requests;
let success_rate = if total > 0 {
(total.saturating_sub(blocked)) as f64 / total as f64
} else {
1.0
};
#[cfg(feature = "ban-manager")]
let active_bans: usize = if let Some(ref bm) = self.ban_manager {
bm.list_bans(crate::ban::BanFilter {
active_only: true,
..Default::default()
})
.await
.map(|v| v.len())
.unwrap_or(0)
} else {
0
};
#[cfg(feature = "circuit-breaker")]
let cb_state = if let Some(ref cb) = self.circuit_breaker {
cb.get_state().await.to_string()
} else {
"disabled".to_string()
};
SystemStatus {
total_requests: total,
blocked_requests: blocked,
success_rate,
#[cfg(feature = "ban-manager")]
active_bans,
#[cfg(feature = "circuit-breaker")]
circuit_breaker: cb_state,
}
}
async fn introspect(&self) -> serde_json::Value {
let snapshot = self.governor.introspect().await;
let body = serde_json::to_value(&snapshot).unwrap_or_else(|_| serde_json::json!({}));
#[cfg(feature = "ban-manager")]
let body = {
let mut body = body;
if let Some(ref bm) = self.ban_manager
&& let Ok(bans) = bm
.list_bans(crate::ban::BanFilter {
active_only: true,
..Default::default()
})
.await
{
let items: Vec<serde_json::Value> = bans
.iter()
.map(|b| {
serde_json::json!({
"target": b.target,
"ban_times": b.ban_times,
"is_manual": b.is_manual,
"reason": b.reason,
"expires_at": b.expires_at.to_rfc3339(),
})
})
.collect();
body["active_bans"] = serde_json::Value::Array(items);
}
body
};
#[cfg(feature = "circuit-breaker")]
let body = {
let mut body = body;
if let Some(ref cb) = self.circuit_breaker {
body["circuit_breaker_state"] = serde_json::json!(cb.get_state().await.to_string());
}
body
};
body
}
async fn apply_config(
&self,
config: FlowControlConfig,
) -> Result<serde_json::Value, AdminServiceError> {
match self.governor.apply_config(config).await {
Ok(report) => {
Ok(serde_json::to_value(&report).unwrap_or_else(|_| serde_json::json!({})))
}
Err(e) => Err(AdminServiceError::Invalid(format!("config rejected: {e}"))),
}
}
async fn check_batch(
&self,
body: BatchCheckBody,
) -> Result<(usize, Vec<serde_json::Value>), AdminServiceError> {
use super::handlers::BATCH_MAX_ITEMS;
if body.requests.is_empty() {
return Err(AdminServiceError::Invalid(
"batch check requires at least one request".to_string(),
));
}
if body.requests.len() > BATCH_MAX_ITEMS {
return Err(AdminServiceError::Invalid(format!(
"batch check limited to {BATCH_MAX_ITEMS} requests per call"
)));
}
let mut results = Vec::with_capacity(body.requests.len());
for (index, item) in body.requests.into_iter().enumerate() {
let mut ctx = crate::matchers::RequestContext::new();
if let Some(user_id) = item.user_id {
ctx.headers.insert("x-user-id".to_string(), user_id);
}
ctx.ip = item.ip.clone();
ctx.client_ip = item.ip;
ctx.path = item.path.unwrap_or_default();
ctx.method = item.method.unwrap_or_else(|| "GET".to_string());
match self.governor.check(&ctx).await {
Ok(decision) => {
let (kind, allowed) = match &decision {
crate::error::Decision::Allowed(_) => ("Allowed", true),
crate::error::Decision::Rejected(_) => ("Rejected", false),
crate::error::Decision::Banned(_) => ("Banned", false),
};
results.push(serde_json::json!({
"index": index,
"allowed": allowed,
"decision": kind,
}));
}
Err(e) => {
results.push(serde_json::json!({
"index": index,
"allowed": false,
"decision": "Error",
"error": e.to_string(),
}));
}
}
}
let allowed_count = results.iter().filter(|r| r["allowed"] == true).count();
Ok((allowed_count, results))
}
async fn prefetch_tokens(
&self,
body: TokenPrefetchBody,
) -> Result<(usize, Vec<serde_json::Value>), AdminServiceError> {
prefetch_tokens_detached(body).await
}
#[cfg(feature = "ban-manager")]
async fn create_ban(
&self,
operator: String,
req: CreateBanRequest,
) -> Result<BanResponse, AdminServiceError> {
use crate::ban::BanSource;
use std::time::Duration;
let Some(ref ban_manager) = self.ban_manager else {
return Err(AdminServiceError::NotConfigured("Ban manager"));
};
let source = BanSource::Manual { operator };
let duration = req.duration_secs.map(Duration::from_secs);
match ban_manager
.create_ban(
req.target,
req.reason,
source,
serde_json::json!({"source": "http-api"}),
duration,
)
.await
{
Ok(detail) => Ok(BanResponse {
id: detail.id,
ban_times: detail.ban_times,
expires_at: detail.expires_at.timestamp(),
is_manual: detail.is_manual,
}),
Err(e) => Err(match &e {
LimiteronError::ValidationError(_) => AdminServiceError::Invalid(e.to_string()),
LimiteronError::AuthorizationError(_) => {
AdminServiceError::Forbidden(e.to_string())
}
_ => AdminServiceError::Internal(e.to_string()),
}),
}
}
#[cfg(feature = "ban-manager")]
async fn delete_ban(
&self,
operator: String,
target: String,
query: BanTargetQuery,
req: UnbanRequest,
) -> Result<String, AdminServiceError> {
use crate::storage::BanTarget;
let Some(ref ban_manager) = self.ban_manager else {
return Err(AdminServiceError::NotConfigured("Ban manager"));
};
let ban_target = match query.target_type.as_deref() {
Some("ip") => BanTarget::Ip(target),
Some("user") => BanTarget::UserId(target),
Some("mac") => BanTarget::Mac(target),
Some("geo") => BanTarget::Geo {
country_code: target,
},
Some("cidr") => BanTarget::Cidr(target),
Some(other) => {
return Err(AdminServiceError::Invalid(format!(
"unsupported target type: {other}"
)));
}
None => {
if target.parse::<std::net::IpAddr>().is_ok() {
BanTarget::Ip(target)
} else {
BanTarget::UserId(target)
}
}
};
match ban_manager.delete_ban(&ban_target, operator).await {
Ok(true) => Ok(req.reason.unwrap_or_else(|| "Ban removed".to_string())),
Ok(false) => Err(AdminServiceError::NotFound("Ban not found".to_string())),
Err(e) => Err(AdminServiceError::Internal(format!(
"Failed to remove ban: {e}"
))),
}
}
#[cfg(feature = "quota-control")]
async fn update_quota(
&self,
user_id: &str,
req: UpdateQuotaRequest,
) -> Result<UpdateQuotaResponse, AdminServiceError> {
let Some(ref quota_controller) = self.quota_controller else {
return Err(AdminServiceError::NotConfigured("Quota controller"));
};
if req.new_limit == 0 {
match quota_controller.reset_quota(user_id, &req.resource).await {
Ok(_) => Ok(UpdateQuotaResponse {
success: true,
expires_at: req.duration_secs.map(|d| {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|t| t.as_secs() + d)
.unwrap_or(0)
}),
}),
Err(e) => Err(AdminServiceError::Internal(format!(
"Failed to reset quota: {e}"
))),
}
} else {
Err(AdminServiceError::Invalid(
"Per-tenant quota limit update is not supported; use global QuotaConfig update_config instead"
.to_string(),
))
}
}
#[cfg(feature = "circuit-breaker")]
async fn circuit_breaker_status(&self) -> Result<CircuitBreakerStatus, AdminServiceError> {
if let Some(ref cb) = self.circuit_breaker {
let stats = cb.get_stats().await;
let total = stats.total_calls as f64;
let failure_rate = if total > 0.0 {
stats.failure_count as f64 / total
} else {
0.0
};
Ok(CircuitBreakerStatus {
state: stats.state.to_string(),
failure_rate,
slow_call_rate: 0.0,
})
} else {
Err(AdminServiceError::NotConfigured("Circuit breaker"))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::admin::LimiteronState;
use crate::admin::handlers::tests::make_state;
fn service_from(state: &LimiteronState) -> GovernorAdminService {
GovernorAdminService::from_state(state)
}
#[tokio::test]
async fn status_reports_fresh_governor_counters() {
let state = make_state().await;
let status = service_from(&state).status().await;
assert_eq!(status.total_requests, 0);
assert_eq!(status.blocked_requests, 0);
assert_eq!(status.success_rate, 1.0);
}
#[tokio::test]
async fn apply_config_rejects_invalid_and_keeps_old() {
use crate::config::{Action, ActionConfig, Rule};
let state = make_state().await;
let service = service_from(&state);
let config = FlowControlConfig {
version: "1.0".to_string(),
global: Default::default(),
rules: vec![
Rule {
id: "dup".to_string(),
name: "a".to_string(),
priority: 1,
matchers: vec![],
limiters: vec![],
action: ActionConfig {
on_exceed: Action::Reject,
ban: None,
},
},
Rule {
id: "dup".to_string(),
name: "b".to_string(),
priority: 2,
matchers: vec![],
limiters: vec![],
action: ActionConfig {
on_exceed: Action::Reject,
ban: None,
},
},
],
};
let result = service.apply_config(config).await;
assert!(
matches!(result, Err(AdminServiceError::Invalid(_))),
"duplicate rule id must be rejected: {result:?}"
);
}
#[tokio::test]
async fn check_batch_rejects_empty() {
let state = make_state().await;
let err = service_from(&state)
.check_batch(BatchCheckBody { requests: vec![] })
.await
.unwrap_err();
assert!(matches!(err, AdminServiceError::Invalid(_)));
}
#[tokio::test]
async fn prefetch_tokens_rejects_empty() {
let state = make_state().await;
let err = service_from(&state)
.prefetch_tokens(super::super::handlers::TokenPrefetchBody { items: vec![] })
.await
.unwrap_err();
assert!(matches!(err, AdminServiceError::Invalid(_)));
}
#[cfg(feature = "ban-manager")]
#[tokio::test]
async fn create_ban_authorization_error_keeps_legacy_message() {
use super::super::handlers::CreateBanRequest;
use crate::authorization::SimpleAuthorizationProvider;
use crate::ban::BanManager;
use crate::storage::BanTarget;
let governor = Arc::new(crate::admin::handlers::tests::make_governor().await);
let ban_manager = Arc::new(
BanManager::builder()
.with_authorization_provider(Arc::new(SimpleAuthorizationProvider::new(vec![
"admin".to_string(),
])))
.build()
.await
.expect("ban manager with authorization provider"),
);
let service = GovernorAdminService {
governor,
ban_manager: Some(ban_manager),
#[cfg(feature = "quota-control")]
quota_controller: None,
#[cfg(feature = "circuit-breaker")]
circuit_breaker: None,
};
let result = service
.create_ban(
"intruder".to_string(),
CreateBanRequest {
target: BanTarget::Ip("10.0.0.9".to_string()),
reason: "test".to_string(),
operator: None,
duration_secs: None,
},
)
.await;
let Err(err) = result else {
panic!("create_ban for an unauthorized operator must fail");
};
let message = err.to_string();
assert!(
message.starts_with("Authorization error: "),
"403 response body message must keep the legacy contract, got: {message}"
);
assert!(
message.contains("intruder"),
"message must carry the operator detail: {message}"
);
assert!(
matches!(err, AdminServiceError::Forbidden { .. }),
"AuthorizationError must map to Forbidden(403): {err:?}"
);
}
#[test]
fn error_display_is_stable() {
assert_eq!(
AdminServiceError::NotConfigured("Ban manager").to_string(),
"Ban manager not configured"
);
assert_eq!(
AdminServiceError::NotFound("Ban not found".to_string()).to_string(),
"Ban not found"
);
}
}