1use axum::{
17 Json, Router,
18 extract::{
19 Path, Request, State,
20 rejection::{JsonRejection, PathRejection},
21 },
22 http::StatusCode,
23 middleware::Next,
24 response::{IntoResponse, Response},
25 routing::get,
26};
27use serde::Deserialize;
28use sqlx::PgPool;
29use tower_http::limit::RequestBodyLimitLayer;
30use uuid::Uuid;
31
32use crate::{
33 auth::{ADMIN_TOKEN_PREFIX, TOKEN_PREFIX, hash_token},
34 error::BusError,
35 ratelimit::RateLimiter,
36 store::admin::{self as store, Actor, AdminCtx},
37};
38
39pub const ADMIN_RATE_LIMIT_DIVISOR: u32 = 10;
45
46pub const MAX_ADMIN_REQUEST_BYTES: usize = 16 * 1024;
49
50#[derive(Clone)]
51pub struct AdminApiState {
52 pub pool: PgPool,
53 limiter: Option<RateLimiter>,
54}
55
56#[derive(Debug)]
58pub enum ApiError {
59 Unauthorized(String),
60 Forbidden(String),
61 NotFound(String),
62 BadRequest(String),
63 Conflict(String),
64 Throttled(u64),
65 Internal,
66}
67
68impl From<BusError> for ApiError {
69 fn from(err: BusError) -> Self {
70 match err {
71 BusError::NotFound(m) => ApiError::NotFound(m),
72 BusError::Invalid(m) => ApiError::BadRequest(m),
73 BusError::Conflict(m) => ApiError::Conflict(m),
74 BusError::Unauthenticated(m) => ApiError::Unauthorized(m),
75 BusError::Forbidden(m) => ApiError::Forbidden(m),
76 BusError::Db(e) => {
77 tracing::error!(error = %e, "database error on /admin");
79 ApiError::Internal
80 }
81 }
82 }
83}
84
85impl IntoResponse for ApiError {
86 fn into_response(self) -> Response {
87 let retry_after = match self {
88 ApiError::Throttled(secs) => Some(secs),
89 _ => None,
90 };
91 let is_challenge = matches!(self, ApiError::Unauthorized(_));
92 let (status, msg) = match self {
93 ApiError::Unauthorized(m) => (StatusCode::UNAUTHORIZED, m),
94 ApiError::Forbidden(m) => (StatusCode::FORBIDDEN, m),
95 ApiError::NotFound(m) => (StatusCode::NOT_FOUND, m),
96 ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
97 ApiError::Conflict(m) => (StatusCode::CONFLICT, m),
98 ApiError::Throttled(secs) => (
99 StatusCode::TOO_MANY_REQUESTS,
100 format!("rate limit exceeded for this credential; retry in {secs}s"),
101 ),
102 ApiError::Internal => (
103 StatusCode::INTERNAL_SERVER_ERROR,
104 "internal error".to_owned(),
105 ),
106 };
107 let mut resp = (status, Json(serde_json::json!({ "error": msg }))).into_response();
108 if is_challenge {
109 resp.headers_mut().insert(
110 axum::http::header::WWW_AUTHENTICATE,
111 axum::http::HeaderValue::from_static("Bearer"),
112 );
113 }
114 if let Some(secs) = retry_after
115 && let Ok(value) = axum::http::HeaderValue::from_str(&secs.to_string())
116 {
117 resp.headers_mut()
118 .insert(axum::http::header::RETRY_AFTER, value);
119 }
120 resp
121 }
122}
123
124type ApiResult<T> = Result<T, ApiError>;
125
126impl From<JsonRejection> for ApiError {
130 fn from(r: JsonRejection) -> Self {
131 ApiError::BadRequest(format!(
132 "invalid JSON body: {}. Send an object with Content-Type: application/json",
133 r.body_text()
134 ))
135 }
136}
137
138impl From<PathRejection> for ApiError {
139 fn from(r: PathRejection) -> Self {
140 ApiError::BadRequest(format!("invalid path parameter: {}", r.body_text()))
141 }
142}
143
144type Body<T> = Result<Json<T>, JsonRejection>;
146type Params<T> = Result<Path<T>, PathRejection>;
147
148async fn require_admin(
151 State(state): State<AdminApiState>,
152 mut req: Request,
153 next: Next,
154) -> ApiResult<Response> {
155 let raw = req
156 .headers()
157 .get(axum::http::header::AUTHORIZATION)
158 .and_then(|v| v.to_str().ok())
159 .and_then(|v| {
160 v.strip_prefix("Bearer ")
161 .or_else(|| v.strip_prefix("bearer "))
162 })
163 .map(str::trim)
164 .filter(|v| !v.is_empty())
165 .ok_or_else(|| {
166 ApiError::Unauthorized(
167 "missing bearer credential; /admin needs an administrative credential \
168 (acsa_…), minted with `ai-crew-sync admin bootstrap` or `admin grant`"
169 .to_owned(),
170 )
171 })?
172 .to_owned();
173
174 if raw.starts_with(TOKEN_PREFIX) {
175 return Err(ApiError::Unauthorized(
176 "this is an agent token; agent tokens cannot administer the bus. /admin needs \
177 an administrative credential (acsa_…), minted with `ai-crew-sync admin \
178 bootstrap` or `admin grant`"
179 .to_owned(),
180 ));
181 }
182 if !raw.starts_with(ADMIN_TOKEN_PREFIX) {
183 return Err(ApiError::Unauthorized(
184 "invalid or revoked administrative credential".to_owned(),
185 ));
186 }
187
188 if let Some(limiter) = &state.limiter
191 && let Err(throttled) = limiter.check(&hex::encode(hash_token(&raw)))
192 {
193 return Err(ApiError::Throttled(throttled.retry_after_secs));
194 }
195
196 let ctx = store::resolve_admin(&state.pool, &raw)
197 .await?
198 .ok_or_else(|| {
199 ApiError::Unauthorized("invalid or revoked administrative credential".to_owned())
200 })?;
201 tracing::debug!(
202 credential = %ctx.id,
203 team = ctx.team_slug.as_deref().unwrap_or("(global)"),
204 "administrator authenticated"
205 );
206 req.extensions_mut().insert(ctx);
207 Ok(next.run(req).await)
208}
209
210fn ctx(req_ctx: Option<axum::Extension<AdminCtx>>) -> ApiResult<AdminCtx> {
214 req_ctx.map(|e| e.0).ok_or_else(|| {
215 tracing::error!("/admin handler reached without an AdminCtx");
216 ApiError::Unauthorized("missing administrative credential".to_owned())
217 })
218}
219
220fn require_global(ctx: &AdminCtx, what: &str) -> ApiResult<()> {
221 if ctx.is_global() {
222 return Ok(());
223 }
224 Err(ApiError::Forbidden(format!(
225 "{what} needs a global administrative credential; this one administers team '{}' only",
226 ctx.team_slug.as_deref().unwrap_or_default()
227 )))
228}
229
230async fn scoped_team(pool: &PgPool, ctx: &AdminCtx, slug: &str) -> ApiResult<Uuid> {
234 let slug = slug.trim().to_lowercase();
235 match (ctx.team_id, ctx.team_slug.as_deref()) {
236 (Some(tid), Some(own)) => {
237 if own == slug {
238 Ok(tid)
239 } else {
240 Err(ApiError::Forbidden(format!(
241 "this credential administers team '{own}' only"
242 )))
243 }
244 }
245 _ => Ok(store::team_id_by_slug(pool, &slug).await?),
246 }
247}
248
249async fn whoami(req_ctx: Option<axum::Extension<AdminCtx>>) -> ApiResult<Json<serde_json::Value>> {
252 let ctx = ctx(req_ctx)?;
253 Ok(Json(serde_json::json!({
254 "credential_id": ctx.id,
255 "scope": if ctx.is_global() { "global" } else { "team" },
256 "team": ctx.team_slug,
257 })))
258}
259
260async fn list_teams(
261 State(state): State<AdminApiState>,
262 req_ctx: Option<axum::Extension<AdminCtx>>,
263) -> ApiResult<Json<serde_json::Value>> {
264 let ctx = ctx(req_ctx)?;
265 let teams = match ctx.team_id {
268 None => store::list_teams(&state.pool).await?,
269 Some(tid) => vec![store::team_by_id(&state.pool, tid).await?],
270 };
271 Ok(Json(serde_json::json!({ "teams": teams })))
272}
273
274#[derive(Deserialize)]
275struct CreateTeam {
276 slug: String,
277 name: Option<String>,
278}
279
280async fn create_team(
281 State(state): State<AdminApiState>,
282 req_ctx: Option<axum::Extension<AdminCtx>>,
283 body: Body<CreateTeam>,
284) -> ApiResult<(StatusCode, Json<serde_json::Value>)> {
285 let ctx = ctx(req_ctx)?;
286 let Json(body) = body?;
287 require_global(&ctx, "creating a team")?;
288 let team = store::create_team(&state.pool, Actor::Admin(ctx.id), &body.slug, body.name).await?;
289 tracing::info!(credential = %ctx.id, team = %team.slug, "team ready");
290 Ok((
291 StatusCode::CREATED,
292 Json(serde_json::json!({ "team": team })),
293 ))
294}
295
296async fn list_agents(
297 State(state): State<AdminApiState>,
298 req_ctx: Option<axum::Extension<AdminCtx>>,
299 team: Params<String>,
300) -> ApiResult<Json<serde_json::Value>> {
301 let ctx = ctx(req_ctx)?;
302 let Path(team) = team?;
303 let tid = scoped_team(&state.pool, &ctx, &team).await?;
304 let agents = store::list_agents(&state.pool, tid).await?;
305 Ok(Json(serde_json::json!({ "agents": agents })))
306}
307
308#[derive(Deserialize)]
309struct CreateAgent {
310 name: String,
311 display_name: Option<String>,
312}
313
314async fn create_agent(
315 State(state): State<AdminApiState>,
316 req_ctx: Option<axum::Extension<AdminCtx>>,
317 team: Params<String>,
318 body: Body<CreateAgent>,
319) -> ApiResult<(StatusCode, Json<serde_json::Value>)> {
320 let ctx = ctx(req_ctx)?;
321 let Path(team) = team?;
322 let Json(body) = body?;
323 let tid = scoped_team(&state.pool, &ctx, &team).await?;
324 let agent = store::create_agent(
325 &state.pool,
326 Actor::Admin(ctx.id),
327 tid,
328 &body.name,
329 body.display_name,
330 )
331 .await?;
332 tracing::info!(credential = %ctx.id, team = %team, agent = %agent.name, "agent ready");
333 Ok((
334 StatusCode::CREATED,
335 Json(serde_json::json!({ "agent": agent })),
336 ))
337}
338
339async fn list_tokens(
340 State(state): State<AdminApiState>,
341 req_ctx: Option<axum::Extension<AdminCtx>>,
342 team: Params<String>,
343) -> ApiResult<Json<serde_json::Value>> {
344 let ctx = ctx(req_ctx)?;
345 let Path(team) = team?;
346 let tid = scoped_team(&state.pool, &ctx, &team).await?;
347 let tokens = store::list_tokens(&state.pool, tid).await?;
348 Ok(Json(serde_json::json!({ "tokens": tokens })))
349}
350
351#[derive(Deserialize)]
352struct IssueToken {
353 agent: String,
354 label: Option<String>,
355}
356
357async fn issue_token(
360 State(state): State<AdminApiState>,
361 req_ctx: Option<axum::Extension<AdminCtx>>,
362 team: Params<String>,
363 body: Body<IssueToken>,
364) -> ApiResult<(StatusCode, Json<serde_json::Value>)> {
365 let ctx = ctx(req_ctx)?;
366 let Path(team) = team?;
367 let Json(body) = body?;
368 let tid = scoped_team(&state.pool, &ctx, &team).await?;
369 let issued = store::issue_token(
370 &state.pool,
371 Actor::Admin(ctx.id),
372 tid,
373 &body.agent,
374 body.label,
375 )
376 .await?;
377 tracing::info!(
378 credential = %ctx.id,
379 team = %issued.team,
380 agent = %issued.agent,
381 token = %issued.id,
382 "agent token issued"
383 );
384 Ok((
385 StatusCode::CREATED,
386 Json(serde_json::json!({ "token": issued })),
387 ))
388}
389
390async fn revoke_token(
391 State(state): State<AdminApiState>,
392 req_ctx: Option<axum::Extension<AdminCtx>>,
393 params: Params<(String, Uuid)>,
394) -> ApiResult<Json<serde_json::Value>> {
395 let ctx = ctx(req_ctx)?;
396 let Path((team, id)) = params?;
397 let tid = scoped_team(&state.pool, &ctx, &team).await?;
398 store::revoke_token(&state.pool, Actor::Admin(ctx.id), Some(tid), id).await?;
401 tracing::info!(credential = %ctx.id, team = %team, token = %id, "agent token revoked");
402 Ok(Json(serde_json::json!({ "revoked": id })))
403}
404
405async fn list_credentials(
406 State(state): State<AdminApiState>,
407 req_ctx: Option<axum::Extension<AdminCtx>>,
408) -> ApiResult<Json<serde_json::Value>> {
409 let ctx = ctx(req_ctx)?;
410 let rows = store::list_admins(&state.pool, ctx.team_id).await?;
412 Ok(Json(serde_json::json!({ "credentials": rows })))
413}
414
415#[derive(Deserialize)]
416struct GrantCredential {
417 team: Option<String>,
419 label: Option<String>,
420}
421
422async fn grant_credential(
425 State(state): State<AdminApiState>,
426 req_ctx: Option<axum::Extension<AdminCtx>>,
427 body: Body<GrantCredential>,
428) -> ApiResult<(StatusCode, Json<serde_json::Value>)> {
429 let ctx = ctx(req_ctx)?;
430 let Json(body) = body?;
431 require_global(&ctx, "granting an administrative credential")?;
432 let tid = match body.team.as_deref().map(str::trim) {
435 None => None,
436 Some("") => {
437 return Err(ApiError::BadRequest(
438 "team is empty; pass a team slug, or omit it for a global credential".to_owned(),
439 ));
440 }
441 Some(slug) => Some(store::team_id_by_slug(&state.pool, slug).await?),
442 };
443 let issued = store::grant_admin(&state.pool, Actor::Admin(ctx.id), tid, body.label).await?;
444 tracing::info!(
445 credential = %ctx.id,
446 granted = %issued.id,
447 team = issued.team.as_deref().unwrap_or("(global)"),
448 "administrative credential granted"
449 );
450 Ok((
451 StatusCode::CREATED,
452 Json(serde_json::json!({ "credential": issued })),
453 ))
454}
455
456async fn revoke_credential(
457 State(state): State<AdminApiState>,
458 req_ctx: Option<axum::Extension<AdminCtx>>,
459 id: Params<Uuid>,
460) -> ApiResult<Json<serde_json::Value>> {
461 let ctx = ctx(req_ctx)?;
462 let Path(id) = id?;
463 store::revoke_admin(&state.pool, Actor::Admin(ctx.id), ctx.team_id, id).await?;
466 tracing::info!(credential = %ctx.id, revoked = %id, "administrative credential revoked");
467 Ok(Json(serde_json::json!({ "revoked": id })))
468}
469
470async fn explain_rejections(req: Request, next: Next) -> Response {
474 let resp = next.run(req).await;
475 match resp.status() {
476 StatusCode::PAYLOAD_TOO_LARGE => ApiError::BadRequest(format!(
477 "request body is too large; /admin accepts up to {MAX_ADMIN_REQUEST_BYTES} bytes"
478 ))
479 .into_response(),
480 _ => resp,
481 }
482}
483
484pub fn router<S: Clone + Send + Sync + 'static>(
488 pool: PgPool,
489 mcp_rate_limit_per_minute: u32,
490) -> Router<S> {
491 let admin_per_minute = match mcp_rate_limit_per_minute {
494 0 => 0,
495 n => (n / ADMIN_RATE_LIMIT_DIVISOR).max(1),
496 };
497 let state = AdminApiState {
498 pool,
499 limiter: RateLimiter::new(admin_per_minute),
500 };
501 Router::new()
502 .route("/whoami", get(whoami))
503 .route("/teams", get(list_teams).post(create_team))
504 .route("/teams/{team}/agents", get(list_agents).post(create_agent))
505 .route("/teams/{team}/tokens", get(list_tokens).post(issue_token))
506 .route(
507 "/teams/{team}/tokens/{id}",
508 axum::routing::delete(revoke_token),
509 )
510 .route("/credentials", get(list_credentials).post(grant_credential))
511 .route(
512 "/credentials/{id}",
513 axum::routing::delete(revoke_credential),
514 )
515 .layer(axum::middleware::from_fn_with_state(
516 state.clone(),
517 require_admin,
518 ))
519 .layer(RequestBodyLimitLayer::new(MAX_ADMIN_REQUEST_BYTES))
520 .layer(axum::middleware::from_fn(explain_rejections))
521 .with_state(state)
522}