Skip to main content

ai_crew_sync/
admin_api.rs

1//! Remote administration: `/admin/*`.
2//!
3//! A plain JSON API, deliberately **not** MCP tools. Administration must not
4//! appear in any agent's tool catalogue, and an agent token must never be
5//! able to mint a credential — not even for its own agent. The only bearer
6//! this surface accepts is an administrative credential ([`AdminCtx`]),
7//! which in turn cannot do anything on `/mcp`.
8//!
9//! Every permission decision is made here, from the credential alone. The
10//! team in a path is a slug the credential must be allowed to administer;
11//! the request body, a UUID or a query string can never widen that.
12//!
13//! Same shape as the MCP surface: stateless, one bearer header, so a bare
14//! `curl` always works.
15
16use 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
39/// The `/admin` rate limit is this fraction of the MCP one
40/// (`BUS_RATE_LIMIT_PER_MINUTE`): 600/min for agents means 60/min for
41/// administration. Administration is a handful of calls a day, so a leaked or
42/// looping credential cannot mint at speed, and there is no second knob to
43/// forget. Setting the MCP limit to 0 disables both.
44pub const ADMIN_RATE_LIMIT_DIVISOR: u32 = 10;
45
46/// Largest `/admin` request body. Names and labels; nothing here is a
47/// document.
48pub 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/// Errors on this surface, rendered as `{"error": "..."}` with a status.
57#[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                // Never leak SQL/connection detail; log it instead.
78                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
126/// Extractor rejections become the same `{"error": …}` shape as everything
127/// else on this surface. Handlers take `Result<Json<T>, JsonRejection>` and
128/// `Result<Path<T>, PathRejection>` and unwrap them through these.
129impl 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
144/// What a JSON body or a path yields once its rejection is mapped.
145type Body<T> = Result<Json<T>, JsonRejection>;
146type Params<T> = Result<Path<T>, PathRejection>;
147
148/// Middleware: only an active administrative credential gets through. An
149/// agent token is refused with a message that says what to use instead.
150async 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    // Charged before the lookup, on the hash: a flood of bad credentials
189    // costs no query either.
190    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
210/// The credential the middleware resolved. Absent only if a route was mounted
211/// outside the middleware, which is a programming error worth failing loudly
212/// on — as 401, never as an open door.
213fn 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
230/// Resolve the team a request names, as the credential is allowed to see it.
231/// A team credential only ever gets its own team back, whatever the path
232/// says, and learns nothing about whether another slug exists.
233async 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
249// --------------------------------------------------------------- handlers --
250
251async 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    // A team credential sees exactly its own team, never the roster — and
266    // never pays for it either: one row by id, not a scan of every team.
267    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
357/// The one response on this surface that carries a secret. It is returned
358/// here and nowhere else: not logged, not audited, not published.
359async 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    // Always scoped to the resolved team, a global credential included: the
399    // path names the team, and a token elsewhere is not found here.
400    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    // A team credential lists its own team's; a global one lists everything.
411    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 slug, or absent/null for a global credential.
418    team: Option<String>,
419    label: Option<String>,
420}
421
422/// The other response carrying a secret. Global administrators only: a team
423/// credential can neither widen itself nor mint another of its kind.
424async 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    // Absent or null means global. An explicit empty string is a mistake,
433    // not a request for the widest scope there is.
434    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    // A team credential may revoke within its team (itself included); a
464    // global or foreign credential is not found from there.
465    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
470/// The body-size layer answers with a bare 413 before any extractor runs;
471/// say so in the same shape as every other error here. Extractor rejections
472/// (malformed JSON, a bad UUID) are mapped in the handlers themselves.
473async 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
484/// The `/admin` router, to be nested at that path. Generic over the outer
485/// state so it merges into the main router whatever that carries; it needs
486/// none of it.
487pub fn router<S: Clone + Send + Sync + 'static>(
488    pool: PgPool,
489    mcp_rate_limit_per_minute: u32,
490) -> Router<S> {
491    // Only 0 disables: a small but non-zero MCP limit still leaves /admin
492    // limited, at one request a minute if it comes to that.
493    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}