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