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)]
32pub struct TokenAdminModel;
33
34impl AdminModel for TokenAdminModel {
35 fn slug(&self) -> &'static str {
36 "api-tokens"
37 }
38
39 fn display_name(&self) -> &'static str {
40 "API Token"
41 }
42
43 fn display_name_plural(&self) -> &'static str {
44 "API Tokens"
45 }
46
47 fn fields(&self) -> Vec<AdminField> {
48 vec![
49 AdminField::new("name", AdminFieldKind::Text)
50 .label("Name")
51 .searchable(),
52 AdminField::new("principal_id", AdminFieldKind::Text)
53 .label("Principal")
54 .searchable()
55 .create_only(),
56 AdminField::new("scopes", AdminFieldKind::TextArea)
57 .label("Scopes (JSON array)")
58 .optional(),
59 AdminField::new("expires_at", AdminFieldKind::DateTime)
60 .label("Expires At (UTC)")
61 .optional()
62 .create_only(),
63 AdminField::new("last_used_at", AdminFieldKind::DateTime)
64 .label("Last Used")
65 .readonly()
66 .optional()
67 .hide_from_list(),
68 AdminField::new("revoked_at", AdminFieldKind::DateTime)
69 .label("Revoked At")
70 .readonly()
71 .optional(),
72 AdminField::new("token", AdminFieldKind::Text)
75 .label("Token (copy now — shown once)")
76 .readonly()
77 .optional()
78 .hide_from_list(),
79 ]
80 }
81
82 fn record_display(&self, record: &Value) -> String {
83 record
84 .get("name")
85 .and_then(Value::as_str)
86 .filter(|n| !n.is_empty())
87 .map_or_else(|| "API Token".to_owned(), |n| format!("Token: {n}"))
88 }
89
90 fn list(
91 &self,
92 pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
93 params: ListParams,
94 ) -> AdminFuture<'_, ListResult> {
95 use diesel_async::RunQueryDsl;
96
97 let pool = pool.clone();
98 Box::pin(async move {
99 let mut conn = pool
100 .get()
101 .await
102 .map_err(|e| AdminError::Database(e.to_string()))?;
103
104 let per_page = params.per_page;
105 let offset = if per_page == 0 {
106 0
107 } else {
108 params.page.saturating_sub(1) * per_page
109 };
110 let limit = if per_page == 0 {
111 i64::MAX
112 } else {
113 i64::try_from(per_page).unwrap_or(i64::MAX)
114 };
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, _>(i64::try_from(offset).unwrap_or(0))
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<diesel_async::AsyncPgConnection>,
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 let mut conn = pool
162 .get()
163 .await
164 .map_err(|e| AdminError::Database(e.to_string()))?;
165 diesel::sql_query(
166 "SELECT id, name, principal_id, scopes::text AS scopes, created_at, \
167 expires_at, last_used_at, revoked_at \
168 FROM api_tokens WHERE id = $1",
169 )
170 .bind::<diesel::sql_types::BigInt, _>(id)
171 .get_result::<TokenRow>(&mut conn)
172 .await
173 .optional()
174 .map(|r| r.map(TokenRow::into_json))
175 .map_err(|e| AdminError::Database(e.to_string()))
176 })
177 }
178
179 fn create(
180 &self,
181 pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
182 data: Value,
183 ) -> AdminFuture<'_, Value> {
184 use diesel_async::RunQueryDsl;
185
186 let pool = pool.clone();
187 Box::pin(async move {
188 let principal_id = data
189 .get("principal_id")
190 .and_then(Value::as_str)
191 .filter(|p| !p.is_empty())
192 .ok_or_else(|| AdminError::Validation("'principal_id' is required".into()))?;
193 let name = data.get("name").and_then(Value::as_str).unwrap_or("");
194 let scopes = parse_scopes(data.get("scopes"))?;
195 let expires_at = parse_expires_at(data.get("expires_at"))?;
196
197 let raw = generate_raw_token();
198 let hash = hash_api_token(&raw);
199 let scopes_json = serde_json::Value::Array(
200 scopes
201 .iter()
202 .map(|s| serde_json::Value::String(s.clone()))
203 .collect(),
204 )
205 .to_string();
206 let expires_at_naive = expires_at.map(|dt| dt.naive_utc());
207
208 let mut conn = pool
209 .get()
210 .await
211 .map_err(|e| AdminError::Database(e.to_string()))?;
212 let row: TokenRow = diesel::sql_query(
217 "INSERT INTO api_tokens (token_hash, principal_id, name, scopes, expires_at) \
218 VALUES ($1, $2, $3, $4::jsonb, $5) \
219 RETURNING id, name, principal_id, scopes::text AS scopes, \
220 created_at, expires_at, last_used_at, revoked_at",
221 )
222 .bind::<diesel::sql_types::Text, _>(&hash)
223 .bind::<diesel::sql_types::Text, _>(principal_id)
224 .bind::<diesel::sql_types::Text, _>(name)
225 .bind::<diesel::sql_types::Text, _>(&scopes_json)
226 .bind::<diesel::sql_types::Nullable<diesel::sql_types::Timestamp>, _>(expires_at_naive)
227 .get_result::<TokenRow>(&mut conn)
228 .await
229 .map_err(|e| AdminError::Database(e.to_string()))?;
230
231 let mut json = row.into_json();
232 if let Value::Object(map) = &mut json {
233 map.insert("token".to_owned(), Value::String(raw));
234 }
235 Ok(json)
236 })
237 }
238
239 fn update(
240 &self,
241 pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
242 id: i64,
243 data: Value,
244 ) -> AdminFuture<'_, Value> {
245 use diesel_async::RunQueryDsl;
246
247 let pool = pool.clone();
248 Box::pin(async move {
249 let name = data.get("name").and_then(Value::as_str).unwrap_or("");
252 let scopes = parse_scopes(data.get("scopes"))?;
253 let scopes_json =
254 serde_json::Value::Array(scopes.into_iter().map(Value::String).collect())
255 .to_string();
256
257 let mut conn = pool
258 .get()
259 .await
260 .map_err(|e| AdminError::Database(e.to_string()))?;
261 diesel::sql_query("UPDATE api_tokens SET name = $1, scopes = $2::jsonb WHERE id = $3")
262 .bind::<diesel::sql_types::Text, _>(name)
263 .bind::<diesel::sql_types::Text, _>(&scopes_json)
264 .bind::<diesel::sql_types::BigInt, _>(id)
265 .execute(&mut conn)
266 .await
267 .map_err(|e| AdminError::Database(e.to_string()))?;
268
269 self.get(&pool, id)
270 .await?
271 .ok_or_else(|| AdminError::Validation(format!("token {id} not found")))
272 })
273 }
274
275 fn delete(
276 &self,
277 pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
278 id: i64,
279 ) -> AdminFuture<'_, ()> {
280 use diesel_async::RunQueryDsl;
281
282 let pool = pool.clone();
283 Box::pin(async move {
284 let mut conn = pool
287 .get()
288 .await
289 .map_err(|e| AdminError::Database(e.to_string()))?;
290 diesel::sql_query(
291 "UPDATE api_tokens SET revoked_at = NOW() AT TIME ZONE 'utc' \
292 WHERE id = $1 AND revoked_at IS NULL",
293 )
294 .bind::<diesel::sql_types::BigInt, _>(id)
295 .execute(&mut conn)
296 .await
297 .map_err(|e| AdminError::Database(e.to_string()))?;
298 Ok(())
299 })
300 }
301}
302
303fn parse_scopes(value: Option<&Value>) -> Result<Vec<String>, AdminError> {
306 match value {
307 None | Some(Value::Null) => Ok(Vec::new()),
308 Some(v @ Value::Array(_)) => Ok(scopes_from_json(v)),
309 Some(Value::String(s)) => {
310 let trimmed = s.trim();
311 if trimmed.is_empty() {
312 return Ok(Vec::new());
313 }
314 let parsed: Vec<String> = serde_json::from_str(trimmed).map_err(|_| {
315 AdminError::Validation(
316 "'scopes' must be a JSON array of strings, e.g. [\"posts:read\"]".into(),
317 )
318 })?;
319 Ok(parsed)
320 }
321 Some(_) => Err(AdminError::Validation(
322 "'scopes' must be a JSON array of strings".into(),
323 )),
324 }
325}
326
327fn parse_expires_at(value: Option<&Value>) -> Result<Option<DateTime<Utc>>, AdminError> {
329 let Some(s) = value.and_then(Value::as_str) else {
330 return Ok(None);
331 };
332 let s = s.trim();
333 if s.is_empty() {
334 return Ok(None);
335 }
336 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
337 return Ok(Some(dt.with_timezone(&Utc)));
338 }
339 for fmt in ["%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M:%S"] {
340 if let Ok(naive) = NaiveDateTime::parse_from_str(s, fmt) {
341 return Ok(Some(DateTime::from_naive_utc_and_offset(naive, Utc)));
342 }
343 }
344 Err(AdminError::Validation(format!(
345 "'expires_at' is not a valid timestamp: {s}"
346 )))
347}
348
349#[derive(diesel::QueryableByName)]
350struct CountRow {
351 #[diesel(sql_type = diesel::sql_types::BigInt)]
352 count: i64,
353}
354
355#[derive(diesel::QueryableByName)]
356struct TokenRow {
357 #[diesel(sql_type = diesel::sql_types::BigInt)]
358 id: i64,
359 #[diesel(sql_type = diesel::sql_types::Text)]
360 name: String,
361 #[diesel(sql_type = diesel::sql_types::Text)]
362 principal_id: String,
363 #[diesel(sql_type = diesel::sql_types::Text)]
364 scopes: String,
365 #[diesel(sql_type = diesel::sql_types::Timestamp)]
366 created_at: NaiveDateTime,
367 #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Timestamp>)]
368 expires_at: Option<NaiveDateTime>,
369 #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Timestamp>)]
370 last_used_at: Option<NaiveDateTime>,
371 #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Timestamp>)]
372 revoked_at: Option<NaiveDateTime>,
373}
374
375impl TokenRow {
376 fn into_json(self) -> Value {
377 let scopes: Value = serde_json::from_str(&self.scopes).unwrap_or(Value::Array(Vec::new()));
378 serde_json::json!({
379 "id": self.id,
380 "name": self.name,
381 "principal_id": self.principal_id,
382 "scopes": scopes,
383 "created_at": self.created_at.and_utc().to_rfc3339(),
384 "expires_at": self.expires_at.map(|d| d.and_utc().to_rfc3339()),
385 "last_used_at": self.last_used_at.map(|d| d.and_utc().to_rfc3339()),
386 "revoked_at": self.revoked_at.map(|d| d.and_utc().to_rfc3339()),
387 })
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn model_metadata() {
397 let m = TokenAdminModel;
398 assert_eq!(m.slug(), "api-tokens");
399 assert_eq!(m.display_name_plural(), "API Tokens");
400 let field_names: Vec<_> = m.fields().iter().map(|f| f.name).collect();
401 assert!(field_names.contains(&"scopes"));
402 assert!(field_names.contains(&"principal_id"));
403 assert!(!field_names.contains(&"token_hash"));
405 }
406
407 #[test]
408 fn record_display_uses_name_when_present() {
409 let m = TokenAdminModel;
410 let with_name = serde_json::json!({"name": "ci-token"});
411 assert_eq!(m.record_display(&with_name), "Token: ci-token");
412 }
413
414 #[test]
415 fn record_display_falls_back_when_name_missing_or_empty() {
416 let m = TokenAdminModel;
417 assert_eq!(m.record_display(&serde_json::json!({})), "API Token");
418 assert_eq!(
419 m.record_display(&serde_json::json!({"name": ""})),
420 "API Token"
421 );
422 }
423
424 #[test]
425 fn parse_scopes_accepts_json_text_and_arrays() {
426 assert_eq!(parse_scopes(None).unwrap(), Vec::<String>::new());
427 assert_eq!(
429 parse_scopes(Some(&Value::Null)).unwrap(),
430 Vec::<String>::new()
431 );
432 assert_eq!(
434 parse_scopes(Some(&Value::String(String::new()))).unwrap(),
435 Vec::<String>::new()
436 );
437 assert_eq!(
438 parse_scopes(Some(&Value::String("[\"a\",\"b\"]".into()))).unwrap(),
439 vec!["a".to_owned(), "b".to_owned()]
440 );
441 assert_eq!(
442 parse_scopes(Some(&serde_json::json!(["x"]))).unwrap(),
443 vec!["x".to_owned()]
444 );
445 assert!(parse_scopes(Some(&Value::String("not json".into()))).is_err());
446 assert!(parse_scopes(Some(&Value::Bool(true))).is_err());
448 }
449
450 #[test]
451 fn parse_expires_at_handles_formats_and_empty() {
452 assert!(parse_expires_at(None).unwrap().is_none());
453 assert!(
454 parse_expires_at(Some(&Value::String(String::new())))
455 .unwrap()
456 .is_none()
457 );
458 assert!(
460 parse_expires_at(Some(&Value::String("2026-12-31T23:59:59Z".into())))
461 .unwrap()
462 .is_some()
463 );
464 assert!(
466 parse_expires_at(Some(&Value::String("2026-12-31T23:59:59".into())))
467 .unwrap()
468 .is_some()
469 );
470 assert!(
472 parse_expires_at(Some(&Value::String("2026-12-31T23:59".into())))
473 .unwrap()
474 .is_some()
475 );
476 assert!(parse_expires_at(Some(&Value::String("nonsense".into()))).is_err());
477 }
478
479 #[test]
480 fn token_row_into_json_omits_secret_and_parses_scopes() {
481 let row = TokenRow {
482 id: 7,
483 name: "ci".into(),
484 principal_id: "service:ci".into(),
485 scopes: "[\"posts:read\"]".into(),
486 created_at: NaiveDateTime::default(),
487 expires_at: None,
488 last_used_at: None,
489 revoked_at: None,
490 };
491 let json = row.into_json();
492 assert_eq!(json["id"], 7);
493 assert_eq!(json["scopes"], serde_json::json!(["posts:read"]));
494 assert!(json.get("token_hash").is_none());
495 assert!(json.get("token").is_none());
496 }
497}