1use 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#[derive(Clone, Debug)]
42#[non_exhaustive]
43pub struct AdminConfig {
44 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#[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 pub started_at: Instant,
66 pub name: String,
68 pub version: String,
70 pub auth: Option<Arc<AuthState>>,
72 pub rbac: Arc<ArcSwap<RbacPolicy>>,
74}
75
76#[derive(Debug, Clone, Serialize)]
78#[non_exhaustive]
79pub struct AdminStatus {
80 pub name: String,
82 pub version: String,
84 pub uptime_seconds: u64,
86 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
137pub 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
164pub(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}