use axum::{
Json, Router,
extract::{
Path, Request, State,
rejection::{JsonRejection, PathRejection},
},
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
routing::get,
};
use serde::Deserialize;
use sqlx::PgPool;
use tower_http::limit::RequestBodyLimitLayer;
use uuid::Uuid;
use crate::{
auth::{ADMIN_TOKEN_PREFIX, TOKEN_PREFIX, hash_token},
error::BusError,
ratelimit::RateLimiter,
store::admin::{self as store, Actor, AdminCtx},
};
pub const ADMIN_RATE_LIMIT_DIVISOR: u32 = 10;
pub const MAX_ADMIN_REQUEST_BYTES: usize = 16 * 1024;
#[derive(Clone)]
pub struct AdminApiState {
pub pool: PgPool,
limiter: Option<RateLimiter>,
}
#[derive(Debug)]
pub enum ApiError {
Unauthorized(String),
Forbidden(String),
NotFound(String),
BadRequest(String),
Conflict(String),
Throttled(u64),
Internal,
}
impl From<BusError> for ApiError {
fn from(err: BusError) -> Self {
match err {
BusError::NotFound(m) => ApiError::NotFound(m),
BusError::Invalid(m) => ApiError::BadRequest(m),
BusError::Conflict(m) => ApiError::Conflict(m),
BusError::Unauthenticated(m) => ApiError::Unauthorized(m),
BusError::Forbidden(m) => ApiError::Forbidden(m),
BusError::Db(e) => {
tracing::error!(error = %e, "database error on /admin");
ApiError::Internal
}
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let retry_after = match self {
ApiError::Throttled(secs) => Some(secs),
_ => None,
};
let is_challenge = matches!(self, ApiError::Unauthorized(_));
let (status, msg) = match self {
ApiError::Unauthorized(m) => (StatusCode::UNAUTHORIZED, m),
ApiError::Forbidden(m) => (StatusCode::FORBIDDEN, m),
ApiError::NotFound(m) => (StatusCode::NOT_FOUND, m),
ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
ApiError::Conflict(m) => (StatusCode::CONFLICT, m),
ApiError::Throttled(secs) => (
StatusCode::TOO_MANY_REQUESTS,
format!("rate limit exceeded for this credential; retry in {secs}s"),
),
ApiError::Internal => (
StatusCode::INTERNAL_SERVER_ERROR,
"internal error".to_owned(),
),
};
let mut resp = (status, Json(serde_json::json!({ "error": msg }))).into_response();
if is_challenge {
resp.headers_mut().insert(
axum::http::header::WWW_AUTHENTICATE,
axum::http::HeaderValue::from_static("Bearer"),
);
}
if let Some(secs) = retry_after
&& let Ok(value) = axum::http::HeaderValue::from_str(&secs.to_string())
{
resp.headers_mut()
.insert(axum::http::header::RETRY_AFTER, value);
}
resp
}
}
type ApiResult<T> = Result<T, ApiError>;
impl From<JsonRejection> for ApiError {
fn from(r: JsonRejection) -> Self {
ApiError::BadRequest(format!(
"invalid JSON body: {}. Send an object with Content-Type: application/json",
r.body_text()
))
}
}
impl From<PathRejection> for ApiError {
fn from(r: PathRejection) -> Self {
ApiError::BadRequest(format!("invalid path parameter: {}", r.body_text()))
}
}
type Body<T> = Result<Json<T>, JsonRejection>;
type Params<T> = Result<Path<T>, PathRejection>;
async fn require_admin(
State(state): State<AdminApiState>,
mut req: Request,
next: Next,
) -> ApiResult<Response> {
let raw = req
.headers()
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| {
v.strip_prefix("Bearer ")
.or_else(|| v.strip_prefix("bearer "))
})
.map(str::trim)
.filter(|v| !v.is_empty())
.ok_or_else(|| {
ApiError::Unauthorized(
"missing bearer credential; /admin needs an administrative credential \
(acsa_…), minted with `ai-crew-sync admin bootstrap` or `admin grant`"
.to_owned(),
)
})?
.to_owned();
if raw.starts_with(TOKEN_PREFIX) {
return Err(ApiError::Unauthorized(
"this is an agent token; agent tokens cannot administer the bus. /admin needs \
an administrative credential (acsa_…), minted with `ai-crew-sync admin \
bootstrap` or `admin grant`"
.to_owned(),
));
}
if !raw.starts_with(ADMIN_TOKEN_PREFIX) {
return Err(ApiError::Unauthorized(
"invalid or revoked administrative credential".to_owned(),
));
}
if let Some(limiter) = &state.limiter
&& let Err(throttled) = limiter.check(&hex::encode(hash_token(&raw)))
{
return Err(ApiError::Throttled(throttled.retry_after_secs));
}
let ctx = store::resolve_admin(&state.pool, &raw)
.await?
.ok_or_else(|| {
ApiError::Unauthorized("invalid or revoked administrative credential".to_owned())
})?;
tracing::debug!(
credential = %ctx.id,
team = ctx.team_slug.as_deref().unwrap_or("(global)"),
"administrator authenticated"
);
req.extensions_mut().insert(ctx);
Ok(next.run(req).await)
}
fn ctx(req_ctx: Option<axum::Extension<AdminCtx>>) -> ApiResult<AdminCtx> {
req_ctx.map(|e| e.0).ok_or_else(|| {
tracing::error!("/admin handler reached without an AdminCtx");
ApiError::Unauthorized("missing administrative credential".to_owned())
})
}
fn require_global(ctx: &AdminCtx, what: &str) -> ApiResult<()> {
if ctx.is_global() {
return Ok(());
}
Err(ApiError::Forbidden(format!(
"{what} needs a global administrative credential; this one administers team '{}' only",
ctx.team_slug.as_deref().unwrap_or_default()
)))
}
async fn scoped_team(pool: &PgPool, ctx: &AdminCtx, slug: &str) -> ApiResult<Uuid> {
let slug = slug.trim().to_lowercase();
match (ctx.team_id, ctx.team_slug.as_deref()) {
(Some(tid), Some(own)) => {
if own == slug {
Ok(tid)
} else {
Err(ApiError::Forbidden(format!(
"this credential administers team '{own}' only"
)))
}
}
_ => Ok(store::team_id_by_slug(pool, &slug).await?),
}
}
async fn whoami(req_ctx: Option<axum::Extension<AdminCtx>>) -> ApiResult<Json<serde_json::Value>> {
let ctx = ctx(req_ctx)?;
Ok(Json(serde_json::json!({
"credential_id": ctx.id,
"scope": if ctx.is_global() { "global" } else { "team" },
"team": ctx.team_slug,
})))
}
async fn list_teams(
State(state): State<AdminApiState>,
req_ctx: Option<axum::Extension<AdminCtx>>,
) -> ApiResult<Json<serde_json::Value>> {
let ctx = ctx(req_ctx)?;
let teams = match ctx.team_id {
None => store::list_teams(&state.pool).await?,
Some(tid) => vec![store::team_by_id(&state.pool, tid).await?],
};
Ok(Json(serde_json::json!({ "teams": teams })))
}
#[derive(Deserialize)]
struct CreateTeam {
slug: String,
name: Option<String>,
}
async fn create_team(
State(state): State<AdminApiState>,
req_ctx: Option<axum::Extension<AdminCtx>>,
body: Body<CreateTeam>,
) -> ApiResult<(StatusCode, Json<serde_json::Value>)> {
let ctx = ctx(req_ctx)?;
let Json(body) = body?;
require_global(&ctx, "creating a team")?;
let team = store::create_team(&state.pool, Actor::Admin(ctx.id), &body.slug, body.name).await?;
tracing::info!(credential = %ctx.id, team = %team.slug, "team ready");
Ok((
StatusCode::CREATED,
Json(serde_json::json!({ "team": team })),
))
}
async fn list_agents(
State(state): State<AdminApiState>,
req_ctx: Option<axum::Extension<AdminCtx>>,
team: Params<String>,
) -> ApiResult<Json<serde_json::Value>> {
let ctx = ctx(req_ctx)?;
let Path(team) = team?;
let tid = scoped_team(&state.pool, &ctx, &team).await?;
let agents = store::list_agents(&state.pool, tid).await?;
Ok(Json(serde_json::json!({ "agents": agents })))
}
#[derive(Deserialize)]
struct CreateAgent {
name: String,
display_name: Option<String>,
}
async fn create_agent(
State(state): State<AdminApiState>,
req_ctx: Option<axum::Extension<AdminCtx>>,
team: Params<String>,
body: Body<CreateAgent>,
) -> ApiResult<(StatusCode, Json<serde_json::Value>)> {
let ctx = ctx(req_ctx)?;
let Path(team) = team?;
let Json(body) = body?;
let tid = scoped_team(&state.pool, &ctx, &team).await?;
let agent = store::create_agent(
&state.pool,
Actor::Admin(ctx.id),
tid,
&body.name,
body.display_name,
)
.await?;
tracing::info!(credential = %ctx.id, team = %team, agent = %agent.name, "agent ready");
Ok((
StatusCode::CREATED,
Json(serde_json::json!({ "agent": agent })),
))
}
async fn list_tokens(
State(state): State<AdminApiState>,
req_ctx: Option<axum::Extension<AdminCtx>>,
team: Params<String>,
) -> ApiResult<Json<serde_json::Value>> {
let ctx = ctx(req_ctx)?;
let Path(team) = team?;
let tid = scoped_team(&state.pool, &ctx, &team).await?;
let tokens = store::list_tokens(&state.pool, tid).await?;
Ok(Json(serde_json::json!({ "tokens": tokens })))
}
#[derive(Deserialize)]
struct IssueToken {
agent: String,
label: Option<String>,
}
async fn issue_token(
State(state): State<AdminApiState>,
req_ctx: Option<axum::Extension<AdminCtx>>,
team: Params<String>,
body: Body<IssueToken>,
) -> ApiResult<(StatusCode, Json<serde_json::Value>)> {
let ctx = ctx(req_ctx)?;
let Path(team) = team?;
let Json(body) = body?;
let tid = scoped_team(&state.pool, &ctx, &team).await?;
let issued = store::issue_token(
&state.pool,
Actor::Admin(ctx.id),
tid,
&body.agent,
body.label,
)
.await?;
tracing::info!(
credential = %ctx.id,
team = %issued.team,
agent = %issued.agent,
token = %issued.id,
"agent token issued"
);
Ok((
StatusCode::CREATED,
Json(serde_json::json!({ "token": issued })),
))
}
async fn revoke_token(
State(state): State<AdminApiState>,
req_ctx: Option<axum::Extension<AdminCtx>>,
params: Params<(String, Uuid)>,
) -> ApiResult<Json<serde_json::Value>> {
let ctx = ctx(req_ctx)?;
let Path((team, id)) = params?;
let tid = scoped_team(&state.pool, &ctx, &team).await?;
store::revoke_token(&state.pool, Actor::Admin(ctx.id), Some(tid), id).await?;
tracing::info!(credential = %ctx.id, team = %team, token = %id, "agent token revoked");
Ok(Json(serde_json::json!({ "revoked": id })))
}
async fn list_credentials(
State(state): State<AdminApiState>,
req_ctx: Option<axum::Extension<AdminCtx>>,
) -> ApiResult<Json<serde_json::Value>> {
let ctx = ctx(req_ctx)?;
let rows = store::list_admins(&state.pool, ctx.team_id).await?;
Ok(Json(serde_json::json!({ "credentials": rows })))
}
#[derive(Deserialize)]
struct GrantCredential {
team: Option<String>,
label: Option<String>,
}
async fn grant_credential(
State(state): State<AdminApiState>,
req_ctx: Option<axum::Extension<AdminCtx>>,
body: Body<GrantCredential>,
) -> ApiResult<(StatusCode, Json<serde_json::Value>)> {
let ctx = ctx(req_ctx)?;
let Json(body) = body?;
require_global(&ctx, "granting an administrative credential")?;
let tid = match body.team.as_deref().map(str::trim) {
None => None,
Some("") => {
return Err(ApiError::BadRequest(
"team is empty; pass a team slug, or omit it for a global credential".to_owned(),
));
}
Some(slug) => Some(store::team_id_by_slug(&state.pool, slug).await?),
};
let issued = store::grant_admin(&state.pool, Actor::Admin(ctx.id), tid, body.label).await?;
tracing::info!(
credential = %ctx.id,
granted = %issued.id,
team = issued.team.as_deref().unwrap_or("(global)"),
"administrative credential granted"
);
Ok((
StatusCode::CREATED,
Json(serde_json::json!({ "credential": issued })),
))
}
async fn revoke_credential(
State(state): State<AdminApiState>,
req_ctx: Option<axum::Extension<AdminCtx>>,
id: Params<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
let ctx = ctx(req_ctx)?;
let Path(id) = id?;
store::revoke_admin(&state.pool, Actor::Admin(ctx.id), ctx.team_id, id).await?;
tracing::info!(credential = %ctx.id, revoked = %id, "administrative credential revoked");
Ok(Json(serde_json::json!({ "revoked": id })))
}
async fn explain_rejections(req: Request, next: Next) -> Response {
let resp = next.run(req).await;
match resp.status() {
StatusCode::PAYLOAD_TOO_LARGE => ApiError::BadRequest(format!(
"request body is too large; /admin accepts up to {MAX_ADMIN_REQUEST_BYTES} bytes"
))
.into_response(),
_ => resp,
}
}
pub fn router<S: Clone + Send + Sync + 'static>(
pool: PgPool,
mcp_rate_limit_per_minute: u32,
) -> Router<S> {
let admin_per_minute = match mcp_rate_limit_per_minute {
0 => 0,
n => (n / ADMIN_RATE_LIMIT_DIVISOR).max(1),
};
let state = AdminApiState {
pool,
limiter: RateLimiter::new(admin_per_minute),
};
Router::new()
.route("/whoami", get(whoami))
.route("/teams", get(list_teams).post(create_team))
.route("/teams/{team}/agents", get(list_agents).post(create_agent))
.route("/teams/{team}/tokens", get(list_tokens).post(issue_token))
.route(
"/teams/{team}/tokens/{id}",
axum::routing::delete(revoke_token),
)
.route("/credentials", get(list_credentials).post(grant_credential))
.route(
"/credentials/{id}",
axum::routing::delete(revoke_credential),
)
.layer(axum::middleware::from_fn_with_state(
state.clone(),
require_admin,
))
.layer(RequestBodyLimitLayer::new(MAX_ADMIN_REQUEST_BYTES))
.layer(axum::middleware::from_fn(explain_rejections))
.with_state(state)
}