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(
177 clippy::unwrap_used,
178 clippy::expect_used,
179 reason = "test-only relaxations; production code uses ? and tracing"
180 )]
181
182 use axum::http::Request;
183 use tower::ServiceExt as _;
184
185 use super::*;
186 use crate::{
187 auth::{ApiKeyEntry, AuthCounters, AuthIdentity, AuthMethod, AuthState},
188 rbac::{RbacConfig, RbacPolicy, RoleConfig},
189 };
190
191 fn make_auth_state() -> Arc<AuthState> {
192 Arc::new(AuthState {
193 api_keys: ArcSwap::from_pointee(vec![ApiKeyEntry::new(
194 "test-key",
195 "argon2id-hash",
196 "admin",
197 )]),
198 rate_limiter: None,
199 pre_auth_limiter: None,
200 #[cfg(feature = "oauth")]
201 jwks_cache: None,
202 seen_identities: crate::auth::SeenIdentitySet::new(),
203 counters: AuthCounters::default(),
204 })
205 }
206
207 fn make_state() -> AdminState {
208 AdminState {
209 started_at: Instant::now(),
210 name: "test".into(),
211 version: "0.0.0".into(),
212 auth: Some(make_auth_state()),
213 rbac: Arc::new(ArcSwap::from_pointee(RbacPolicy::new(
214 &RbacConfig::with_roles(vec![RoleConfig::new(
215 "admin",
216 vec!["*".into()],
217 vec!["*".into()],
218 )]),
219 ))),
220 }
221 }
222
223 fn admin_req(uri: &str, role: Option<&str>) -> Request<Body> {
224 let mut req = Request::builder().uri(uri).body(Body::empty()).unwrap();
225 if let Some(r) = role {
226 req.extensions_mut().insert(AuthIdentity {
227 name: "tester".into(),
228 role: r.to_owned(),
229 method: AuthMethod::BearerToken,
230 raw_token: None,
231 sub: None,
232 });
233 }
234 req
235 }
236
237 #[tokio::test]
238 async fn keys_endpoint_omits_hash() {
239 let app = admin_router(make_state(), &AdminConfig::default());
240 let resp = app
241 .oneshot(admin_req("/admin/auth/keys", Some("admin")))
242 .await
243 .unwrap();
244 assert_eq!(resp.status(), StatusCode::OK);
245 let body = axum::body::to_bytes(resp.into_body(), 64 * 1024)
246 .await
247 .unwrap();
248 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
249 let arr = json.as_array().unwrap();
250 assert_eq!(arr.len(), 1);
251 assert_eq!(arr[0]["name"], "test-key");
252 assert!(arr[0].get("hash").is_none());
253 }
254
255 #[tokio::test]
256 async fn wrong_role_gets_403() {
257 let app = admin_router(make_state(), &AdminConfig::default());
258 let resp = app
259 .oneshot(admin_req("/admin/status", Some("viewer")))
260 .await
261 .unwrap();
262 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
263 }
264
265 #[tokio::test]
266 async fn no_identity_gets_403() {
267 let app = admin_router(make_state(), &AdminConfig::default());
268 let resp = app.oneshot(admin_req("/admin/status", None)).await.unwrap();
269 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
270 }
271
272 #[tokio::test]
273 async fn status_returns_uptime() {
274 let app = admin_router(make_state(), &AdminConfig::default());
275 let resp = app
276 .oneshot(admin_req("/admin/status", Some("admin")))
277 .await
278 .unwrap();
279 assert_eq!(resp.status(), StatusCode::OK);
280 }
281
282 #[tokio::test]
283 async fn rbac_summary_includes_role_list() {
284 let app = admin_router(make_state(), &AdminConfig::default());
285 let resp = app
286 .oneshot(admin_req("/admin/rbac", Some("admin")))
287 .await
288 .unwrap();
289 assert_eq!(resp.status(), StatusCode::OK);
290 let body = axum::body::to_bytes(resp.into_body(), 64 * 1024)
291 .await
292 .unwrap();
293 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
294 assert_eq!(json["enabled"], true);
295 assert_eq!(json["roles"][0]["name"], "admin");
296 }
297}