Skip to main content

rmcp_server_kit/
admin.rs

1//! Admin diagnostic endpoints.
2//!
3//! When enabled, the server exposes a small `/admin/*` surface that returns
4//! read-only diagnostic JSON: uptime, active auth configuration (no
5//! secrets), auth counters, and an RBAC policy summary.
6//!
7//! The admin router is always wrapped in the existing auth + RBAC stack
8//! and additionally requires the caller's role to match the `role` field
9//! on [`crate::admin::AdminConfig`]. Configuration validation refuses to
10//! enable admin without auth.
11//!
12//! # Cancel safety
13//!
14//! Admin handlers are cancel-safe with respect to admin state: they only
15//! read in-memory `Arc` / `ArcSwap` state and build JSON responses.
16//! [`crate::admin::require_admin_role`] performs its role check before
17//! `next.run(req).await` and holds no guard, lock, or permit across that
18//! await; downstream route cancel safety is inherited from Axum and the
19//! selected route.
20
21use std::{
22    sync::Arc,
23    time::{Instant, SystemTime, UNIX_EPOCH},
24};
25
26use arc_swap::ArcSwap;
27use axum::{
28    Json, Router,
29    body::Body,
30    extract::{Request, State},
31    http::StatusCode,
32    middleware::Next,
33    response::{IntoResponse, Response},
34    routing::get,
35};
36use serde::Serialize;
37
38use crate::{auth::AuthState, rbac::RbacPolicy};
39
40/// Admin endpoint configuration.
41#[derive(Clone, Debug)]
42#[non_exhaustive]
43pub struct AdminConfig {
44    /// RBAC role required to access the admin endpoints.
45    pub role: String,
46}
47
48impl Default for AdminConfig {
49    fn default() -> Self {
50        Self {
51            role: "admin".to_owned(),
52        }
53    }
54}
55
56/// Shared state used by admin endpoint handlers.
57#[allow(
58    missing_debug_implementations,
59    reason = "contains Arc<AuthState> and ArcSwap<RbacPolicy> without Debug impls"
60)]
61#[derive(Clone)]
62#[non_exhaustive]
63pub(crate) struct AdminState {
64    /// Server start instant, used for uptime.
65    pub started_at: Instant,
66    /// Server name for /admin/status.
67    pub name: String,
68    /// Server version for /admin/status.
69    pub version: String,
70    /// Shared auth state (optional for test constructions).
71    pub auth: Option<Arc<AuthState>>,
72    /// Shared RBAC policy for diagnostics.
73    pub rbac: Arc<ArcSwap<RbacPolicy>>,
74}
75
76/// `/admin/status` response body.
77#[derive(Debug, Clone, Serialize)]
78#[non_exhaustive]
79pub struct AdminStatus {
80    /// Server name.
81    pub name: String,
82    /// Server version string.
83    pub version: String,
84    /// Seconds since the server process started.
85    pub uptime_seconds: u64,
86    /// Wall-clock UNIX epoch at startup.
87    pub started_at_epoch: u64,
88}
89
90fn admin_status(state: &AdminState) -> AdminStatus {
91    let started_epoch = SystemTime::now()
92        .duration_since(UNIX_EPOCH)
93        .map(|d| d.as_secs())
94        .unwrap_or_default()
95        .saturating_sub(state.started_at.elapsed().as_secs());
96    AdminStatus {
97        name: state.name.clone(),
98        version: state.version.clone(),
99        uptime_seconds: state.started_at.elapsed().as_secs(),
100        started_at_epoch: started_epoch,
101    }
102}
103
104async fn status_handler(State(state): State<AdminState>) -> Json<AdminStatus> {
105    Json(admin_status(&state))
106}
107
108async fn auth_keys_handler(State(state): State<AdminState>) -> Response {
109    state.auth.as_ref().map_or_else(
110        || not_available("auth is not configured"),
111        |auth| Json(auth.api_key_summaries()).into_response(),
112    )
113}
114
115async fn auth_counters_handler(State(state): State<AdminState>) -> Response {
116    state.auth.as_ref().map_or_else(
117        || not_available("auth is not configured"),
118        |auth| Json(auth.counters_snapshot()).into_response(),
119    )
120}
121
122async fn rbac_handler(State(state): State<AdminState>) -> Response {
123    Json(state.rbac.load().summary()).into_response()
124}
125
126fn not_available(reason: &str) -> Response {
127    (
128        StatusCode::SERVICE_UNAVAILABLE,
129        Json(serde_json::json!({
130            "error": "unavailable",
131            "error_description": reason,
132        })),
133    )
134        .into_response()
135}
136
137/// Role-check middleware for admin routes.
138///
139/// Reads the caller's role from the `AuthIdentity` request extension
140/// (populated by the outer auth middleware) and rejects requests whose
141/// role does not match `expected_role`.
142pub async fn require_admin_role(
143    expected_role: Arc<str>,
144    req: Request<Body>,
145    next: Next,
146) -> Response {
147    let role = req
148        .extensions()
149        .get::<crate::auth::AuthIdentity>()
150        .map_or("", |id| id.role.as_str());
151    if role != expected_role.as_ref() {
152        return (
153            StatusCode::FORBIDDEN,
154            Json(serde_json::json!({
155                "error": "forbidden",
156                "error_description": "admin role required",
157            })),
158        )
159            .into_response();
160    }
161    next.run(req).await
162}
163
164/// Build the `/admin` router layered with the admin role check.
165///
166/// The caller is expected to merge this router on top of their top-level
167/// router *after* the auth + RBAC middleware has been installed, so that
168/// by the time a request reaches this router the task-local role is set.
169pub(crate) fn admin_router(state: AdminState, config: &AdminConfig) -> Router {
170    let role: Arc<str> = Arc::from(config.role.as_str());
171    Router::new()
172        .route("/admin/status", get(status_handler))
173        .route("/admin/auth/keys", get(auth_keys_handler))
174        .route("/admin/auth/counters", get(auth_counters_handler))
175        .route("/admin/rbac", get(rbac_handler))
176        .with_state(state)
177        .layer(axum::middleware::from_fn(move |req, next| {
178            let r = Arc::clone(&role);
179            require_admin_role(r, req, next)
180        }))
181}
182
183#[cfg(test)]
184mod tests {
185    #![allow(
186        clippy::unwrap_used,
187        clippy::expect_used,
188        reason = "test-only relaxations; production code uses ? and tracing"
189    )]
190
191    use axum::http::Request;
192    use tower::ServiceExt as _;
193
194    use super::*;
195    use crate::{
196        auth::{ApiKeyEntry, AuthCounters, AuthIdentity, AuthMethod, AuthState},
197        rbac::{RbacConfig, RbacPolicy, RoleConfig},
198    };
199
200    fn make_auth_state() -> Arc<AuthState> {
201        Arc::new(AuthState {
202            api_keys: ArcSwap::from_pointee(vec![ApiKeyEntry::new(
203                "test-key",
204                "argon2id-hash",
205                "admin",
206            )]),
207            rate_limiter: None,
208            pre_auth_limiter: None,
209            #[cfg(feature = "oauth")]
210            jwks_cache: None,
211            seen_identities: crate::auth::SeenIdentitySet::new(),
212            counters: AuthCounters::default(),
213            resource_metadata_url: None,
214        })
215    }
216
217    fn make_state() -> AdminState {
218        AdminState {
219            started_at: Instant::now(),
220            name: "test".into(),
221            version: "0.0.0".into(),
222            auth: Some(make_auth_state()),
223            rbac: Arc::new(ArcSwap::from_pointee(RbacPolicy::new(
224                &RbacConfig::with_roles(vec![RoleConfig::new(
225                    "admin",
226                    vec!["*".into()],
227                    vec!["*".into()],
228                )]),
229            ))),
230        }
231    }
232
233    fn admin_req(uri: &str, role: Option<&str>) -> Request<Body> {
234        let mut req = Request::builder().uri(uri).body(Body::empty()).unwrap();
235        if let Some(r) = role {
236            req.extensions_mut().insert(AuthIdentity {
237                name: "tester".into(),
238                role: r.to_owned(),
239                method: AuthMethod::BearerToken,
240                raw_token: None,
241                sub: None,
242            });
243        }
244        req
245    }
246
247    #[tokio::test]
248    async fn keys_endpoint_omits_hash() {
249        let app = admin_router(make_state(), &AdminConfig::default());
250        let resp = app
251            .oneshot(admin_req("/admin/auth/keys", Some("admin")))
252            .await
253            .unwrap();
254        assert_eq!(resp.status(), StatusCode::OK);
255        let body = axum::body::to_bytes(resp.into_body(), 64 * 1024)
256            .await
257            .unwrap();
258        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
259        let arr = json.as_array().unwrap();
260        assert_eq!(arr.len(), 1);
261        assert_eq!(arr[0]["name"], "test-key");
262        assert!(arr[0].get("hash").is_none());
263    }
264
265    #[tokio::test]
266    async fn wrong_role_gets_403() {
267        let app = admin_router(make_state(), &AdminConfig::default());
268        let resp = app
269            .oneshot(admin_req("/admin/status", Some("viewer")))
270            .await
271            .unwrap();
272        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
273    }
274
275    #[tokio::test]
276    async fn no_identity_gets_403() {
277        let app = admin_router(make_state(), &AdminConfig::default());
278        let resp = app.oneshot(admin_req("/admin/status", None)).await.unwrap();
279        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
280    }
281
282    #[tokio::test]
283    async fn status_returns_uptime() {
284        let app = admin_router(make_state(), &AdminConfig::default());
285        let resp = app
286            .oneshot(admin_req("/admin/status", Some("admin")))
287            .await
288            .unwrap();
289        assert_eq!(resp.status(), StatusCode::OK);
290    }
291
292    #[tokio::test]
293    async fn rbac_summary_includes_role_list() {
294        let app = admin_router(make_state(), &AdminConfig::default());
295        let resp = app
296            .oneshot(admin_req("/admin/rbac", Some("admin")))
297            .await
298            .unwrap();
299        assert_eq!(resp.status(), StatusCode::OK);
300        let body = axum::body::to_bytes(resp.into_body(), 64 * 1024)
301            .await
302            .unwrap();
303        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
304        assert_eq!(json["enabled"], true);
305        assert_eq!(json["roles"][0]["name"], "admin");
306    }
307}