1use autumn_web::auth::{generate_raw_token, hash_api_token, scopes_from_json};
9use chrono::{DateTime, NaiveDateTime, Utc};
10use serde_json::Value;
11
12use crate::{
13 AdminError, AdminField, AdminFieldKind, AdminFuture, AdminModel, ListParams, ListResult,
14};
15
16#[derive(Debug, Default, Clone)]
40pub struct TokenAdminModel;
41
42impl AdminModel for TokenAdminModel {
43 fn slug(&self) -> &'static str {
44 "api-tokens"
45 }
46
47 fn display_name(&self) -> &'static str {
48 "API Token"
49 }
50
51 fn display_name_plural(&self) -> &'static str {
52 "API Tokens"
53 }
54
55 fn fields(&self) -> Vec<AdminField> {
56 vec![
57 AdminField::new("name", AdminFieldKind::Text)
58 .label("Name")
59 .searchable(),
60 AdminField::new("principal_id", AdminFieldKind::Text)
61 .label("Principal")
62 .searchable()
63 .create_only(),
64 AdminField::new("scopes", AdminFieldKind::TextArea)
65 .label("Scopes (JSON array)")
66 .optional(),
67 AdminField::new("expires_at", AdminFieldKind::DateTime)
68 .label("Expires At (UTC)")
69 .optional()
70 .create_only(),
71 AdminField::new("last_used_at", AdminFieldKind::DateTime)
72 .label("Last Used")
73 .readonly()
74 .optional()
75 .hide_from_list(),
76 AdminField::new("revoked_at", AdminFieldKind::DateTime)
77 .label("Revoked At")
78 .readonly()
79 .optional(),
80 AdminField::new("token", AdminFieldKind::Text)
83 .label("Token (copy now — shown once)")
84 .readonly()
85 .optional()
86 .hide_from_list(),
87 ]
88 }
89
90 fn record_display(&self, record: &Value) -> String {
91 record
92 .get("name")
93 .and_then(Value::as_str)
94 .filter(|n| !n.is_empty())
95 .map_or_else(|| "API Token".to_owned(), |n| format!("Token: {n}"))
96 }
97
98 fn list(
99 &self,
100 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
101 params: ListParams,
102 ) -> AdminFuture<'_, ListResult> {
103 use diesel_async::RunQueryDsl;
104
105 let pool = pool.clone();
106 Box::pin(async move {
107 crate::traits::require_postgres("TokenAdminModel")?;
108 let mut conn = pool
109 .get()
110 .await
111 .map_err(|e| AdminError::Database(e.to_string()))?;
112
113 let per_page = params.per_page;
114 let (offset, limit) = params.sql_offset_limit();
115 let search_pattern = format!("%{}%", params.search.as_deref().unwrap_or(""));
116
117 let total: i64 = diesel::sql_query(
118 "SELECT COUNT(*) FROM api_tokens \
119 WHERE (name ILIKE $1 OR principal_id ILIKE $1)",
120 )
121 .bind::<diesel::sql_types::Text, _>(&search_pattern)
122 .get_result::<CountRow>(&mut conn)
123 .await
124 .map_or(0, |r| r.count);
125
126 let records: Vec<Value> = diesel::sql_query(
127 "SELECT id, name, principal_id, scopes::text AS scopes, created_at, \
128 expires_at, last_used_at, revoked_at \
129 FROM api_tokens \
130 WHERE (name ILIKE $1 OR principal_id ILIKE $1) \
131 ORDER BY id DESC \
132 LIMIT $2 OFFSET $3",
133 )
134 .bind::<diesel::sql_types::Text, _>(&search_pattern)
135 .bind::<diesel::sql_types::BigInt, _>(limit)
136 .bind::<diesel::sql_types::BigInt, _>(offset)
137 .load::<TokenRow>(&mut conn)
138 .await
139 .map(|rows| rows.into_iter().map(TokenRow::into_json).collect())
140 .map_err(|e| AdminError::Database(e.to_string()))?;
141
142 Ok(ListResult {
143 total: u64::try_from(total).unwrap_or(0),
144 page: params.page,
145 per_page,
146 records,
147 })
148 })
149 }
150
151 fn get(
152 &self,
153 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
154 id: i64,
155 ) -> AdminFuture<'_, Option<Value>> {
156 use diesel::OptionalExtension as _;
157 use diesel_async::RunQueryDsl;
158
159 let pool = pool.clone();
160 Box::pin(async move {
161 crate::traits::require_postgres("TokenAdminModel")?;
162 let mut conn = pool
163 .get()
164 .await
165 .map_err(|e| AdminError::Database(e.to_string()))?;
166 diesel::sql_query(
167 "SELECT id, name, principal_id, scopes::text AS scopes, created_at, \
168 expires_at, last_used_at, revoked_at \
169 FROM api_tokens WHERE id = $1",
170 )
171 .bind::<diesel::sql_types::BigInt, _>(id)
172 .get_result::<TokenRow>(&mut conn)
173 .await
174 .optional()
175 .map(|r| r.map(TokenRow::into_json))
176 .map_err(|e| AdminError::Database(e.to_string()))
177 })
178 }
179
180 fn create(
181 &self,
182 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
183 data: Value,
184 ) -> AdminFuture<'_, Value> {
185 use diesel_async::RunQueryDsl;
186
187 let pool = pool.clone();
188 Box::pin(async move {
189 crate::traits::require_postgres("TokenAdminModel")?;
190 let principal_id = data
191 .get("principal_id")
192 .and_then(Value::as_str)
193 .filter(|p| !p.is_empty())
194 .ok_or_else(|| AdminError::Validation("'principal_id' is required".into()))?;
195 let name = data.get("name").and_then(Value::as_str).unwrap_or("");
196 let scopes = parse_scopes(data.get("scopes"))?;
197 let expires_at = parse_expires_at(data.get("expires_at"))?;
198
199 let raw = generate_raw_token();
200 let hash = hash_api_token(&raw);
201 let scopes_json = serde_json::Value::Array(
202 scopes
203 .iter()
204 .map(|s| serde_json::Value::String(s.clone()))
205 .collect(),
206 )
207 .to_string();
208 let expires_at_naive = expires_at.map(|dt| dt.naive_utc());
209
210 let mut conn = pool
211 .get()
212 .await
213 .map_err(|e| AdminError::Database(e.to_string()))?;
214 let row: TokenRow = diesel::sql_query(
219 "INSERT INTO api_tokens (token_hash, principal_id, name, scopes, expires_at) \
220 VALUES ($1, $2, $3, $4::jsonb, $5) \
221 RETURNING id, name, principal_id, scopes::text AS scopes, \
222 created_at, expires_at, last_used_at, revoked_at",
223 )
224 .bind::<diesel::sql_types::Text, _>(&hash)
225 .bind::<diesel::sql_types::Text, _>(principal_id)
226 .bind::<diesel::sql_types::Text, _>(name)
227 .bind::<diesel::sql_types::Text, _>(&scopes_json)
228 .bind::<diesel::sql_types::Nullable<diesel::sql_types::Timestamp>, _>(expires_at_naive)
229 .get_result::<TokenRow>(&mut conn)
230 .await
231 .map_err(|e| AdminError::Database(e.to_string()))?;
232
233 let mut json = row.into_json();
234 if let Value::Object(map) = &mut json {
235 map.insert("token".to_owned(), Value::String(raw));
236 }
237 Ok(json)
238 })
239 }
240
241 fn update(
242 &self,
243 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
244 id: i64,
245 data: Value,
246 ) -> AdminFuture<'_, Value> {
247 use diesel_async::RunQueryDsl;
248
249 let pool = pool.clone();
250 Box::pin(async move {
251 crate::traits::require_postgres("TokenAdminModel")?;
252 let name = data.get("name").and_then(Value::as_str).unwrap_or("");
255 let scopes = parse_scopes(data.get("scopes"))?;
256 let scopes_json =
257 serde_json::Value::Array(scopes.into_iter().map(Value::String).collect())
258 .to_string();
259
260 let mut conn = pool
261 .get()
262 .await
263 .map_err(|e| AdminError::Database(e.to_string()))?;
264 diesel::sql_query("UPDATE api_tokens SET name = $1, scopes = $2::jsonb WHERE id = $3")
265 .bind::<diesel::sql_types::Text, _>(name)
266 .bind::<diesel::sql_types::Text, _>(&scopes_json)
267 .bind::<diesel::sql_types::BigInt, _>(id)
268 .execute(&mut conn)
269 .await
270 .map_err(|e| AdminError::Database(e.to_string()))?;
271
272 self.get(&pool, id)
273 .await?
274 .ok_or_else(|| AdminError::Validation(format!("token {id} not found")))
275 })
276 }
277
278 fn delete(
279 &self,
280 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
281 id: i64,
282 ) -> AdminFuture<'_, ()> {
283 use diesel_async::RunQueryDsl;
284
285 let pool = pool.clone();
286 Box::pin(async move {
287 crate::traits::require_postgres("TokenAdminModel")?;
288 let mut conn = pool
291 .get()
292 .await
293 .map_err(|e| AdminError::Database(e.to_string()))?;
294 diesel::sql_query(
295 "UPDATE api_tokens SET revoked_at = NOW() AT TIME ZONE 'utc' \
296 WHERE id = $1 AND revoked_at IS NULL",
297 )
298 .bind::<diesel::sql_types::BigInt, _>(id)
299 .execute(&mut conn)
300 .await
301 .map_err(|e| AdminError::Database(e.to_string()))?;
302 Ok(())
303 })
304 }
305
306 fn execute_action(
307 &self,
308 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
309 action: &str,
310 ids: Vec<i64>,
311 ) -> AdminFuture<'_, u64> {
312 if action == "delete" {
322 let pool = pool.clone();
323 return Box::pin(async move {
324 ::autumn_web::backend_select! {
333 pg => {{
334 use diesel_async::RunQueryDsl;
335
336 let mut conn = pool
351 .get()
352 .await
353 .map_err(|e| AdminError::Database(e.to_string()))?;
354 diesel::sql_query(
355 "UPDATE api_tokens SET revoked_at = NOW() AT TIME ZONE 'utc' \
356 WHERE id = ANY($1) AND revoked_at IS NULL",
357 )
358 .bind::<diesel::sql_types::Array<diesel::sql_types::BigInt>, _>(&ids)
359 .execute(&mut conn)
360 .await
361 .map_err(|e| AdminError::Database(e.to_string()))?;
362 Ok(u64::try_from(ids.len()).unwrap_or(u64::MAX))
363 }},
364 sqlite => {{
365 let _ = (&pool, &ids);
366 crate::traits::require_postgres("TokenAdminModel").map(|()| 0)
367 }},
368 }
369 });
370 }
371
372 crate::traits::dispatch_restore_purge_or_unhandled(self, pool, action, ids)
373 }
374}
375
376fn parse_scopes(value: Option<&Value>) -> Result<Vec<String>, AdminError> {
379 match value {
380 None | Some(Value::Null) => Ok(Vec::new()),
381 Some(v @ Value::Array(_)) => Ok(scopes_from_json(v)),
382 Some(Value::String(s)) => {
383 let trimmed = s.trim();
384 if trimmed.is_empty() {
385 return Ok(Vec::new());
386 }
387 let parsed: Vec<String> = serde_json::from_str(trimmed).map_err(|_| {
388 AdminError::Validation(
389 "'scopes' must be a JSON array of strings, e.g. [\"posts:read\"]".into(),
390 )
391 })?;
392 Ok(parsed)
393 }
394 Some(_) => Err(AdminError::Validation(
395 "'scopes' must be a JSON array of strings".into(),
396 )),
397 }
398}
399
400fn parse_expires_at(value: Option<&Value>) -> Result<Option<DateTime<Utc>>, AdminError> {
402 let Some(s) = value.and_then(Value::as_str) else {
403 return Ok(None);
404 };
405 let s = s.trim();
406 if s.is_empty() {
407 return Ok(None);
408 }
409 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
410 return Ok(Some(dt.with_timezone(&Utc)));
411 }
412 for fmt in ["%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M:%S"] {
413 if let Ok(naive) = NaiveDateTime::parse_from_str(s, fmt) {
414 return Ok(Some(DateTime::from_naive_utc_and_offset(naive, Utc)));
415 }
416 }
417 Err(AdminError::Validation(format!(
418 "'expires_at' is not a valid timestamp: {s}"
419 )))
420}
421
422#[derive(diesel::QueryableByName)]
423struct CountRow {
424 #[diesel(sql_type = diesel::sql_types::BigInt)]
425 count: i64,
426}
427
428#[derive(diesel::QueryableByName)]
429struct TokenRow {
430 #[diesel(sql_type = diesel::sql_types::BigInt)]
431 id: i64,
432 #[diesel(sql_type = diesel::sql_types::Text)]
433 name: String,
434 #[diesel(sql_type = diesel::sql_types::Text)]
435 principal_id: String,
436 #[diesel(sql_type = diesel::sql_types::Text)]
437 scopes: String,
438 #[diesel(sql_type = diesel::sql_types::Timestamp)]
439 created_at: NaiveDateTime,
440 #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Timestamp>)]
441 expires_at: Option<NaiveDateTime>,
442 #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Timestamp>)]
443 last_used_at: Option<NaiveDateTime>,
444 #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Timestamp>)]
445 revoked_at: Option<NaiveDateTime>,
446}
447
448impl TokenRow {
449 fn into_json(self) -> Value {
450 let scopes: Value = serde_json::from_str(&self.scopes).unwrap_or(Value::Array(Vec::new()));
451 serde_json::json!({
452 "id": self.id,
453 "name": self.name,
454 "principal_id": self.principal_id,
455 "scopes": scopes,
456 "created_at": self.created_at.and_utc().to_rfc3339(),
457 "expires_at": self.expires_at.map(|d| d.and_utc().to_rfc3339()),
458 "last_used_at": self.last_used_at.map(|d| d.and_utc().to_rfc3339()),
459 "revoked_at": self.revoked_at.map(|d| d.and_utc().to_rfc3339()),
460 })
461 }
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467
468 #[test]
469 fn model_metadata() {
470 let m = TokenAdminModel;
471 assert_eq!(m.slug(), "api-tokens");
472 assert_eq!(m.display_name_plural(), "API Tokens");
473 let field_names: Vec<_> = m.fields().iter().map(|f| f.name).collect();
474 assert!(field_names.contains(&"scopes"));
475 assert!(field_names.contains(&"principal_id"));
476 assert!(!field_names.contains(&"token_hash"));
478 }
479
480 #[test]
481 fn record_display_uses_name_when_present() {
482 let m = TokenAdminModel;
483 let with_name = serde_json::json!({"name": "ci-token"});
484 assert_eq!(m.record_display(&with_name), "Token: ci-token");
485 }
486
487 #[test]
488 fn record_display_falls_back_when_name_missing_or_empty() {
489 let m = TokenAdminModel;
490 assert_eq!(m.record_display(&serde_json::json!({})), "API Token");
491 assert_eq!(
492 m.record_display(&serde_json::json!({"name": ""})),
493 "API Token"
494 );
495 }
496
497 #[test]
498 fn parse_scopes_accepts_json_text_and_arrays() {
499 assert_eq!(parse_scopes(None).unwrap(), Vec::<String>::new());
500 assert_eq!(
502 parse_scopes(Some(&Value::Null)).unwrap(),
503 Vec::<String>::new()
504 );
505 assert_eq!(
507 parse_scopes(Some(&Value::String(String::new()))).unwrap(),
508 Vec::<String>::new()
509 );
510 assert_eq!(
511 parse_scopes(Some(&Value::String("[\"a\",\"b\"]".into()))).unwrap(),
512 vec!["a".to_owned(), "b".to_owned()]
513 );
514 assert_eq!(
515 parse_scopes(Some(&serde_json::json!(["x"]))).unwrap(),
516 vec!["x".to_owned()]
517 );
518 assert!(parse_scopes(Some(&Value::String("not json".into()))).is_err());
519 assert!(parse_scopes(Some(&Value::Bool(true))).is_err());
521 }
522
523 #[test]
524 fn parse_expires_at_handles_formats_and_empty() {
525 assert!(parse_expires_at(None).unwrap().is_none());
526 assert!(
527 parse_expires_at(Some(&Value::String(String::new())))
528 .unwrap()
529 .is_none()
530 );
531 assert!(
533 parse_expires_at(Some(&Value::String("2026-12-31T23:59:59Z".into())))
534 .unwrap()
535 .is_some()
536 );
537 assert!(
539 parse_expires_at(Some(&Value::String("2026-12-31T23:59:59".into())))
540 .unwrap()
541 .is_some()
542 );
543 assert!(
545 parse_expires_at(Some(&Value::String("2026-12-31T23:59".into())))
546 .unwrap()
547 .is_some()
548 );
549 assert!(parse_expires_at(Some(&Value::String("nonsense".into()))).is_err());
550 }
551
552 #[test]
553 fn token_row_into_json_omits_secret_and_parses_scopes() {
554 let row = TokenRow {
555 id: 7,
556 name: "ci".into(),
557 principal_id: "service:ci".into(),
558 scopes: "[\"posts:read\"]".into(),
559 created_at: NaiveDateTime::default(),
560 expires_at: None,
561 last_used_at: None,
562 revoked_at: None,
563 };
564 let json = row.into_json();
565 assert_eq!(json["id"], 7);
566 assert_eq!(json["scopes"], serde_json::json!(["posts:read"]));
567 assert!(json.get("token_hash").is_none());
568 assert!(json.get("token").is_none());
569 }
570
571 fn dummy_pool()
579 -> diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection> {
580 use diesel_async::pooled_connection::AsyncDieselConnectionManager;
581 use diesel_async::pooled_connection::deadpool::Pool;
582 let mgr = AsyncDieselConnectionManager::<::autumn_web::RuntimeConnection>::new(
583 "postgresql://test",
584 );
585 Pool::builder(mgr).build().expect("build pool")
586 }
587
588 #[tokio::test]
589 async fn execute_action_restore_errors_soft_delete_unsupported() {
590 let model = TokenAdminModel;
591 let pool = dummy_pool();
592 let err = model
593 .execute_action(&pool, "restore", vec![1, 2])
594 .await
595 .expect_err("restore must fail: model does not support soft delete");
596 assert!(
597 matches!(err, AdminError::Other(_)),
598 "must be AdminError::Other: {err:?}"
599 );
600 assert!(format!("{err:?}").contains("does not support soft delete"));
601 }
602
603 #[tokio::test]
604 async fn execute_action_purge_errors_soft_delete_unsupported() {
605 let model = TokenAdminModel;
606 let pool = dummy_pool();
607 let err = model
608 .execute_action(&pool, "purge", vec![1])
609 .await
610 .expect_err("purge must fail: model does not support soft delete");
611 assert!(
612 matches!(err, AdminError::Other(_)),
613 "must be AdminError::Other: {err:?}"
614 );
615 assert!(format!("{err:?}").contains("does not support soft delete"));
616 }
617
618 #[tokio::test]
619 async fn execute_action_unhandled_action_errors_with_action_name() {
620 let model = TokenAdminModel;
621 let pool = dummy_pool();
622 let err = model
623 .execute_action(&pool, "archive", vec![1])
624 .await
625 .expect_err("unknown action must error");
626 assert!(
627 matches!(err, AdminError::Other(_)),
628 "must be AdminError::Other: {err:?}"
629 );
630 assert!(format!("{err:?}").contains("unhandled bulk action 'archive'"));
631 }
632}