Skip to main content

autumn_admin_plugin/
tokens.rs

1//! Admin panel model for scoped API tokens (`api_tokens`).
2//!
3//! Registers a management page at `/admin/api-tokens/` to create, list, and
4//! revoke scoped service tokens (issue #1158). The raw token is shown **once**
5//! at creation and is never stored or re-displayed; listing exposes only
6//! non-secret metadata (name, principal, scopes, expiry, last-used, revoked).
7
8use 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/// Admin panel model for scoped API tokens.
17///
18/// Register with the admin plugin to get a token management UI at
19/// `/admin/api-tokens/`:
20///
21/// ```rust,ignore
22/// use autumn_admin_plugin::{AdminPlugin, tokens::TokenAdminModel};
23///
24/// autumn_web::app()
25///     .plugin(AdminPlugin::new().register(TokenAdminModel::default()))
26///     .run()
27///     .await;
28/// ```
29///
30/// Requires the managed `api_tokens` table (run `autumn migrate`).
31#[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            // Only populated in the response from `create` — the raw token,
73            // surfaced once at issuance and never stored or re-displayed.
74            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            // Atomic INSERT RETURNING: the row's server-assigned columns (id,
213            // created_at, …) come back in the same round-trip as the INSERT so
214            // there is no window in which the token exists in the DB but its
215            // metadata has not been returned to the caller.
216            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            // A token's secret/principal are immutable; only the human-readable
250            // name and granted scopes are editable after issuance.
251            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            // "Delete" a token means revoke it: keep the audit row, stop it
285            // authenticating. Idempotent — re-revoking is a no-op.
286            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
303/// Parse the `scopes` form value (JSON-array text or a JSON array) into a flat
304/// list of scope strings.
305fn 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
327/// Parse the optional `expires_at` form value into a UTC instant.
328fn 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        // No secret column is ever a field except the one-time create token.
404        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        // Explicit JSON null → empty (same as absent)
428        assert_eq!(
429            parse_scopes(Some(&Value::Null)).unwrap(),
430            Vec::<String>::new()
431        );
432        // Empty string → empty
433        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        // Non-string, non-array, non-null → error
447        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        // RFC3339 with timezone
459        assert!(
460            parse_expires_at(Some(&Value::String("2026-12-31T23:59:59Z".into())))
461                .unwrap()
462                .is_some()
463        );
464        // Without timezone (naive datetime format)
465        assert!(
466            parse_expires_at(Some(&Value::String("2026-12-31T23:59:59".into())))
467                .unwrap()
468                .is_some()
469        );
470        // Minute-precision format
471        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}