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