Skip to main content

link_assistant_router/
admin_api.rs

1//! HTTP surface of the admin UI: the bootstrap claim, credential rotation,
2//! and read-only status, plus the router that serves them on the dedicated
3//! admin port.
4//!
5//! Everything under `/api/admin` except the three bootstrap routes requires the
6//! admin credential. The bootstrap routes carry their own rules — see
7//! [`crate::admin`] for the two-phase claim protocol.
8
9// The handlers here are `async fn` to match axum's handler signature even when
10// their bodies are synchronous. Mirrors the same allow in `crate::proxy`.
11#![allow(clippy::unused_async)]
12
13use std::sync::Arc;
14
15use axum::Router;
16use axum::extract::{Request, State};
17use axum::http::{HeaderMap, StatusCode};
18use axum::middleware::{Next, from_fn, from_fn_with_state};
19use axum::response::{IntoResponse, Response};
20use axum::routing::{get, post};
21
22use crate::admin::{AdminClaim, ClaimError};
23use crate::proxy::{AppState, error_response};
24use crate::{provider_proxy, proxy, token_admin};
25
26/// Routes that must stay reachable without an admin credential, because they
27/// are how a credential comes into existence in the first place.
28const OPEN_PATHS: &[&str] = &[
29    "/api/admin/status",
30    "/api/admin/bootstrap",
31    "/api/admin/bootstrap/confirm",
32];
33
34/// Build the admin-port router: the bootstrap/status API, the admin-only
35/// management API, and the embedded React UI.
36pub fn router(state: AppState) -> Router {
37    Router::new()
38        .route("/api/admin/status", get(admin_status))
39        .route("/api/admin/bootstrap", post(bootstrap))
40        .route("/api/admin/bootstrap/confirm", post(bootstrap_confirm))
41        .route("/api/admin/rotate", post(rotate_credential))
42        .route("/api/admin/summary", get(admin_summary))
43        .route("/api/admin/usage", get(proxy::usage_endpoint))
44        .route("/api/admin/accounts", get(proxy::accounts_endpoint))
45        .route(
46            "/api/tokens",
47            post(token_admin::issue_token).get(token_admin::list_tokens),
48        )
49        .route("/api/tokens/list", get(token_admin::list_tokens))
50        .route("/api/tokens/revoke", post(token_admin::revoke_token))
51        .route(
52            "/api/tokens/rotate-client",
53            post(token_admin::rotate_client_token),
54        )
55        .route("/api/providers", get(provider_proxy::list_providers))
56        .route_layer(from_fn_with_state(state.clone(), require_admin))
57        .fallback(crate::admin_ui::serve_asset)
58        // Outermost, so the UI assets and the error responses of the auth
59        // middleware are hardened too — see [`crate::security_headers`].
60        .layer(from_fn(crate::security_headers::apply))
61        .with_state(state)
62}
63
64/// Reject every admin-port API request that does not carry the admin
65/// credential, except the bootstrap routes and the UI assets themselves.
66///
67/// Both ports now run the same rule ([`crate::proxy::is_admin_authorised`]):
68/// the claimed credential, any admin-scoped `la_sk_…` JWT, or the flat
69/// `TOKEN_ADMIN_KEY`. One credential model means an administrator does not
70/// need a different token depending on which port they reach for.
71async fn require_admin(State(state): State<AppState>, request: Request, next: Next) -> Response {
72    let path = request.uri().path();
73    let is_api = path.starts_with("/api/");
74    if !is_api || OPEN_PATHS.contains(&path) {
75        return next.run(request).await;
76    }
77    if proxy::is_admin_authorised(&state, request.headers()) {
78        return next.run(request).await;
79    }
80    error_response(
81        StatusCode::UNAUTHORIZED,
82        "authentication_error",
83        "admin credential required",
84    )
85}
86
87fn bearer(headers: &HeaderMap) -> Option<&str> {
88    headers
89        .get("authorization")
90        .and_then(|value| value.to_str().ok())
91        .and_then(|value| value.strip_prefix("Bearer "))
92}
93
94/// `GET /api/admin/status` — is admin claimed, and may bootstrap run?
95pub async fn admin_status(State(state): State<AppState>) -> impl IntoResponse {
96    (StatusCode::OK, axum::Json(state.admin.status())).into_response()
97}
98
99/// `POST /api/admin/bootstrap` — phase 1 of the claim.
100///
101/// Mints a candidate admin JWT. Bootstrap stays **open**: the token is minted
102/// revoked, so it is not valid for anything until the client confirms it.
103///
104/// An optional `{"ttl_hours": n}` body lets the first administrator choose the
105/// credential lifetime; it is clamped to
106/// [`crate::admin::DEFAULT_CLAIM_TTL_HOURS`].
107pub async fn bootstrap(
108    State(state): State<AppState>,
109    body: Option<axum::Json<TtlRequest>>,
110) -> impl IntoResponse {
111    let ttl_hours = body.and_then(|axum::Json(request)| request.ttl_hours);
112    match state.admin.begin_with_ttl(ttl_hours) {
113        Ok(candidate) => (
114            StatusCode::OK,
115            axum::Json(serde_json::json!({
116                "claim_id": candidate.claim_id,
117                "token": candidate.token,
118                "expires_in_secs": candidate.expires_in_secs,
119                "ttl_hours": candidate.ttl_hours,
120                "confirm_url": "/api/admin/bootstrap/confirm",
121            })),
122        )
123            .into_response(),
124        Err(e) => claim_error_response(e),
125    }
126}
127
128/// `POST /api/admin/bootstrap/confirm` — phase 2 of the claim.
129///
130/// The request must be authenticated with the candidate token itself; that is
131/// the proof the client stored it. Only this call closes bootstrap.
132pub async fn bootstrap_confirm(
133    State(state): State<AppState>,
134    headers: HeaderMap,
135    axum::Json(req): axum::Json<ConfirmRequest>,
136) -> impl IntoResponse {
137    let Some(token) = bearer(&headers) else {
138        return error_response(
139            StatusCode::UNAUTHORIZED,
140            "authentication_error",
141            "confirm must present the candidate token as a Bearer credential",
142        );
143    };
144    match state.admin.confirm(&req.claim_id, token) {
145        Ok(()) => (
146            StatusCode::OK,
147            axum::Json(serde_json::json!({"claimed": true})),
148        )
149            .into_response(),
150        Err(e) => claim_error_response(e),
151    }
152}
153
154/// `POST /api/admin/rotate` — issue a replacement admin credential and retire
155/// the current one. Requires the current credential.
156pub async fn rotate_credential(
157    State(state): State<AppState>,
158    body: Option<axum::Json<TtlRequest>>,
159) -> impl IntoResponse {
160    let ttl_hours = body.and_then(|axum::Json(request)| request.ttl_hours);
161    match state.admin.rotate_with_ttl(ttl_hours) {
162        Ok(token) => {
163            let status = state.admin.status();
164            (
165                StatusCode::OK,
166                axum::Json(serde_json::json!({
167                    "token": token,
168                    "token_id": status.token_id,
169                    "credential_kind": status.credential_kind,
170                })),
171            )
172                .into_response()
173        }
174        Err(e) => claim_error_response(e),
175    }
176}
177
178/// Optional body carrying an administrator-chosen credential lifetime.
179#[derive(Debug, Default, serde::Deserialize)]
180pub struct TtlRequest {
181    /// Requested lifetime in hours; clamped by the claim.
182    #[serde(default)]
183    pub ttl_hours: Option<i64>,
184}
185
186/// `GET /api/admin/summary` — `doctor`-style read-only view of what the router
187/// is wired to. Requires the admin credential.
188pub async fn admin_summary(State(state): State<AppState>) -> impl IntoResponse {
189    let accounts = state
190        .account_router
191        .as_ref()
192        .map_or(0, crate::accounts::AccountRouter::len);
193    let credential = state
194        .oauth_provider
195        .discover_credential_path()
196        .map(|path| path.display().to_string());
197    let subscription = state.subscription_reader.as_ref().map(|reader| {
198        serde_json::json!({
199            "home": reader.home().display().to_string(),
200            "credential_found": reader.discover_credential_path().is_some(),
201        })
202    });
203    let admin_status = state.admin.status();
204    (
205        StatusCode::OK,
206        axum::Json(serde_json::json!({
207            "version": crate::VERSION,
208            "upstream_provider": state.upstream_provider.as_str(),
209            "upstream_base_url": state.upstream_base_url,
210            "accounts": accounts,
211            "claude_credential": credential,
212            "subscription": subscription,
213            "login_api_enabled": state.login_manager.is_enabled(),
214            "admin": admin_status,
215        })),
216    )
217        .into_response()
218}
219
220/// Map a claim-protocol failure onto an HTTP response.
221///
222/// `409 Conflict` is used for "already claimed" so a client can tell a closed
223/// bootstrap apart from a bad credential (`401`).
224fn claim_error_response(error: ClaimError) -> Response {
225    let (status, kind) = match error {
226        ClaimError::AlreadyClaimed | ClaimError::ProvisionedByEnvironment => {
227            (StatusCode::CONFLICT, "already_claimed")
228        }
229        ClaimError::NoCandidate | ClaimError::ClaimIdMismatch => {
230            (StatusCode::BAD_REQUEST, "invalid_request_error")
231        }
232        ClaimError::TokenMismatch => (StatusCode::UNAUTHORIZED, "authentication_error"),
233        ClaimError::Storage => (StatusCode::INTERNAL_SERVER_ERROR, "api_error"),
234    };
235    error_response(status, kind, &error.to_string())
236}
237
238/// Body of `POST /api/admin/bootstrap/confirm`.
239#[derive(serde::Deserialize)]
240pub struct ConfirmRequest {
241    /// The `claim_id` returned by the mint call.
242    pub claim_id: String,
243}
244
245/// Convenience accessor used by `main` when starting the admin listener.
246#[must_use]
247pub fn admin_handle(state: &AppState) -> Arc<AdminClaim> {
248    Arc::clone(&state.admin)
249}