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 resource_metadata_url: None,
205 })
206 }
207
208 fn make_state() -> AdminState {
209 AdminState {
210 started_at: Instant::now(),
211 name: "test".into(),
212 version: "0.0.0".into(),
213 auth: Some(make_auth_state()),
214 rbac: Arc::new(ArcSwap::from_pointee(RbacPolicy::new(
215 &RbacConfig::with_roles(vec![RoleConfig::new(
216 "admin",
217 vec!["*".into()],
218 vec!["*".into()],
219 )]),
220 ))),
221 }
222 }
223
224 fn admin_req(uri: &str, role: Option<&str>) -> Request<Body> {
225 let mut req = Request::builder().uri(uri).body(Body::empty()).unwrap();
226 if let Some(r) = role {
227 req.extensions_mut().insert(AuthIdentity {
228 name: "tester".into(),
229 role: r.to_owned(),
230 method: AuthMethod::BearerToken,
231 raw_token: None,
232 sub: None,
233 });
234 }
235 req
236 }
237
238 #[tokio::test]
239 async fn keys_endpoint_omits_hash() {
240 let app = admin_router(make_state(), &AdminConfig::default());
241 let resp = app
242 .oneshot(admin_req("/admin/auth/keys", Some("admin")))
243 .await
244 .unwrap();
245 assert_eq!(resp.status(), StatusCode::OK);
246 let body = axum::body::to_bytes(resp.into_body(), 64 * 1024)
247 .await
248 .unwrap();
249 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
250 let arr = json.as_array().unwrap();
251 assert_eq!(arr.len(), 1);
252 assert_eq!(arr[0]["name"], "test-key");
253 assert!(arr[0].get("hash").is_none());
254 }
255
256 #[tokio::test]
257 async fn wrong_role_gets_403() {
258 let app = admin_router(make_state(), &AdminConfig::default());
259 let resp = app
260 .oneshot(admin_req("/admin/status", Some("viewer")))
261 .await
262 .unwrap();
263 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
264 }
265
266 #[tokio::test]
267 async fn no_identity_gets_403() {
268 let app = admin_router(make_state(), &AdminConfig::default());
269 let resp = app.oneshot(admin_req("/admin/status", None)).await.unwrap();
270 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
271 }
272
273 #[tokio::test]
274 async fn status_returns_uptime() {
275 let app = admin_router(make_state(), &AdminConfig::default());
276 let resp = app
277 .oneshot(admin_req("/admin/status", Some("admin")))
278 .await
279 .unwrap();
280 assert_eq!(resp.status(), StatusCode::OK);
281 }
282
283 #[tokio::test]
284 async fn rbac_summary_includes_role_list() {
285 let app = admin_router(make_state(), &AdminConfig::default());
286 let resp = app
287 .oneshot(admin_req("/admin/rbac", Some("admin")))
288 .await
289 .unwrap();
290 assert_eq!(resp.status(), StatusCode::OK);
291 let body = axum::body::to_bytes(resp.into_body(), 64 * 1024)
292 .await
293 .unwrap();
294 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
295 assert_eq!(json["enabled"], true);
296 assert_eq!(json["roles"][0]["name"], "admin");
297 }
298}