1use serde_json::Value;
2use sqlx::Row;
3use sqlx::sqlite::SqliteRow;
4use tracing::{debug, info};
5use uuid::Uuid;
6
7use crate::random::random_bytes;
8use crate::sqlite::db::Database;
9use crate::sqlite::nonce::now_secs;
10use crate::sqlite::order::rfc3339;
11
12#[derive(Debug)]
29pub struct Eab {
30 pub kid: String,
31 pub secret: Vec<u8>,
32 pub label: Option<String>,
33 pub profile: Option<String>,
36 pub status: String,
37 pub created_at: i64,
38}
39
40const SECRET_LEN: usize = 32;
43
44impl Eab {
45 fn from_row(row: SqliteRow) -> Result<Self, sqlx::Error> {
46 Ok(Eab {
47 kid: row.try_get("kid")?,
48 secret: row.try_get("secret")?,
49 label: row.try_get("label")?,
50 profile: row.try_get("profile")?,
51 status: row.try_get("status")?,
52 created_at: row.try_get("created_at")?,
53 })
54 }
55
56 pub async fn create(
61 label: Option<String>,
62 profile: Option<String>,
63 database: &Database,
64 ) -> Result<Eab, sqlx::Error> {
65 let eab = Eab {
66 kid: Uuid::new_v4().to_string(),
67 secret: random_bytes::<SECRET_LEN>().to_vec(),
68 label,
69 profile,
70 status: "active".to_string(),
71 created_at: now_secs(),
72 };
73
74 debug!(event = "db_eab_create_started", outcome = "progress", kid = ?eab.kid, profile = ?eab.profile);
75 sqlx::query(
76 "INSERT INTO eab_keys (kid, secret, label, profile, status, created_at) \
77 VALUES (?, ?, ?, ?, ?, ?);",
78 )
79 .bind(&eab.kid)
80 .bind(&eab.secret)
81 .bind(&eab.label)
82 .bind(&eab.profile)
83 .bind(&eab.status)
84 .bind(eab.created_at)
85 .execute(&database.pool)
86 .await?;
87
88 info!(event = "db_eab_created", outcome = "success", kid = ?eab.kid);
89 Ok(eab)
90 }
91
92 pub async fn find_by_kid(
96 kid: &str,
97 profile: &str,
98 database: &Database,
99 ) -> Result<Option<Eab>, sqlx::Error> {
100 debug!(event = "db_eab_find_by_kid_started", outcome = "progress", kid = ?kid, profile = %profile);
101 let row = sqlx::query(
102 "SELECT kid, secret, label, profile, status, created_at FROM eab_keys \
103 WHERE kid = ? AND (profile IS NULL OR profile = ?);",
104 )
105 .bind(kid)
106 .bind(profile)
107 .fetch_optional(&database.pool)
108 .await?;
109
110 row.map(Eab::from_row).transpose()
111 }
112
113 pub async fn find_any_by_kid(
118 kid: &str,
119 database: &Database,
120 ) -> Result<Option<Eab>, sqlx::Error> {
121 debug!(event = "db_eab_find_any_by_kid_started", outcome = "progress", kid = ?kid);
122 let row = sqlx::query(
123 "SELECT kid, secret, label, profile, status, created_at FROM eab_keys WHERE kid = ?;",
124 )
125 .bind(kid)
126 .fetch_optional(&database.pool)
127 .await?;
128
129 row.map(Eab::from_row).transpose()
130 }
131
132 pub async fn list_all(database: &Database) -> Result<Vec<Eab>, sqlx::Error> {
140 debug!(event = "db_eab_list_all_started", outcome = "progress");
141 let rows = sqlx::query(
142 "SELECT kid, secret, label, profile, status, created_at FROM eab_keys \
143 ORDER BY created_at ASC, kid ASC;",
144 )
145 .fetch_all(&database.pool)
146 .await?;
147
148 rows.into_iter().map(Eab::from_row).collect()
149 }
150
151 pub async fn search(
161 limit: i64,
162 offset: i64,
163 database: &Database,
164 ) -> Result<(Vec<Eab>, i64), sqlx::Error> {
165 debug!(
166 event = "db_eab_search_started",
167 outcome = "progress",
168 limit = limit,
169 offset = offset
170 );
171 let rows = sqlx::query(
172 "SELECT kid, secret, label, profile, status, created_at FROM eab_keys \
173 ORDER BY created_at ASC, kid ASC LIMIT ? OFFSET ?;",
174 )
175 .bind(limit)
176 .bind(offset)
177 .fetch_all(&database.pool)
178 .await?;
179 let total: i64 = sqlx::query("SELECT COUNT(*) FROM eab_keys;")
180 .fetch_one(&database.pool)
181 .await?
182 .try_get(0)?;
183
184 let keys = rows
185 .into_iter()
186 .map(Eab::from_row)
187 .collect::<Result<_, _>>()?;
188 Ok((keys, total))
189 }
190
191 pub async fn revoke(kid: &str, database: &Database) -> Result<bool, sqlx::Error> {
196 debug!(event = "db_eab_revoke_started", outcome = "progress", kid = ?kid);
197 let result = sqlx::query("UPDATE eab_keys SET status = 'revoked' WHERE kid = ?;")
198 .bind(kid)
199 .execute(&database.pool)
200 .await?;
201
202 let updated = result.rows_affected() > 0;
203 if updated {
204 info!(event = "db_eab_revoked", outcome = "success", kid = ?kid);
205 } else {
206 debug!(event = "db_eab_revoke_missing", outcome = "success", kid = ?kid);
207 }
208 Ok(updated)
209 }
210
211 #[must_use]
213 pub fn is_active(&self) -> bool {
214 self.status == "active"
215 }
216
217 #[must_use]
223 pub fn to_json(&self) -> Value {
224 serde_json::json!({
225 "kid": self.kid,
226 "label": self.label,
227 "profile": self.profile,
228 "status": self.status,
229 "createdAt": rfc3339(self.created_at),
230 })
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use std::sync::Arc;
238
239 #[tokio::test]
240 async fn create_persists_an_active_key_with_a_32_byte_secret() {
241 let db = Arc::new(Database::connect_in_memory().await.unwrap());
242 let eab = Eab::create(Some("team-a".to_string()), None, &db)
243 .await
244 .unwrap();
245 assert_eq!(eab.status, "active");
246 assert_eq!(eab.secret.len(), 32);
247 assert_eq!(eab.label.as_deref(), Some("team-a"));
248 }
249
250 #[tokio::test]
251 async fn find_by_kid_round_trip() {
252 let db = Arc::new(Database::connect_in_memory().await.unwrap());
253 let created = Eab::create(None, None, &db).await.unwrap();
254 let found = Eab::find_by_kid(&created.kid, "default", &db)
255 .await
256 .unwrap()
257 .unwrap();
258 assert_eq!(found.secret, created.secret);
259 assert!(found.label.is_none());
260 }
261
262 #[tokio::test]
263 async fn find_by_kid_of_unknown_returns_none() {
264 let db = Arc::new(Database::connect_in_memory().await.unwrap());
265 assert!(
266 Eab::find_by_kid("nope", "default", &db)
267 .await
268 .unwrap()
269 .is_none()
270 );
271 }
272
273 #[tokio::test]
284 async fn list_all_returns_every_key_and_empty_is_empty() {
285 let db = Arc::new(Database::connect_in_memory().await.unwrap());
286 assert!(Eab::list_all(&db).await.unwrap().is_empty());
287
288 let first = Eab::create(None, None, &db).await.unwrap();
289 let second = Eab::create(None, None, &db).await.unwrap();
290 let all = Eab::list_all(&db).await.unwrap();
291 assert_eq!(all.len(), 2);
292 for expected in [&first.kid, &second.kid] {
293 assert!(
294 all.iter().any(|eab| eab.kid == *expected),
295 "{expected} was not listed"
296 );
297 }
298 }
299
300 #[tokio::test]
305 async fn search_pages_without_overlap_and_reports_the_unpaged_total() {
306 let db = Arc::new(Database::connect_in_memory().await.unwrap());
307 assert_eq!(Eab::search(50, 0, &db).await.unwrap().1, 0);
308
309 let created: Vec<String> = {
310 let mut kids = Vec::new();
311 for _ in 0..5 {
312 kids.push(Eab::create(None, None, &db).await.unwrap().kid);
313 }
314 kids
315 };
316
317 let (first, total) = Eab::search(2, 0, &db).await.unwrap();
318 let (second, also_total) = Eab::search(2, 2, &db).await.unwrap();
319 let (third, _) = Eab::search(2, 4, &db).await.unwrap();
320
321 assert_eq!(total, 5);
322 assert_eq!(also_total, 5, "the total is the table, not the page");
323 assert_eq!((first.len(), second.len(), third.len()), (2, 2, 1));
324
325 let walked: Vec<String> = first
328 .iter()
329 .chain(second.iter())
330 .chain(third.iter())
331 .map(|eab| eab.kid.clone())
332 .collect();
333 assert_eq!(walked.len(), created.len());
334 for kid in &created {
335 assert_eq!(
336 walked.iter().filter(|seen| *seen == kid).count(),
337 1,
338 "{kid} was not on exactly one page"
339 );
340 }
341 }
342
343 #[tokio::test]
347 async fn search_reads_the_table_in_the_same_order_as_list_all() {
348 let db = Arc::new(Database::connect_in_memory().await.unwrap());
349 for _ in 0..3 {
350 Eab::create(None, None, &db).await.unwrap();
351 }
352
353 let unpaged: Vec<String> = Eab::list_all(&db)
354 .await
355 .unwrap()
356 .into_iter()
357 .map(|eab| eab.kid)
358 .collect();
359 let paged: Vec<String> = Eab::search(50, 0, &db)
360 .await
361 .unwrap()
362 .0
363 .into_iter()
364 .map(|eab| eab.kid)
365 .collect();
366
367 assert_eq!(paged, unpaged);
368 }
369
370 #[tokio::test]
371 async fn revoke_marks_revoked_reports_true_and_is_idempotent() {
372 let db = Arc::new(Database::connect_in_memory().await.unwrap());
373 let eab = Eab::create(None, None, &db).await.unwrap();
374 assert!(Eab::revoke(&eab.kid, &db).await.unwrap());
375 assert!(
376 !Eab::find_by_kid(&eab.kid, "default", &db)
377 .await
378 .unwrap()
379 .unwrap()
380 .is_active()
381 );
382 assert!(Eab::revoke(&eab.kid, &db).await.unwrap());
384 }
385
386 #[tokio::test]
387 async fn revoke_of_unknown_kid_reports_false() {
388 let db = Arc::new(Database::connect_in_memory().await.unwrap());
389 assert!(!Eab::revoke("nope", &db).await.unwrap());
390 }
391
392 #[tokio::test]
393 async fn to_json_never_includes_the_secret() {
394 let db = Arc::new(Database::connect_in_memory().await.unwrap());
395 let eab = Eab::create(Some("x".to_string()), None, &db).await.unwrap();
396 let json = eab.to_json();
397 assert!(json.get("secret").is_none());
398 assert!(json.get("hmacKey").is_none());
399 assert_eq!(json["kid"], eab.kid);
400 assert_eq!(json["status"], "active");
401 assert_eq!(json["label"], "x");
402 }
403}