1use 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#[derive(Clone, Debug)]
33#[non_exhaustive]
34pub struct AdminConfig {
35 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#[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 pub started_at: Instant,
57 pub name: String,
59 pub version: String,
61 pub auth: Option<Arc<AuthState>>,
63 pub rbac: Arc<ArcSwap<RbacPolicy>>,
65}
66
67#[derive(Debug, Clone, Serialize)]
69#[non_exhaustive]
70pub struct AdminStatus {
71 pub name: String,
73 pub version: String,
75 pub uptime_seconds: u64,
77 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
128pub 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
155pub(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}