1use sqlx::Row;
2use sqlx::sqlite::SqliteRow;
3use tracing::{info, warn};
4
5use crate::sqlite::db::Database;
6use crate::sqlite::nonce::now_secs;
7use crate::sqlite::order::rfc3339;
8
9#[derive(Debug, Clone)]
25pub struct AdminRecoveryCode {
26 pub id: String,
27 pub user_id: String,
28 pub code_hash: String,
30 pub created_at: i64,
31 pub used_at: Option<i64>,
33}
34
35impl AdminRecoveryCode {
36 fn from_row(row: SqliteRow) -> Result<Self, sqlx::Error> {
37 Ok(AdminRecoveryCode {
38 id: row.try_get("id")?,
39 user_id: row.try_get("user_id")?,
40 code_hash: row.try_get("code_hash")?,
41 created_at: row.try_get("created_at")?,
42 used_at: row.try_get("used_at")?,
43 })
44 }
45
46 pub async fn replace_all(
57 user_id: &str,
58 hashes: &[String],
59 database: &Database,
60 ) -> Result<(), sqlx::Error> {
61 let now = now_secs();
62 let mut tx = database.pool.begin().await?;
63
64 sqlx::query("DELETE FROM admin_recovery_codes WHERE user_id = ?;")
65 .bind(user_id)
66 .execute(&mut *tx)
67 .await?;
68
69 for hash in hashes {
70 sqlx::query(
71 "INSERT INTO admin_recovery_codes (id, user_id, code_hash, created_at, used_at) \
72 VALUES (?, ?, ?, ?, NULL);",
73 )
74 .bind(uuid::Uuid::new_v4().to_string())
75 .bind(user_id)
76 .bind(hash)
77 .bind(now)
78 .execute(&mut *tx)
79 .await?;
80 }
81
82 tx.commit().await?;
83
84 info!(
85 event = "db_admin_recovery_codes_replaced",
86 outcome = "success",
87 user_id = %user_id,
88 minted = hashes.len()
89 );
90 Ok(())
91 }
92
93 pub async fn list_unused(
96 user_id: &str,
97 database: &Database,
98 ) -> Result<Vec<AdminRecoveryCode>, sqlx::Error> {
99 let rows = sqlx::query(
100 "SELECT id, user_id, code_hash, created_at, used_at \
101 FROM admin_recovery_codes WHERE user_id = ? AND used_at IS NULL \
102 ORDER BY created_at ASC, id ASC;",
103 )
104 .bind(user_id)
105 .fetch_all(&database.pool)
106 .await?;
107
108 rows.into_iter().map(AdminRecoveryCode::from_row).collect()
109 }
110
111 pub async fn count_unused(user_id: &str, database: &Database) -> Result<i64, sqlx::Error> {
114 let row = sqlx::query(
115 "SELECT COUNT(*) AS total FROM admin_recovery_codes \
116 WHERE user_id = ? AND used_at IS NULL;",
117 )
118 .bind(user_id)
119 .fetch_one(&database.pool)
120 .await?;
121
122 row.try_get("total")
123 }
124
125 pub async fn consume(id: &str, database: &Database) -> Result<bool, sqlx::Error> {
135 let result = sqlx::query(
136 "UPDATE admin_recovery_codes SET used_at = ? WHERE id = ? AND used_at IS NULL;",
137 )
138 .bind(now_secs())
139 .bind(id)
140 .execute(&database.pool)
141 .await?;
142
143 let consumed = result.rows_affected() == 1;
144 if !consumed {
145 warn!(event = "db_admin_recovery_code_already_used", outcome = "failure", code_id = %id);
149 }
150 Ok(consumed)
151 }
152
153 pub async fn delete_for_user(user_id: &str, database: &Database) -> Result<u64, sqlx::Error> {
157 let result = sqlx::query("DELETE FROM admin_recovery_codes WHERE user_id = ?;")
158 .bind(user_id)
159 .execute(&database.pool)
160 .await?;
161
162 Ok(result.rows_affected())
163 }
164
165 #[must_use]
168 pub fn to_json(&self) -> serde_json::Value {
169 serde_json::json!({
170 "id": self.id,
171 "createdAt": rfc3339(self.created_at),
172 "usedAt": self.used_at.map(rfc3339),
173 })
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180 use crate::sqlite::admin_user::AdminUser;
181 use std::sync::Arc;
182
183 async fn db_with_user() -> (Arc<Database>, AdminUser) {
184 let db = Arc::new(Database::connect_in_memory().await.unwrap());
185 let user = AdminUser::create("alice", "hash", &db).await.unwrap();
186 (db, user)
187 }
188
189 fn hashes(count: usize) -> Vec<String> {
190 (0..count).map(|index| format!("hash-{index}")).collect()
191 }
192
193 #[tokio::test]
194 async fn replace_all_mints_a_set_and_supersedes_the_previous_one() {
195 let (db, user) = db_with_user().await;
196
197 AdminRecoveryCode::replace_all(&user.id, &hashes(10), &db)
198 .await
199 .unwrap();
200 assert_eq!(
201 AdminRecoveryCode::count_unused(&user.id, &db)
202 .await
203 .unwrap(),
204 10
205 );
206
207 let first = AdminRecoveryCode::list_unused(&user.id, &db).await.unwrap()[0]
211 .id
212 .clone();
213 assert!(AdminRecoveryCode::consume(&first, &db).await.unwrap());
214 assert_eq!(
215 AdminRecoveryCode::count_unused(&user.id, &db)
216 .await
217 .unwrap(),
218 9
219 );
220
221 AdminRecoveryCode::replace_all(&user.id, &hashes(10), &db)
222 .await
223 .unwrap();
224 let after = AdminRecoveryCode::list_unused(&user.id, &db).await.unwrap();
225 assert_eq!(after.len(), 10);
226 assert!(
227 after.iter().all(|code| code.id != first),
228 "no row of the superseded set may survive"
229 );
230 }
231
232 #[tokio::test]
235 async fn a_code_can_be_consumed_exactly_once() {
236 let (db, user) = db_with_user().await;
237 AdminRecoveryCode::replace_all(&user.id, &hashes(3), &db)
238 .await
239 .unwrap();
240
241 let code = AdminRecoveryCode::list_unused(&user.id, &db).await.unwrap()[0].clone();
242
243 assert!(AdminRecoveryCode::consume(&code.id, &db).await.unwrap());
244 assert!(
245 !AdminRecoveryCode::consume(&code.id, &db).await.unwrap(),
246 "a second consumption of one code must fail, whatever raced it"
247 );
248 assert!(
249 !AdminRecoveryCode::consume("no-such-code", &db)
250 .await
251 .unwrap()
252 );
253
254 let remaining = AdminRecoveryCode::list_unused(&user.id, &db).await.unwrap();
256 assert_eq!(remaining.len(), 2);
257 assert!(remaining.iter().all(|other| other.id != code.id));
258 }
259
260 #[tokio::test]
261 async fn deleting_the_operator_cascades_to_their_codes() {
262 let (db, user) = db_with_user().await;
263 AdminRecoveryCode::replace_all(&user.id, &hashes(10), &db)
264 .await
265 .unwrap();
266
267 assert!(AdminUser::delete(&user.id, &db).await.unwrap());
268 assert_eq!(
269 AdminRecoveryCode::count_unused(&user.id, &db)
270 .await
271 .unwrap(),
272 0
273 );
274 }
275
276 #[tokio::test]
277 async fn delete_for_user_removes_the_whole_set() {
278 let (db, user) = db_with_user().await;
279 AdminRecoveryCode::replace_all(&user.id, &hashes(10), &db)
280 .await
281 .unwrap();
282
283 assert_eq!(
284 AdminRecoveryCode::delete_for_user(&user.id, &db)
285 .await
286 .unwrap(),
287 10
288 );
289 assert_eq!(
290 AdminRecoveryCode::count_unused(&user.id, &db)
291 .await
292 .unwrap(),
293 0
294 );
295 }
296
297 #[tokio::test]
298 async fn to_json_leaks_no_hash() {
299 let (db, user) = db_with_user().await;
300 AdminRecoveryCode::replace_all(&user.id, &["a-secret-hash".to_string()], &db)
301 .await
302 .unwrap();
303
304 let code = AdminRecoveryCode::list_unused(&user.id, &db).await.unwrap()[0].clone();
305 let rendered = code.to_json().to_string();
306
307 assert!(!rendered.contains("a-secret-hash"));
308 assert!(!rendered.contains("codeHash"));
309 assert!(rendered.contains(&code.id));
310 assert!(rendered.contains("\"usedAt\":null"));
311 }
312}